Lotsa stuff
This commit is contained in:
parent
df4d0efe33
commit
d7bd00c3b6
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,2 +1,5 @@
|
|||
__pycache__
|
||||
.mypy_cache
|
||||
build
|
||||
dist
|
||||
door_pi_control.egg-info/
|
||||
|
|
|
@ -1,488 +1,55 @@
|
|||
#!/usr/bin/python
|
||||
import argparse
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import select
|
||||
import socket
|
||||
from . import nfc, socket, door, bell
|
||||
from .util import init_logging
|
||||
|
||||
from collections import namedtuple
|
||||
import paho.mqtt.client as mqcl
|
||||
import serial
|
||||
|
||||
UPDATE_RATE = 2
|
||||
MAX_UPDATE_RATE = 20
|
||||
ERROR_THRESHOLD = 250
|
||||
OPEN_THRESHOLD = 190
|
||||
CLOSED_THRESHOLD = 180
|
||||
CLOSED_WANT = 50
|
||||
MIN_IDLE_TIME = 1
|
||||
COMMAND_IDLE_TIME = 1.5
|
||||
|
||||
def timestamp():
|
||||
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
class MqttValue:
|
||||
def __init__(self, client, topic, persistent = False, start_value = None, *, translate = None, max_update = 0.1):
|
||||
self.client = client
|
||||
self.topic = topic
|
||||
self.persistent = persistent
|
||||
self.value = start_value
|
||||
self.last_update = datetime.datetime.now() - datetime.timedelta(seconds = max_update)
|
||||
self.last_update_value = None
|
||||
self.max_update = max_update
|
||||
|
||||
if translate == None:
|
||||
self.translate = str
|
||||
elif callable(translate):
|
||||
self.translate = translate
|
||||
else:
|
||||
self.translate = lambda x: translate[x]
|
||||
|
||||
if start_value != None:
|
||||
self.update(start_value)
|
||||
|
||||
def update(self, value, force = False, no_update = False):
|
||||
if value != self.value or value != self.last_update_value:
|
||||
self.value = value
|
||||
if force or (not no_update and value != self.last_update_value and (datetime.datetime.now() - self.last_update).total_seconds() >= self.max_update):
|
||||
self.last_update = datetime.datetime.now()
|
||||
self.last_update_value = value
|
||||
self.client.publish(self.topic, self.translate(value), qos = 2, retain = self.persistent)
|
||||
|
||||
def force_update(self, value):
|
||||
self.update(value, force = True)
|
||||
|
||||
def __call__(self, value = None, **kwargs):
|
||||
if value != None:
|
||||
self.update(value, **kwargs)
|
||||
else:
|
||||
return self.value
|
||||
|
||||
class DoorControl:
|
||||
# Actions
|
||||
IDLE, CLOSE, OPEN_THEN_CLOSE, OPEN, CLOSE_THEN_OPEN, ERROR = range(6)
|
||||
state_names = {
|
||||
OPEN: "open",
|
||||
CLOSE: "close",
|
||||
ERROR: "error",
|
||||
}
|
||||
action_names = {
|
||||
IDLE: "idling",
|
||||
OPEN: "waiting for open door",
|
||||
CLOSE: "waiting for closed door",
|
||||
ERROR: "error",
|
||||
}
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
self.mqttc = mqcl.Client()
|
||||
self.logger = self._setup_logging(config)
|
||||
self._open_serial_port(config)
|
||||
self.valid_tokens = self._read_valid_tokens(config)
|
||||
self.nfc_fifo = self._open_nfc_fifo(config)
|
||||
self.control_socket, self.comm_channels = self._open_control_socket(config)
|
||||
|
||||
self.mqttc.on_connect = lambda client, userdata, flags, rc: self.logger.debug(f"Connected to mqtt host ({rc})")
|
||||
self.mqttc.connect_async(config.mqtt_host, keepalive=60)
|
||||
self.mqttc.loop_start()
|
||||
|
||||
# Current door state
|
||||
self.state = MqttValue(self.mqttc, "door/state/value", True, translate = self.state_names)
|
||||
self.target_state = MqttValue(self.mqttc, "door/state/target", True, DoorControl.CLOSE, translate = self.state_names, max_update = 0)
|
||||
self.state_pos = MqttValue(self.mqttc, "door/position/value", True, 0)
|
||||
self.last_invalid_token = MqttValue(self.mqttc, "door/token/last_invalid", True)
|
||||
self.speed = MqttValue(self.mqttc, "door/position/speed")
|
||||
# Current target action
|
||||
self.action = MqttValue(self.mqttc, "door/state/action", True, DoorControl.CLOSE, translate = self.action_names, max_update = 0)
|
||||
# Start time of the current action
|
||||
self.start_time = None
|
||||
# How often the action was repeated
|
||||
self.repeats = 0
|
||||
|
||||
self.last_handled_state = self.OPEN
|
||||
self.last_door_pos_time = datetime.datetime.now() - datetime.timedelta(minutes = 10)
|
||||
self.last_position = 0
|
||||
self.last_handle_state_timestamp = datetime.datetime.now()
|
||||
self.idle_start_time = None
|
||||
self.last_command_time = datetime.datetime.now() - datetime.timedelta(COMMAND_IDLE_TIME)
|
||||
|
||||
def idle_time(self):
|
||||
if self.idle_start_time != None:
|
||||
return (datetime.datetime.now() - self.idle_start_time).total_seconds()
|
||||
else:
|
||||
return 0
|
||||
|
||||
def _send_door_cmd(self, cmd: bytes):
|
||||
"""Send a command to the door."""
|
||||
if cmd != b'R': self.logger.debug(f"Sending {cmd}")
|
||||
return self.serial_port.write(cmd)
|
||||
|
||||
def _read_door_line(self):
|
||||
"""Read a single line from the serial port"""
|
||||
return self.serial_port.readline().decode('ascii')
|
||||
|
||||
def _setup_logging(self, config):
|
||||
"""Set up logging"""
|
||||
log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
logging.basicConfig(force = True,
|
||||
level = logging.DEBUG,
|
||||
format = log_format)
|
||||
|
||||
# create logger
|
||||
logger = logging.getLogger('nfc_log')
|
||||
|
||||
# create console handler and set level to debug
|
||||
fh = logging.FileHandler(config.log_file)
|
||||
fh.setLevel(logging.DEBUG)
|
||||
|
||||
# create formatter
|
||||
formatter = logging.Formatter(log_format)
|
||||
fh.setFormatter(formatter)
|
||||
logger.addHandler(fh)
|
||||
|
||||
return logger
|
||||
|
||||
def _open_control_socket(self, config):
|
||||
"""(Re-)creates and opens the control socket. Config must have a control_socket member."""
|
||||
self.logger.debug("Opening control socket")
|
||||
if os.path.exists(config.control_socket):
|
||||
os.unlink(config.control_socket)
|
||||
|
||||
control_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
control_socket.bind(config.control_socket)
|
||||
control_socket.listen(5)
|
||||
comm_channels = []
|
||||
|
||||
return control_socket, comm_channels
|
||||
|
||||
def _open_serial_port(self, config):
|
||||
"""Opens the serial port controlling the door opener. config must have a serial_port member."""
|
||||
try:
|
||||
self.serial_port = serial.Serial(config.serial_port, timeout=2)
|
||||
self._send_door_cmd(b'>r1\n')
|
||||
except:
|
||||
self.serial_port = None
|
||||
return self.serial_port
|
||||
|
||||
def _open_nfc_fifo(self, config):
|
||||
"""Opens config.nfc_fifo as the FIFO through which detected tokens are passed in."""
|
||||
nfc_fifo = open(config.nfc_fifo, "r")
|
||||
return nfc_fifo
|
||||
|
||||
def _read_valid_tokens(self, config):
|
||||
"""Refreshes all tokens from config.valid_tokens"""
|
||||
valid = {}
|
||||
try:
|
||||
self.logger.info("Loading tokens")
|
||||
lines =[ s.strip() for s in open(config.valid_tokens, "r").readlines() ]
|
||||
for i, line in enumerate(lines):
|
||||
l = line.split('|')
|
||||
if len(l) == 5:
|
||||
if not l[0].strip().startswith('#'):
|
||||
token, name, organization, email, valid_thru = l
|
||||
try:
|
||||
if len(valid_thru.strip()) > 0:
|
||||
valid_thru = datetime.date.fromisoformat(valid_thru)
|
||||
else:
|
||||
valid_thru = None
|
||||
except Exception:
|
||||
logging.error(f"Could not parse valid thru date for token {token} in line {i}")
|
||||
valid_thru = None
|
||||
logging.debug(f"Got token {token} associated with {name} <{email}> of {organization}, valid thru {valid_thru}")
|
||||
if token in valid:
|
||||
logging.warning(f"Overwriting token {token}")
|
||||
valid[token] = {'name': name, 'organization': organization, 'email': email,
|
||||
'valid_thru': valid_thru}
|
||||
else:
|
||||
logging.warning(f"Skipping line {i} ({line}) since it does not contain exactly 5 data field")
|
||||
except Exception as e:
|
||||
valid = {}
|
||||
logging.error(f"Error reading token file. Exception: {e}")
|
||||
|
||||
return valid
|
||||
|
||||
def _set_position(self, data: str):
|
||||
"""Set a new door position"""
|
||||
pos, speed = [ int(x) for x in data.strip().split() ]
|
||||
self.state_pos(pos)
|
||||
self.speed(speed)
|
||||
|
||||
if self.speed() != 0 and self.idle_start_time != None:
|
||||
self.idle_start_time = None
|
||||
elif self.speed() == 0 and self.idle_start_time == None:
|
||||
self.idle_start_time = datetime.datetime.now()
|
||||
|
||||
if pos > ERROR_THRESHOLD and self.state() != DoorControl.ERROR:
|
||||
self.logger.error("Invalid position:", state)
|
||||
self.state(DoorControl.ERROR)
|
||||
elif pos > OPEN_THRESHOLD and self.state() != DoorControl.OPEN:
|
||||
self.state(DoorControl.OPEN)
|
||||
elif pos < CLOSED_THRESHOLD and self.state() != DoorControl.CLOSE:
|
||||
self.state(DoorControl.CLOSE)
|
||||
|
||||
def _check_reporting(self, current: int):
|
||||
if current == 0:
|
||||
self.logger.info("Turning position reporting on")
|
||||
self._send_door_cmd(b'>r1\n')
|
||||
else:
|
||||
self.logger.info("Position reporting is on")
|
||||
|
||||
def handle_door_line(self):
|
||||
"""Reads a single line from the serial port and handles it"""
|
||||
data = self._read_door_line().strip()
|
||||
|
||||
handling = {
|
||||
"pos": (str, self._set_position),
|
||||
"pos reporting": (int, self._check_reporting),
|
||||
}
|
||||
|
||||
try:
|
||||
prefix, data = data.split(':')
|
||||
if prefix in handling:
|
||||
data_type, fun = handling[prefix]
|
||||
fun(data_type(data))
|
||||
except:
|
||||
pass
|
||||
|
||||
def poll_door_state(self):
|
||||
"""Checks the door state if the last polling was a while ago, and returns the current state"""
|
||||
t = (datetime.datetime.now() - self.last_door_pos_time).total_seconds()
|
||||
if t >= 0.5:
|
||||
self.last_door_pos_time = datetime.datetime.now()
|
||||
self._send_door_cmd(b"R")
|
||||
self.handle_door_line()
|
||||
return self.state()
|
||||
|
||||
def handle_door_state(self):
|
||||
"""Checks the door state and executes any actions necessary to reach the target state"""
|
||||
# Commands associated with each target state
|
||||
target_state_cmd = {
|
||||
DoorControl.OPEN: b'O',
|
||||
DoorControl.CLOSE: b'C'
|
||||
}
|
||||
|
||||
now = datetime.datetime.now()
|
||||
|
||||
old_state = self.last_handled_state
|
||||
delta_t = (now - self.last_handle_state_timestamp).total_seconds()
|
||||
|
||||
# If no serial port, try to open it or return
|
||||
if not self.serial_port:
|
||||
try:
|
||||
self._open_serial_port(self.config)
|
||||
except:
|
||||
return
|
||||
|
||||
self.poll_door_state()
|
||||
|
||||
if (self.idle_time() < MIN_IDLE_TIME) and delta_t < 5:
|
||||
return
|
||||
|
||||
# Get new state
|
||||
self.last_handled_state = self.state()
|
||||
self.last_handle_state_timestamp = now
|
||||
|
||||
if self.state() == DoorControl.ERROR:
|
||||
self.logger.error("Restarting the MCU and exiting.")
|
||||
self.send_door_cmd(b'S')
|
||||
return
|
||||
|
||||
# Idle + change = key?
|
||||
if self.action() == DoorControl.IDLE:
|
||||
if self.state() != old_state:
|
||||
self.logger.info(f"Door changed unexpectedly: {DoorControl.state_names[self.state()]}")
|
||||
self.target_state(self.state())
|
||||
self.start_time = now
|
||||
if self.state() == DoorControl.CLOSE \
|
||||
and self.start_time \
|
||||
and (now - self.start_time).total_seconds() >= self.config.state_timeout:
|
||||
self.start_time = None
|
||||
if self.state_pos() <= CLOSED_THRESHOLD and self.state_pos() > CLOSED_WANT:
|
||||
self.logger.info("Closing door a bit more")
|
||||
self._send_door_cmd(target_state_cmd[DoorControl.CLOSE])
|
||||
return
|
||||
|
||||
if self.start_time is None:
|
||||
self.start_time = now
|
||||
self._send_door_cmd(target_state_cmd[self.action()])
|
||||
|
||||
# Target state reached
|
||||
if self.state() == self.target_state():
|
||||
self.action(DoorControl.IDLE)
|
||||
self.logger.debug(f"Reached target position: {DoorControl.state_names[self.state()]}")
|
||||
self.repeats = 0
|
||||
self.start_time = now
|
||||
return
|
||||
elif self.state() != old_state:
|
||||
# Changed state, try to go to the target state again
|
||||
self.start_time = now
|
||||
self.action(old_state)
|
||||
self._send_door_cmd(target_state_cmd[self.action()])
|
||||
|
||||
# Execution time
|
||||
t = (now - self.start_time).total_seconds()
|
||||
if t >= self.config.state_timeout:
|
||||
# Timeout -> switch to timeout action
|
||||
if self.action() == DoorControl.OPEN:
|
||||
self.action(DoorControl.CLOSE)
|
||||
else:
|
||||
self.action(DoorControl.OPEN)
|
||||
self.start_time = None
|
||||
self.repeats = 0
|
||||
self.logger.debug(f"Timeout. Switching to {DoorControl.action_names[self.action()]}")
|
||||
elif t >= (1 + self.repeats) * self.config.repeat_time:
|
||||
# Repeat every couple of seconds
|
||||
self.repeats += 1
|
||||
self.logger.debug(f"Repeating command: {target_state_cmd[self.action()]}")
|
||||
self._send_door_cmd(target_state_cmd[self.action()])
|
||||
|
||||
def open_door(self):
|
||||
self.logger.info("Opening the door")
|
||||
self.target_state(DoorControl.OPEN)
|
||||
if self.action() == DoorControl.IDLE:
|
||||
self.action(DoorControl.OPEN)
|
||||
self.start_time = None
|
||||
self.handle_door_state()
|
||||
|
||||
def close_door(self):
|
||||
self.logger.info("Closing the door")
|
||||
self.target_state(DoorControl.CLOSE)
|
||||
if self.action() == DoorControl.IDLE:
|
||||
self.action(DoorControl.CLOSE)
|
||||
self.start_time = None
|
||||
self.handle_door_state()
|
||||
|
||||
def toggle_door_state(self):
|
||||
if self.action() != DoorControl.IDLE:
|
||||
return
|
||||
if self.target_state() == DoorControl.CLOSE:
|
||||
self.open_door()
|
||||
else:
|
||||
self.close_door()
|
||||
|
||||
def handle_nfc_token(self, token = None):
|
||||
if not token:
|
||||
token = self.nfc_fifo.readline().strip()
|
||||
self.logger.debug(f"Token from nfc_fifo: {token}")
|
||||
if token == "":
|
||||
self.logger.debug("Opening nfc_fifo")
|
||||
self.nfc_fifo = self._open_nfc_fifo(self.config)
|
||||
return
|
||||
|
||||
token = token.strip()
|
||||
if token in self.valid_tokens:
|
||||
data = self.valid_tokens[token]
|
||||
|
||||
if data['valid_thru'] is not None:
|
||||
# if a valid thru date has been set we check if the token is still valid
|
||||
authorized = datetime.date.today() <= data['valid_thru']
|
||||
else:
|
||||
# otherwise we don't need to check
|
||||
authorized = True
|
||||
|
||||
if authorized:
|
||||
self.logger.info(f"Valid token {token} of {data['name']}")
|
||||
self.toggle_door_state()
|
||||
else:
|
||||
self.logger.warning(f"Token {token} of {data['name']} expired on {data['valid_thru']}")
|
||||
else:
|
||||
self.logger.warning(f"Invalid token: {token}")
|
||||
self.last_invalid_token(f"{timestamp()};{token}")
|
||||
|
||||
class LineBuffer(object):
|
||||
def __init__(self, f, handler):
|
||||
self.data = b''
|
||||
self.f = f
|
||||
self.handler = handler
|
||||
|
||||
def update(self):
|
||||
data = self.f.recv(1024)
|
||||
if not data:
|
||||
return False
|
||||
self.data += data
|
||||
d = self.data.split(b'\n')
|
||||
d, self.data = d[:-1], d[-1]
|
||||
for i in d:
|
||||
self.handler(self.f, i)
|
||||
return True
|
||||
|
||||
def handle_cmd(self, comm, data):
|
||||
cmd = data.decode('utf8').split()
|
||||
cmd, args = cmd[0], cmd[1:]
|
||||
self.logger.debug(f"Got command: {data}")
|
||||
|
||||
send = lambda x: comm.send(x.encode('utf8'))
|
||||
|
||||
if cmd == 'fake':
|
||||
self.logger.debug(f"Faking token {args[0]}")
|
||||
send("Handling token\n")
|
||||
self.handle_nfc_token(args[0])
|
||||
elif cmd == 'reset':
|
||||
self.logger.info("Resetting")
|
||||
send("Resetting MCU")
|
||||
self._send_door_cmd(b'S')
|
||||
elif cmd == 'open':
|
||||
if len(args) > 0:
|
||||
self.logger.info(f"Control socket opening door for {args[0]}")
|
||||
send("Opening door")
|
||||
self.open_door()
|
||||
else:
|
||||
send("Missing login")
|
||||
elif cmd == 'close':
|
||||
if len(args) > 0:
|
||||
self.logger.info(f"Control socket closing door for {args[0]}")
|
||||
send("Closing door")
|
||||
self.close_door()
|
||||
else:
|
||||
send("Missing login")
|
||||
elif cmd == 'rld':
|
||||
self.logger.debug("Reloading tokens")
|
||||
send("Reloading tokens")
|
||||
self.valid_tokens = self._read_valid_tokens(self.config)
|
||||
elif cmd == 'stat':
|
||||
send("Door status is %s, position is %d. Current action: %s (%g seconds ago)\n" % (
|
||||
DoorControl.state_names.get(self.state, "None"),
|
||||
self.state_pos.value,
|
||||
DoorControl.action_names[self.action()],
|
||||
(datetime.datetime.now() - self.start_time).total_seconds()))
|
||||
import logging
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
import logging
|
||||
import time
|
||||
|
||||
from .nfc import DoorControlNfc
|
||||
from .socket import DoorControlSocket
|
||||
from .door import Control
|
||||
from .util import init_logging, logger
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--serial_port", default="/dev/serial/by-id/usb-Imaginaerraum.de_DoorControl_43363220195053573A002C0-if01")
|
||||
parser.add_argument("--serial_port", default="/dev/serial/by-id/usb-Imaginaerraum.de_DoorControl_433632201350535727003F0-if01")
|
||||
parser.add_argument("--bell_port", default="/dev/ttyS2")
|
||||
parser.add_argument("--nfc_fifo", default="/tmp/nfc_fifo")
|
||||
parser.add_argument("--control_socket", default="/tmp/nfc.sock")
|
||||
parser.add_argument("--valid_tokens", default="/etc/door_tokens")
|
||||
parser.add_argument("--log_file", default="/tmp/nfc.log")
|
||||
parser.add_argument("--state_timeout", type=float, default=10)
|
||||
parser.add_argument("--state_timeout_speed", type=float, default=3)
|
||||
parser.add_argument("--repeat_time", type=float, default=COMMAND_IDLE_TIME)
|
||||
parser.add_argument("--mqtt_host", default="10.10.21.2")
|
||||
|
||||
|
||||
config = parser.parse_args()
|
||||
|
||||
dc = DoorControl(config)
|
||||
|
||||
buffers = {}
|
||||
while True:
|
||||
readable = select.select([ dc.serial_port.fileno(), dc.nfc_fifo, dc.control_socket ] + dc.comm_channels, [], [], 1 / UPDATE_RATE)[0]
|
||||
mqttc = mqcl.Client()
|
||||
mqttc.connect_async("10.10.21.2", keepalive=60)
|
||||
mqttc.loop_start()
|
||||
|
||||
for c in readable:
|
||||
if c == dc.serial_port.fileno():
|
||||
dc.handle_door_line()
|
||||
elif c == dc.nfc_fifo:
|
||||
dc.handle_nfc_token()
|
||||
elif c == dc.control_socket:
|
||||
dc.logger.debug("Got connection")
|
||||
sock = dc.control_socket.accept()[0]
|
||||
buffers[sock] = dc.LineBuffer(sock, dc.handle_cmd)
|
||||
dc.comm_channels += [sock]
|
||||
else:
|
||||
if not buffers[c].update():
|
||||
dc.logger.debug("Lost connection")
|
||||
del buffers[c]
|
||||
dc.comm_channels.remove(c)
|
||||
dc.handle_door_state()
|
||||
init_logging(level=logging.INFO, output_file=config.log_file)
|
||||
|
||||
logger().info("Starting control")
|
||||
control = Control(config, mqttc)
|
||||
control.start()
|
||||
|
||||
logger().info("Starting NFC")
|
||||
nfc = DoorControlNfc(config, control, mqttc)
|
||||
nfc.start()
|
||||
|
||||
logger().info("Starting socket")
|
||||
socket = DoorControlSocket(config, control, nfc)
|
||||
socket.start()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
logger().info("Starting bell")
|
||||
def door_is_open():
|
||||
return control.state() == control.target() == door.constants.state.OPEN
|
||||
bell_control = bell.Control(config.bell_port, door_is_open)
|
||||
bell_control.start()
|
||||
|
||||
while True:
|
||||
time.sleep(60)
|
||||
|
|
3
door_pi_control/__main__.py
Normal file
3
door_pi_control/__main__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
#!/usr/bin/python
|
||||
from . import main
|
||||
main()
|
179
door_pi_control/bell.py
Normal file
179
door_pi_control/bell.py
Normal file
|
@ -0,0 +1,179 @@
|
|||
from . import mqtt, util
|
||||
from .util import timestamp
|
||||
import time
|
||||
|
||||
import serial
|
||||
import serial.threaded
|
||||
import threading
|
||||
|
||||
msgs = {
|
||||
"push_1": bytes.fromhex("B0 A0 6F 08"),
|
||||
"push_2": bytes.fromhex("B5 A0 6F 18"),
|
||||
"release": bytes.fromhex("B2 A0 6F 08"),
|
||||
"voice_closed": bytes.fromhex("B5 02 60 08"),
|
||||
"ring_hs": bytes.fromhex("91 02 60 08"),
|
||||
"ring_meyer": bytes.fromhex("91 01 60 08"),
|
||||
"open_push": bytes.fromhex("96 a0 6f a4"),
|
||||
"open_release": bytes.fromhex("96 a0 6f a0"),
|
||||
"light_push": bytes.fromhex("bd a0 6f a4"),
|
||||
"light_release":bytes.fromhex("bd a0 6f a0"),
|
||||
}
|
||||
|
||||
class Reader(util.Loggable):
|
||||
def __init__(self, should_open_immediately_cb = None):
|
||||
self.tmp = bytes()
|
||||
self.data = []
|
||||
self.lock = threading.RLock()
|
||||
self.cv = threading.Condition(self.lock)
|
||||
|
||||
self.times = []
|
||||
self.start = None
|
||||
self.last_symbol_timestamp = None
|
||||
self.light_push_time = None
|
||||
|
||||
self.doing_light = False
|
||||
self.closed = False
|
||||
self.open_now_cb = should_open_immediately_cb
|
||||
|
||||
def connection_lost(self, exc):
|
||||
if exc:
|
||||
print(exc)
|
||||
traceback.print_exc()
|
||||
self._logger().info("Closed")
|
||||
self.closed = True
|
||||
|
||||
def connection_made(self, transport):
|
||||
# super(Reader, self).connection_made(transport)
|
||||
self.port = transport
|
||||
self._logger().info("Opened")
|
||||
|
||||
def chksum(self, x):
|
||||
return x[0] ^ x[1] ^ x[2] ^ x[3]
|
||||
|
||||
def write(self, data):
|
||||
with self.lock:
|
||||
self.port.write(bytes([0xA8]) + data + bytes([self.chksum(data), 0xA3]))
|
||||
|
||||
def data_received(self, data):
|
||||
self.tmp += data
|
||||
data= self.tmp
|
||||
# log(data)
|
||||
|
||||
while len(data) >= 7:
|
||||
while len(data) >= 7 and (data[0] != 0xA8 or data[-1] != 0xA3 or self.chksum(data[1:-2]) != data[-2]):
|
||||
if 0xA8 in data[1:]:
|
||||
next = data[1:].index(0xA8)
|
||||
data = data[1+next:]
|
||||
else:
|
||||
data = bytes()
|
||||
|
||||
if len(data) >= 7:
|
||||
msg = data[1:5]
|
||||
with self.cv:
|
||||
if len(self.data) and msg == self.data[-1]:
|
||||
pass
|
||||
#log("Repeats")
|
||||
else:
|
||||
for k,v in msgs.items():
|
||||
if v == msg:
|
||||
self._logger().debug("Known message: %s: %s" % (k, v.hex()))
|
||||
break
|
||||
else:
|
||||
self._logger().debug("Unknown message: %s" % (msg.hex(),))
|
||||
|
||||
self.data.append(data[1:5])
|
||||
self.cv.notify()
|
||||
|
||||
if msgs['light_push'] == msg and not self.light_push_time:
|
||||
self.light_push_time = datetime.datetime.now()
|
||||
elif msgs['light_release'] == msg and self.light_push_time != None:
|
||||
dt = (datetime.datetime.now() - self.light_push_time).total_seconds()
|
||||
self.light_push_time = None
|
||||
self._logger().debug(f"Light time: {dt}")
|
||||
if dt >= 3:
|
||||
self.doing_light = not self.doing_light
|
||||
elif msgs['ring_hs'] == msg:
|
||||
open_now = self.open_now_cb and self.open_now_cb()
|
||||
if open_now:
|
||||
self.open_door()
|
||||
elif not self.start:
|
||||
self.start = datetime.datetime.now()
|
||||
if self.last_symbol_timestamp and (self.start - self.last_symbol_timestamp).total_seconds() > 5:
|
||||
self.times = []
|
||||
elif msgs['release'] == msg and self.start:
|
||||
t = datetime.datetime.now()
|
||||
self.last_symbol_timestamp = t
|
||||
dt = (t - self.start).total_seconds()
|
||||
self.start = None
|
||||
self.times = self.times[-3:] + [dt]
|
||||
avg = sum(self.times) / len(self.times)
|
||||
morse = [x>avg for x in self.times]
|
||||
if len(self.times) == 4:
|
||||
self._logger().info(", ".join([{True: "dah", False: "dit"}[x > avg] for x in self.times]))
|
||||
|
||||
if morse == [False, False, True, False]:
|
||||
self.open_door()
|
||||
data = data[7:]
|
||||
|
||||
self.tmp = data
|
||||
|
||||
def alternate_msgs(self, names, times = 3, *, dt = 0.04):
|
||||
for _ in range(times):
|
||||
for m in names:
|
||||
self.write(msgs[m])
|
||||
time.sleep(dt)
|
||||
|
||||
def send_light_msg(self):
|
||||
self.alternate_msgs(['light_push'])
|
||||
time.sleep(0.3)
|
||||
self.alternate_msgs(['light_release'])
|
||||
|
||||
def send_open_msg(self):
|
||||
self.alternate_msgs(['open_push'])
|
||||
time.sleep(0.3)
|
||||
self.alternate_msgs(['open_release'])
|
||||
|
||||
def open_door(self):
|
||||
self._logger().info("Opening")
|
||||
self.alternate_msgs(['light_push', 'open_push'], dt = 0.04)
|
||||
time.sleep(0.3)
|
||||
self.alternate_msgs(['light_release', 'open_release'], dt = 0.04);
|
||||
|
||||
def get(self, block = False):
|
||||
with self.cv:
|
||||
if len(self.data) == 0:
|
||||
if block:
|
||||
self.cv.wait_for(lambda: len(self.data) > 0)
|
||||
else:
|
||||
return None
|
||||
rv = self.data[0]
|
||||
self.data = self.data[1:]
|
||||
return rv
|
||||
|
||||
class Control(util.Loggable):
|
||||
def __init__(self, port, open_immediately_cb = None):
|
||||
self._port = port
|
||||
self._task = None
|
||||
self._cb = open_immediately_cb
|
||||
def start(self):
|
||||
if not self._task:
|
||||
self._task = threading.Thread(target = self.run, daemon=True)
|
||||
self._task.start()
|
||||
def run(self):
|
||||
port = None
|
||||
protocol = None
|
||||
while True:
|
||||
if port is None or protocol.closed:
|
||||
try:
|
||||
port = serial.Serial(self._port, 9600)
|
||||
protocol = serial.threaded.ReaderThread(port, lambda: Reader(self._cb))
|
||||
protocol.start()
|
||||
protocol = protocol.protocol
|
||||
except Exception as e:
|
||||
self._logger().exception("Port opening error")
|
||||
time.sleep(10)
|
||||
continue
|
||||
|
||||
if protocol.doing_light:
|
||||
protocol.send_light_msg()
|
||||
time.sleep(1)
|
4
door_pi_control/door/__init__.py
Normal file
4
door_pi_control/door/__init__.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
"""Meh"""
|
||||
from .communication import Communication
|
||||
from .control import Control
|
||||
|
144
door_pi_control/door/communication.py
Normal file
144
door_pi_control/door/communication.py
Normal file
|
@ -0,0 +1,144 @@
|
|||
import os
|
||||
import select
|
||||
import serial
|
||||
import time
|
||||
|
||||
from threading import Lock, RLock, Thread
|
||||
from typing import IO, Dict, Callable, Union
|
||||
|
||||
from ..util import Loggable
|
||||
|
||||
class Communication(Loggable):
|
||||
def __init__(self, port: Union[os.PathLike, IO]):
|
||||
self._mutex: RLock = RLock()
|
||||
self._write_mutex: Lock = Lock()
|
||||
|
||||
self._subscribers: Dict[str, Callable] = {}
|
||||
self._read_pipe, self._write_pipe = os.pipe()
|
||||
os.set_blocking(self._read_pipe, False)
|
||||
self._read_pipe = os.fdopen(self._read_pipe, 'rb', buffering=0, )
|
||||
self._write_pipe = os.fdopen(self._write_pipe, 'wb', buffering=0)
|
||||
|
||||
self._port_path = port
|
||||
self._serial_port = None
|
||||
self._open_serial_port()
|
||||
|
||||
self._stopped = True
|
||||
self._task = None
|
||||
|
||||
def _forward(self) -> None:
|
||||
data = self._read_pipe.read()
|
||||
while True:
|
||||
try:
|
||||
return self._write(data)
|
||||
except serial.serialutil.SerialException:
|
||||
self._open_serial_port()
|
||||
|
||||
def _open_serial_port(self) -> None:
|
||||
self._logger().debug("Opening port")
|
||||
port = self._port_path
|
||||
if type(port) == str:
|
||||
i = 0
|
||||
while i < 30 and not os.path.exists(port):
|
||||
time.sleep(1)
|
||||
i += 1
|
||||
self._serial_port = serial.Serial(port, timeout=2)
|
||||
else:
|
||||
self._serial_port = port
|
||||
|
||||
def _process(self):
|
||||
try:
|
||||
line = self._read()
|
||||
except serial.serialutil.SerialException:
|
||||
line = None
|
||||
if line == None:
|
||||
self._logger().info("Empty line, reopening serial port")
|
||||
self._open_serial_port()
|
||||
return
|
||||
|
||||
if ':' not in line:
|
||||
self._logger().debug(f"Line: {line}")
|
||||
self._send_event("", line.strip())
|
||||
return
|
||||
|
||||
try:
|
||||
prefix, data = line.split(":", maxsplit=2)
|
||||
if not prefix.startswith("pos"):
|
||||
self._logger().debug(f"Line: {line}")
|
||||
self._send_event(prefix, data.strip())
|
||||
except:
|
||||
pass
|
||||
|
||||
def _read(self) -> str:
|
||||
data = self._serial_port.readline()
|
||||
if data == "":
|
||||
return None
|
||||
return data.decode("ascii").strip()
|
||||
|
||||
def _run(self):
|
||||
while True:
|
||||
with self._mutex:
|
||||
if self._stopped:
|
||||
return
|
||||
|
||||
readable, _, _ = select.select(
|
||||
[self._serial_port, self._read_pipe],
|
||||
[], [], 0.1)
|
||||
for r in readable:
|
||||
if r == self._serial_port:
|
||||
self._process()
|
||||
elif r == self._read_pipe:
|
||||
self._forward()
|
||||
|
||||
def _send_event(self, prefix, data):
|
||||
with self._mutex:
|
||||
if prefix in self._subscribers:
|
||||
for sub in self._subscribers[prefix]:
|
||||
sub(prefix, data)
|
||||
|
||||
def _write(self, data: bytes) -> None:
|
||||
with self._write_mutex:
|
||||
self._logger().debug(f"Sending {repr(data)}")
|
||||
if self._serial_port:
|
||||
self._serial_port.write(data)
|
||||
|
||||
|
||||
def start(self):
|
||||
with self._mutex:
|
||||
self._logger().debug("Starting")
|
||||
if self._task is None:
|
||||
self._stopped = False
|
||||
self._task = Thread(target=self._run, daemon=True)
|
||||
self._task.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._mutex.acquire()
|
||||
if self._task is not None:
|
||||
self._stopped = True
|
||||
self._mutex.release()
|
||||
self._task.join()
|
||||
self._mutex.acquire()
|
||||
self._task = None
|
||||
self._mutex.release()
|
||||
|
||||
def subscribe(self, prefix: str, callback: Callable):
|
||||
with self._mutex:
|
||||
if prefix not in self._subscribers:
|
||||
self._subscribers[prefix] = []
|
||||
self._subscribers[prefix].append(callback)
|
||||
|
||||
def write(self, data):
|
||||
with self._write_mutex:
|
||||
self._write_pipe.write(data)
|
||||
|
||||
def cmd_open(self):
|
||||
self.write(b"O")
|
||||
|
||||
def cmd_close(self):
|
||||
self.write(b"C")
|
||||
|
||||
def cmd_report(self):
|
||||
self.write(b">r1\nR")
|
||||
|
||||
def cmd_restart(self):
|
||||
self.write(b"S")
|
31
door_pi_control/door/constants.py
Normal file
31
door_pi_control/door/constants.py
Normal file
|
@ -0,0 +1,31 @@
|
|||
"""Constants"""
|
||||
ERROR_THRESHOLD = 350
|
||||
OPEN_THRESHOLD = 190
|
||||
CLOSED_THRESHOLD = 6*7
|
||||
CLOSED_WANT = 4*7
|
||||
MIN_IDLE_TIME = 0
|
||||
|
||||
UPDATE_RATE = 1
|
||||
MAX_UPDATE_RATE = 20
|
||||
COMMAND_IDLE_TIME = 0.5
|
||||
|
||||
class state:
|
||||
IDLE, CLOSE, OPEN_THEN_CLOSE, OPEN, CLOSE_THEN_OPEN, ERROR, RESTART = range(7)
|
||||
|
||||
|
||||
state_names = {
|
||||
state.OPEN: "open",
|
||||
state.CLOSE: "closed",
|
||||
state.ERROR: "error",
|
||||
state.OPEN_THEN_CLOSE: "open, then close",
|
||||
state.CLOSE_THEN_OPEN: "close, then open",
|
||||
state.RESTART: "reset MCU",
|
||||
state.IDLE: "idle",
|
||||
}
|
||||
|
||||
action_names = {
|
||||
state.IDLE: "idling",
|
||||
state.OPEN: "waiting for open door",
|
||||
state.CLOSE: "waiting for closed door",
|
||||
state.ERROR: "error",
|
||||
}
|
220
door_pi_control/door/control.py
Normal file
220
door_pi_control/door/control.py
Normal file
|
@ -0,0 +1,220 @@
|
|||
from datetime import datetime
|
||||
import os
|
||||
from threading import Lock, RLock, Condition, Thread
|
||||
|
||||
from .communication import Communication
|
||||
from .. import util
|
||||
from .. import mqtt
|
||||
from .constants import state_names, state
|
||||
from . import constants
|
||||
|
||||
class Control(util.Loggable):
|
||||
def __init__(self, config, mqtt_client=None):
|
||||
self._config = config
|
||||
|
||||
self._mutex = RLock()
|
||||
self._control_update = Condition(self._mutex)
|
||||
|
||||
self._comms = Communication(config.serial_port)
|
||||
self._position_task: Thread = None
|
||||
self._control_task: Thread = None
|
||||
|
||||
self.target = mqtt.Value(mqtt_client, "door/state/target",
|
||||
persistent=True,
|
||||
translate=state_names)
|
||||
self.state = mqtt.Value(mqtt_client, "door/state/value",
|
||||
persistent=True,
|
||||
translate=state_names)
|
||||
self.position = mqtt.Value(mqtt_client, "door/position/value",
|
||||
persistent=True)
|
||||
self._speed = 0
|
||||
|
||||
self._started = False
|
||||
self._stop = False
|
||||
self._idle = True
|
||||
|
||||
def _run_position(self):
|
||||
cond = Condition(self._mutex)
|
||||
warned = False
|
||||
|
||||
def update_position(_, line):
|
||||
p, s = map(int, line.split())
|
||||
with cond:
|
||||
if p != self.position():
|
||||
self._logger().debug(f"Position: {p}, {s}")
|
||||
self.position(p)
|
||||
self._speed = s
|
||||
cond.notify()
|
||||
|
||||
self._comms.subscribe("pos", update_position)
|
||||
last_update = datetime.now()
|
||||
last_movement = datetime.now()
|
||||
while True:
|
||||
with cond:
|
||||
if self._stop:
|
||||
return
|
||||
if not cond.wait(5):
|
||||
self._comms.cmd_report()
|
||||
dt = (datetime.now() - last_update).total_seconds()
|
||||
if dt > 10 and not warned:
|
||||
warned = True
|
||||
self._logger().warn(f"No position for {dt} seconds")
|
||||
else:
|
||||
last_update = datetime.now()
|
||||
warned = False
|
||||
|
||||
if self._speed != 0:
|
||||
last_movement = datetime.now()
|
||||
|
||||
mov_dt = (datetime.now() - last_movement).total_seconds()
|
||||
if mov_dt > constants.MIN_IDLE_TIME:
|
||||
if self.position() < constants.CLOSED_THRESHOLD:
|
||||
self.state(state.CLOSE)
|
||||
self._control_update.notify()
|
||||
elif self.position() > constants.ERROR_THRESHOLD:
|
||||
self.state(state.ERROR)
|
||||
self._control_update.notify()
|
||||
elif self.position() > constants.OPEN_THRESHOLD:
|
||||
self.state(state.OPEN)
|
||||
self._control_update.notify()
|
||||
elif not self._idle:
|
||||
self._control_update.notify()
|
||||
self._idle = True
|
||||
|
||||
else:
|
||||
self._idle = False
|
||||
|
||||
def _run_control(self):
|
||||
# Last known state
|
||||
st = state.IDLE
|
||||
# Current action
|
||||
action = state.IDLE
|
||||
cmd = {
|
||||
None: lambda: None,
|
||||
state.IDLE: lambda: None,
|
||||
state.ERROR: lambda: None,
|
||||
state.RESTART: self._comms.cmd_restart,
|
||||
state.OPEN: self._comms.cmd_open,
|
||||
state.OPEN_THEN_CLOSE: self._comms.cmd_open,
|
||||
state.CLOSE: self._comms.cmd_close,
|
||||
state.CLOSE_THEN_OPEN: self._comms.cmd_close,
|
||||
}
|
||||
|
||||
with self._control_update:
|
||||
last_target = state.IDLE
|
||||
# When starting, reset the MCU once
|
||||
self.target(state.CLOSE)
|
||||
# Starting time of the current action
|
||||
start_time = datetime.now()
|
||||
timeouts = 0
|
||||
while not self._stop:
|
||||
# Wait for an update
|
||||
self._control_update.wait(1)
|
||||
if not self._idle:
|
||||
# If still moving, continue
|
||||
self._logger().debug(f"Not idle")
|
||||
continue
|
||||
|
||||
# Update was that the target has changed
|
||||
if self.target() != last_target:
|
||||
self._logger().debug(f"Target update: {state_names[self.target()]}")
|
||||
self._logger().debug(f"{cmd[self.target()]}, {cmd[last_target]}")
|
||||
if cmd.get(action, None) != cmd[self.target()]:
|
||||
# We need to send a different command for this
|
||||
self._logger().debug(f"Calling {cmd[self.target()]}")
|
||||
cmd[self.target()]()
|
||||
else:
|
||||
self._logger().debug(f"Same command as {state_names[last_target]}")
|
||||
# Update last known target and starting time
|
||||
last_target = self.target()
|
||||
start_time = datetime.now()
|
||||
|
||||
# Update current target
|
||||
target = last_target
|
||||
|
||||
if self.state() != st:
|
||||
# State from position handling differs from last known state
|
||||
self._logger().debug(f"State update, target is {state_names[target]}")
|
||||
st = self.state()
|
||||
self._logger().info("Reached state "
|
||||
f"{state_names.get(st, st)}")
|
||||
if st == target:
|
||||
# Reached target
|
||||
timeouts = 0
|
||||
if target == state.CLOSE \
|
||||
and self.position() > constants.CLOSED_WANT:
|
||||
self._logger().info(
|
||||
f"Position is {self.position()}, "
|
||||
"closing some more")
|
||||
self._comms.cmd_close()
|
||||
action = state.IDLE
|
||||
elif self.state() == state.ERROR:
|
||||
# Position too high, restart
|
||||
self._comm.cmd_restart()
|
||||
self.target(state.CLOSE)
|
||||
else:
|
||||
if timeouts < 3:
|
||||
timeouts += 1
|
||||
if action == target:
|
||||
# Initially, switch to the other one
|
||||
# and execute that
|
||||
action = {
|
||||
state.CLOSE: state.OPEN_THEN_CLOSE,
|
||||
state.OPEN: state.CLOSE_THEN_OPEN
|
||||
}.get(target, state.RESTART)
|
||||
cmd[action]()
|
||||
else:
|
||||
# Then go back
|
||||
action = target
|
||||
cmd[action]()
|
||||
else:
|
||||
# Tried too often, restart
|
||||
self.target(state.RESTART)
|
||||
|
||||
if action == state.IDLE:
|
||||
continue
|
||||
|
||||
def start(self):
|
||||
with self._mutex:
|
||||
if not self._started:
|
||||
self._started = True
|
||||
self._stop = False
|
||||
self._comms.start()
|
||||
self._position_task = Thread(target=self._run_position,
|
||||
daemon=True)
|
||||
self._control_task = Thread(target=self._run_control,
|
||||
daemon=True)
|
||||
self._position_task.start()
|
||||
self._control_task.start()
|
||||
|
||||
def stop(self):
|
||||
with self._mutex:
|
||||
if self._started:
|
||||
self._started = False
|
||||
self._stop = True
|
||||
self._comms.stop()
|
||||
self._mutex.release()
|
||||
self._position_task.join()
|
||||
self._control_task.join()
|
||||
|
||||
def open(self):
|
||||
with self._mutex:
|
||||
self.target(state.OPEN)
|
||||
self._control_update.notify()
|
||||
|
||||
def close(self):
|
||||
with self._mutex:
|
||||
self.target(state.CLOSE)
|
||||
self._control_update.notify()
|
||||
|
||||
def toggle(self):
|
||||
with self._mutex:
|
||||
self._logger().debug("Asked to toggle")
|
||||
if self.target() == state.OPEN:
|
||||
self.close()
|
||||
else:
|
||||
self.open()
|
||||
|
||||
def state(self):
|
||||
with self._mutex:
|
||||
return self._state
|
89
door_pi_control/door/token_control.py
Normal file
89
door_pi_control/door/token_control.py
Normal file
|
@ -0,0 +1,89 @@
|
|||
from ... import util
|
||||
import datetime
|
||||
import os
|
||||
import select
|
||||
from threading import Thread, RLock
|
||||
|
||||
class TokenControl(util.Loggable):
|
||||
def __init__(self, token_path, fifo_path, control):
|
||||
self._token_path = token_path
|
||||
self._fifo_path = fifo_path
|
||||
self._control = control
|
||||
|
||||
self._open_fifo()
|
||||
self.refresh_tokens()
|
||||
|
||||
pipes = os.pipe()
|
||||
self._mutex = RLock()
|
||||
self._stop_pipe_write = pipes[1]
|
||||
self._stop_pipe_read = pipes[0]
|
||||
|
||||
self._thread = None
|
||||
|
||||
def _open_fifo(self):
|
||||
self._fifo = open(self._fifo_path, "r")
|
||||
|
||||
def refresh_tokens(self):
|
||||
"""Refreshes all tokens from config.valid_tokens"""
|
||||
valid = {}
|
||||
try:
|
||||
self._logger().info("Loading tokens")
|
||||
lines =[ s.strip() for s in open(self._token_path, "r").readlines() ]
|
||||
for i, line in enumerate(lines):
|
||||
l = line.split('|')
|
||||
if len(l) == 5:
|
||||
if not l[0].strip().startswith('#'):
|
||||
token, name, organization, email, valid_thru = l
|
||||
try:
|
||||
if len(valid_thru.strip()) > 0:
|
||||
valid_thru = datetime.date.fromisoformat(valid_thru)
|
||||
else:
|
||||
valid_thru = None
|
||||
except Exception:
|
||||
self._logger().error(f"Could not parse valid thru date for token {token} in line {i}")
|
||||
valid_thru = None
|
||||
self._logger().debug(f"Got token {token} associated with {name} <{email}> of {organization}, valid thru {valid_thru}")
|
||||
if token in valid:
|
||||
self._logger().warning(f"Overwriting token {token}")
|
||||
valid[token] = {
|
||||
'name': name,
|
||||
'organization': organization,
|
||||
'email': email,
|
||||
'valid_thru': valid_thru
|
||||
}
|
||||
else:
|
||||
self._logger().warning(f"Skipping line {i} ({line}) since it does not contain exactly 5 data field")
|
||||
except Exception as e:
|
||||
valid = {}
|
||||
self._logger().error(f"Error reading token file. Exception: {e}")
|
||||
with self._mutex:
|
||||
self._tokens = valid
|
||||
|
||||
def start(self):
|
||||
with self._mutex:
|
||||
if self._thread != None:
|
||||
return
|
||||
|
||||
self._thread = Thread(target = self.run, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
with self._mutex:
|
||||
if self._thread == None:
|
||||
return
|
||||
|
||||
self._stop_pipe_write.write('c')
|
||||
self._thread.join()
|
||||
self._thread = None
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
readable = select.select([self._fifo, self._stop_pipe_read], [], [], 0.5)[0]
|
||||
if self._stop_pipe_read in readable:
|
||||
self._stop_pipe_read.read(1)
|
||||
return
|
||||
if self._fifo in readable:
|
||||
line = self._fifo.readline()
|
||||
if line == None:
|
||||
self._open_fifo()
|
||||
continue
|
81
door_pi_control/mqtt.py
Normal file
81
door_pi_control/mqtt.py
Normal file
|
@ -0,0 +1,81 @@
|
|||
import datetime
|
||||
import logging
|
||||
import typing
|
||||
from typing import Any, Optional, Callable
|
||||
|
||||
from paho.mqtt.client import Client as Client
|
||||
|
||||
_logger = logging.getLogger("door_pi_control").getChild("util")
|
||||
|
||||
class Value:
|
||||
def __init__(self,
|
||||
client: Client,
|
||||
topic: str,
|
||||
start_value: Any = None,
|
||||
*,
|
||||
persistent: bool = False,
|
||||
translate: typing.Union[
|
||||
None,
|
||||
typing.Dict[Any, str],
|
||||
typing.Callable]
|
||||
= None,
|
||||
max_update: float = 0.1):
|
||||
self.client = client
|
||||
self.topic = topic
|
||||
self.persistent = persistent
|
||||
self.value = start_value
|
||||
self.last_update = datetime.datetime.now() - datetime.timedelta(
|
||||
seconds=max_update)
|
||||
self.last_update_value = None
|
||||
self.max_update = max_update
|
||||
|
||||
if translate is None:
|
||||
self.translate: Callable = str
|
||||
elif callable(translate):
|
||||
self.translate = translate
|
||||
else:
|
||||
self.translate = lambda x: translate[x]
|
||||
|
||||
if start_value is not None:
|
||||
self.update(start_value)
|
||||
|
||||
def update(self, value: Any, *,
|
||||
force: bool = False,
|
||||
no_update: bool = False) -> None:
|
||||
if value != self.value or value != self.last_update_value:
|
||||
self.value = value
|
||||
if value is not None and \
|
||||
(force
|
||||
or (not no_update
|
||||
and value != self.last_update_value
|
||||
and (datetime.datetime.now()
|
||||
- self.last_update
|
||||
).total_seconds() >= self.max_update)):
|
||||
self.last_update = datetime.datetime.now()
|
||||
self.last_update_value = value
|
||||
if self.client is not None:
|
||||
self.client.publish(
|
||||
self.topic,
|
||||
self.translate(value),
|
||||
qos=2,
|
||||
retain=self.persistent)
|
||||
|
||||
def force_update(self, value) -> None:
|
||||
self.update(value, force=True)
|
||||
|
||||
def __call__(self, value=None, **kwargs) -> typing.Optional[Any]:
|
||||
if value is not None:
|
||||
self.update(value, **kwargs)
|
||||
return value
|
||||
return self.value
|
||||
|
||||
def str(self):
|
||||
return self.translate(self.value)
|
||||
|
||||
def reconnecting_client(host: str, *, keepalive: int = 60):
|
||||
client = Client()
|
||||
client.on_connect = lambda client, userdata, flags, rc: \
|
||||
_logger.debug("Connected to mqtt host")
|
||||
client.connect_async(host, keepalive=keepalive)
|
||||
client.loop_start()
|
||||
return client
|
127
door_pi_control/nfc.py
Normal file
127
door_pi_control/nfc.py
Normal file
|
@ -0,0 +1,127 @@
|
|||
import select
|
||||
import socket
|
||||
import time
|
||||
from threading import Lock, RLock, Condition, Thread
|
||||
from typing import Callable, Optional, IO, Container
|
||||
|
||||
from . import mqtt, util
|
||||
from .util import timestamp
|
||||
|
||||
class DoorControlNfc(util.Loggable):
|
||||
"""Validates tokens read from a socket and tells a DoorControl to toggle its target"""
|
||||
def __init__(self, config, control, mqtt_client = None):
|
||||
self._config = config
|
||||
self._nfc = self._open_nfc_fifo()
|
||||
self._control = control
|
||||
self._stop = True
|
||||
self._mutex = RLock()
|
||||
self._cond = Condition(self._mutex)
|
||||
self._fifo = None
|
||||
self._mqttc = mqtt_client
|
||||
self._read_valid_tokens()
|
||||
self.last_invalid_token = mqtt.Value(self._mqttc,
|
||||
"door/token/last_invalid", persistent=True)
|
||||
|
||||
def _open_nfc_fifo(self) -> IO:
|
||||
"""Opens config.nfc_fifo as the FIFO through which detected tokens are passed in."""
|
||||
self._logger().debug(f"Opening FIFO {self._config.nfc_fifo}")
|
||||
return open(self._config.nfc_fifo, "rb")
|
||||
|
||||
def _read_valid_tokens(self) -> Container:
|
||||
"""Refreshes all tokens from config.valid_tokens"""
|
||||
with self._mutex:
|
||||
valid = {}
|
||||
try:
|
||||
self._logger().info("Loading tokens")
|
||||
lines =[ s.strip() for s in open(self._config.valid_tokens, "r").readlines() ]
|
||||
for i, line in enumerate(lines):
|
||||
l = line.split('|')
|
||||
if len(l) == 5:
|
||||
if not l[0].strip().startswith('#'):
|
||||
token, name, organization, email, valid_thru = l
|
||||
try:
|
||||
if len(valid_thru.strip()) > 0:
|
||||
valid_thru = datetime.date.fromisoformat(valid_thru)
|
||||
else:
|
||||
valid_thru = None
|
||||
except Exception:
|
||||
self._logger().error(f"Could not parse valid thru date for token {token} in line {i}")
|
||||
valid_thru = None
|
||||
self._logger().debug(f"Got token {token} associated with {name} <{email}> of {organization}, valid thru {valid_thru}")
|
||||
if token in valid:
|
||||
self._logger().warning(f"Overwriting token {token}")
|
||||
valid[token] = {
|
||||
'name': name,
|
||||
'organization': organization,
|
||||
'email': email,
|
||||
'valid_thru': valid_thru
|
||||
}
|
||||
else:
|
||||
self._logger().warning(f"Skipping line {i} ({line}) since it does not contain exactly 5 data field")
|
||||
except Exception as e:
|
||||
valid = {}
|
||||
self._logger().error(f"Error reading token file. Exception: {e}")
|
||||
|
||||
self._tokens = valid
|
||||
|
||||
def _run(self):
|
||||
with self._cond:
|
||||
while not self._stop:
|
||||
if not self._fifo:
|
||||
self._read_valid_tokens()
|
||||
self._fifo = self._open_nfc_fifo()
|
||||
if not self._fifo:
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
self._cond.release()
|
||||
readable, _, _ = select.select(
|
||||
[self._fifo],
|
||||
[], [], 1)
|
||||
self._cond.acquire()
|
||||
|
||||
if self._fifo in readable:
|
||||
token = self._fifo.readline().strip()
|
||||
if token == "":
|
||||
self._fifo = None
|
||||
continue
|
||||
self._logger().debug(f"Token from nfc_fifo: {token}")
|
||||
self.handle_token(token)
|
||||
|
||||
def handle_token(self, token):
|
||||
with self._mutex:
|
||||
if token in self._tokens:
|
||||
data = self._tokens[token]
|
||||
|
||||
if data['valid_thru'] is not None:
|
||||
# if a valid thru date has been set we check if the token is still valid
|
||||
authorized = datetime.date.today() <= data['valid_thru']
|
||||
else:
|
||||
# otherwise we don't need to check
|
||||
authorized = True
|
||||
|
||||
if authorized:
|
||||
self._logger().info(f"Valid token {token} of {data['name']}")
|
||||
self._control.toggle()
|
||||
else:
|
||||
self._logger().warning(f"Token {token} of {data['name']} expired on {data['valid_thru']}")
|
||||
else:
|
||||
self._logger().warning(f"Invalid token: {token}")
|
||||
self.last_invalid_token(f"{timestamp()};{token}")
|
||||
|
||||
def start(self):
|
||||
with self._mutex:
|
||||
if self._stop:
|
||||
self._stop = False
|
||||
self._task = Thread(target=self._run, daemon=True)
|
||||
self._task.start()
|
||||
print("Done")
|
||||
|
||||
def stop(self):
|
||||
with self._cond:
|
||||
if not self._stop:
|
||||
self._stop = True
|
||||
self._cond.notify()
|
||||
self._task.join()
|
||||
|
||||
|
160
door_pi_control/socket.py
Normal file
160
door_pi_control/socket.py
Normal file
|
@ -0,0 +1,160 @@
|
|||
import os
|
||||
import select
|
||||
import socket
|
||||
import time
|
||||
from threading import Lock, RLock, Condition, Thread
|
||||
from typing import Callable, Optional, IO, Container
|
||||
|
||||
from . import mqtt, util
|
||||
from .util import timestamp
|
||||
from .door.constants import state_names
|
||||
|
||||
class DoorControlSocket(util.Loggable):
|
||||
class LineBuffer(object):
|
||||
def __init__(self, f, handler):
|
||||
self.data = b''
|
||||
self.f = f
|
||||
self.handler = handler
|
||||
|
||||
def update(self):
|
||||
data = self.f.recv(1024)
|
||||
print(repr(data))
|
||||
if not data:
|
||||
print("Eep")
|
||||
return False
|
||||
self.data += data
|
||||
d = self.data.split(b'\n')
|
||||
d, self.data = d[:-1], d[-1]
|
||||
for i in d:
|
||||
print("Handling", repr(i))
|
||||
self.handler(self.f, i)
|
||||
return True
|
||||
|
||||
def __init__(self, config, control, nfc):
|
||||
self._config = config
|
||||
self._control = control
|
||||
self._nfc = nfc
|
||||
self._fifo = self._open_control_socket()
|
||||
self._stop = True
|
||||
self._mutex = RLock()
|
||||
self._cond = Condition(self._mutex)
|
||||
|
||||
def _open_control_socket(self) -> socket.socket:
|
||||
"""(Re-)creates and opens the control socket. Config must have a control_socket member."""
|
||||
path = self._config.control_socket
|
||||
self._logger().debug(f"Opening control socket {path}")
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.bind(path)
|
||||
s.listen(5)
|
||||
return s
|
||||
|
||||
def _run(self):
|
||||
buffers = {}
|
||||
sockets = []
|
||||
with self._cond:
|
||||
while not self._stop:
|
||||
if not self._fifo:
|
||||
self._fifo = self._open_control_socket()
|
||||
if not self._fifo:
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
readable, _, _ = select.select(
|
||||
[self._fifo] + sockets,
|
||||
[], [], 1)
|
||||
|
||||
for fd in readable:
|
||||
if fd == self._fifo:
|
||||
self._logger().debug("Got connection")
|
||||
sock = fd.accept()[0]
|
||||
buffers[sock] = DoorControlSocket.LineBuffer(sock, self.handle_cmd)
|
||||
sockets += [sock]
|
||||
else:
|
||||
if not buffers[fd].update():
|
||||
del buffers[fd]
|
||||
sockets.remove(fd)
|
||||
|
||||
def handle_cmd(self, comm, data):
|
||||
cmd = data.decode('utf8').split()
|
||||
try:
|
||||
cmd, args = cmd[0], cmd[1:]
|
||||
except:
|
||||
return
|
||||
|
||||
self._logger().debug(f"Got command: {data}")
|
||||
send = lambda x: comm.send(x.encode('utf8'))
|
||||
|
||||
if cmd == 'fake':
|
||||
self._logger().debug(f"Faking token {args[0]}")
|
||||
send("Handling token\n")
|
||||
self._nfc.handle_token(args[0])
|
||||
elif cmd == 'reset':
|
||||
self._logger().info("Resetting")
|
||||
send("Resetting MCU")
|
||||
self._control._comms.cmd_restart()
|
||||
elif cmd == 'open':
|
||||
if len(args) > 0:
|
||||
self._logger().info(f"Control socket opening door for {args[0]}")
|
||||
send("Opening door")
|
||||
self._control.open()
|
||||
else:
|
||||
send("Missing login")
|
||||
elif cmd == 'close':
|
||||
if len(args) > 0:
|
||||
self._logger().info(f"Control socket closing door for {args[0]}")
|
||||
send("Closing door")
|
||||
self._control.close()
|
||||
else:
|
||||
send("Missing login")
|
||||
elif cmd == 'rld':
|
||||
self._logger().debug("Reloading tokens")
|
||||
send("Reloading tokens")
|
||||
self._nfc._read_valid_tokens()
|
||||
elif cmd == 'stat':
|
||||
send("Door status is %s, position is %d. Current action: %s\n" % (
|
||||
self._control.state.str(),
|
||||
self._control.position(),
|
||||
self._control.target.str()))
|
||||
|
||||
|
||||
def start(self):
|
||||
with self._mutex:
|
||||
if self._stop:
|
||||
self._stop = False
|
||||
self._task = Thread(target=self._run, daemon=True)
|
||||
self._task.start()
|
||||
|
||||
def stop(self):
|
||||
with self._cond:
|
||||
if not self._stop:
|
||||
self._stop = True
|
||||
self._cond.notify()
|
||||
self._task.join()
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--serial_port", default="/dev/serial/by-id/usb-Imaginaerraum.de_DoorControl_43363220195053573A002C0-if01")
|
||||
parser.add_argument("--nfc_fifo", default="/tmp/nfc_fifo")
|
||||
parser.add_argument("--control_socket", default="/tmp/nfc.sock")
|
||||
parser.add_argument("--valid_tokens", default="/etc/door_tokens")
|
||||
parser.add_argument("--log_file", default="/tmp/nfc.log")
|
||||
parser.add_argument("--state_timeout", type=float, default=10)
|
||||
parser.add_argument("--state_timeout_speed", type=float, default=3)
|
||||
parser.add_argument("--repeat_time", type=float, default=COMMAND_IDLE_TIME)
|
||||
parser.add_argument("--mqtt_host", default="10.10.21.2")
|
||||
config = parser.parse_args()
|
||||
util.init_logging()
|
||||
|
||||
def start():
|
||||
dc = DoorControl(config)
|
||||
dc._comms.start()
|
||||
t = Thread(target=dc.run)
|
||||
t.start()
|
||||
return dc
|
108
door_pi_control/util.py
Normal file
108
door_pi_control/util.py
Normal file
|
@ -0,0 +1,108 @@
|
|||
from typing import Callable, Iterable, Optional, Tuple, TextIO, BinaryIO, IO, Union, List
|
||||
from os import PathLike
|
||||
from threading import RLock, Condition
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import sys
|
||||
|
||||
_log_handlers: List[logging.Handler] = []
|
||||
_log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
|
||||
def init_logging(*,
|
||||
level: int = logging.DEBUG,
|
||||
output_file: Union[None, str, PathLike] = None,
|
||||
output_host: Optional[Tuple[str, int]] = None,
|
||||
output_stream: Optional[TextIO] = sys.stdout) -> None:
|
||||
"""Set up the logging format and outputs.
|
||||
|
||||
level: Logging level (DEBUG)
|
||||
output_file: File to write to (None)
|
||||
output_host: Tuple of host and port that will receive log datagrams
|
||||
"""
|
||||
global _log_handlers
|
||||
|
||||
_log_handlers = []
|
||||
|
||||
if output_file != None:
|
||||
# Create a file handler
|
||||
file_handler = logging.FileHandler(output_file)
|
||||
_log_handlers.append(file_handler)
|
||||
|
||||
if output_host != None:
|
||||
dgram_handler = logging.DatagramHandler(*output_host)
|
||||
_log_handlers.append(dgram_handler)
|
||||
|
||||
if output_stream != None:
|
||||
console_handler = logging.StreamHandler(output_stream)
|
||||
_log_handlers.append(console_handler)
|
||||
|
||||
for h in _log_handlers:
|
||||
h.setLevel(level)
|
||||
|
||||
# Default log output
|
||||
logging.basicConfig(force=True, level=level, format=_log_format, handlers=_log_handlers)
|
||||
|
||||
def logger(*path: Iterable[str]) -> logging.Logger:
|
||||
logger = logging.getLogger("door_pi_control")
|
||||
for p in path:
|
||||
logger = logger.getChild(p)
|
||||
return logger
|
||||
|
||||
def now() -> datetime.datetime:
|
||||
return datetime.datetime.now()
|
||||
|
||||
def timestamp() -> str:
|
||||
return now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
class Loggable:
|
||||
def _logger(self) -> logging.Logger:
|
||||
return logger(type(self).__name__)
|
||||
|
||||
class Event:
|
||||
def __init__(self, lock = None):
|
||||
self._lock = lock if lock != None else RLock()
|
||||
self._condition = Condition(self._lock)
|
||||
self._subscribers = {}
|
||||
|
||||
def acquire(self, *args):
|
||||
return self._condition.acquire(*args)
|
||||
|
||||
def notify(self, *args, **kwargs):
|
||||
with self._lock:
|
||||
for cv, cb in self._subscribers:
|
||||
with cv:
|
||||
if cb:
|
||||
cb(*args, **kwargs)
|
||||
cv.notify()
|
||||
self._condition.notify()
|
||||
|
||||
def notify_all(self):
|
||||
with self._lock:
|
||||
for cv, cb in self._subscribers:
|
||||
with cv:
|
||||
if cb:
|
||||
cb()
|
||||
cv.notify_all()
|
||||
self._condition.notify_all()
|
||||
|
||||
def subscribe(self, cv: Condition, callback: Callable = None):
|
||||
with self._lock:
|
||||
self._subscribers[cv] = callback
|
||||
|
||||
def unsubscribe(self, cv: Condition):
|
||||
with self._lock:
|
||||
if cv in self._subscribers:
|
||||
del self._subscribers[cv]
|
||||
|
||||
def wait(self, timeout=None):
|
||||
return self._condition.wait(timeout)
|
||||
|
||||
def wait_for(self, predicate, timeout=None):
|
||||
return self._condition.wait_for(predicate, timeout)
|
||||
|
||||
def __enter__(self):
|
||||
return self._lock.__enter__()
|
||||
|
||||
def __exit__(self, *args):
|
||||
self._lock.__exit__()
|
|
@ -1,3 +1,17 @@
|
|||
[build-system]
|
||||
requires = ["setuptools", "wheel"]
|
||||
requires = ["setuptools"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "door_pi_control"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"paho-mqtt",
|
||||
"pyserial",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages]
|
||||
find = {}
|
||||
|
||||
[project.scripts]
|
||||
door_pi_control="door_pi_control:main"
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
[metadata]
|
||||
name = door_pi_control
|
||||
version = 0.0.1
|
||||
|
||||
[options]
|
||||
packages = door_pi_control
|
||||
install_requires =
|
||||
paho-mqtt
|
||||
serial
|
Loading…
Reference in New Issue
Block a user