From d7bd00c3b6ef6081625720f450a0335e82b5ebf0 Mon Sep 17 00:00:00 2001 From: Valentin Ochs Date: Sun, 6 Nov 2022 14:30:11 +0100 Subject: [PATCH] Lotsa stuff --- .gitignore | 3 + door_pi_control/__init__.py | 513 ++------------------------ door_pi_control/__main__.py | 3 + door_pi_control/bell.py | 179 +++++++++ door_pi_control/door/__init__.py | 4 + door_pi_control/door/communication.py | 144 ++++++++ door_pi_control/door/constants.py | 31 ++ door_pi_control/door/control.py | 220 +++++++++++ door_pi_control/door/token_control.py | 89 +++++ door_pi_control/mqtt.py | 81 ++++ door_pi_control/nfc.py | 127 +++++++ door_pi_control/socket.py | 160 ++++++++ door_pi_control/util.py | 108 ++++++ pyproject.toml | 16 +- setup.cfg | 9 - setup.py | 2 - 16 files changed, 1204 insertions(+), 485 deletions(-) create mode 100644 door_pi_control/__main__.py create mode 100644 door_pi_control/bell.py create mode 100644 door_pi_control/door/__init__.py create mode 100644 door_pi_control/door/communication.py create mode 100644 door_pi_control/door/constants.py create mode 100644 door_pi_control/door/control.py create mode 100644 door_pi_control/door/token_control.py create mode 100644 door_pi_control/mqtt.py create mode 100644 door_pi_control/nfc.py create mode 100644 door_pi_control/socket.py create mode 100644 door_pi_control/util.py delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/.gitignore b/.gitignore index 3883e04..49855f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ __pycache__ .mypy_cache +build +dist +door_pi_control.egg-info/ diff --git a/door_pi_control/__init__.py b/door_pi_control/__init__.py index ba28682..128349e 100644 --- a/door_pi_control/__init__.py +++ b/door_pi_control/__init__.py @@ -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) diff --git a/door_pi_control/__main__.py b/door_pi_control/__main__.py new file mode 100644 index 0000000..0a2701a --- /dev/null +++ b/door_pi_control/__main__.py @@ -0,0 +1,3 @@ +#!/usr/bin/python +from . import main +main() diff --git a/door_pi_control/bell.py b/door_pi_control/bell.py new file mode 100644 index 0000000..f0cec94 --- /dev/null +++ b/door_pi_control/bell.py @@ -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) diff --git a/door_pi_control/door/__init__.py b/door_pi_control/door/__init__.py new file mode 100644 index 0000000..54b1d84 --- /dev/null +++ b/door_pi_control/door/__init__.py @@ -0,0 +1,4 @@ +"""Meh""" +from .communication import Communication +from .control import Control + diff --git a/door_pi_control/door/communication.py b/door_pi_control/door/communication.py new file mode 100644 index 0000000..0dd0d84 --- /dev/null +++ b/door_pi_control/door/communication.py @@ -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") diff --git a/door_pi_control/door/constants.py b/door_pi_control/door/constants.py new file mode 100644 index 0000000..6d231c3 --- /dev/null +++ b/door_pi_control/door/constants.py @@ -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", +} diff --git a/door_pi_control/door/control.py b/door_pi_control/door/control.py new file mode 100644 index 0000000..e6c43e4 --- /dev/null +++ b/door_pi_control/door/control.py @@ -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 diff --git a/door_pi_control/door/token_control.py b/door_pi_control/door/token_control.py new file mode 100644 index 0000000..d9bbfd1 --- /dev/null +++ b/door_pi_control/door/token_control.py @@ -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 diff --git a/door_pi_control/mqtt.py b/door_pi_control/mqtt.py new file mode 100644 index 0000000..d1d5586 --- /dev/null +++ b/door_pi_control/mqtt.py @@ -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 diff --git a/door_pi_control/nfc.py b/door_pi_control/nfc.py new file mode 100644 index 0000000..5c80ab7 --- /dev/null +++ b/door_pi_control/nfc.py @@ -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() + + diff --git a/door_pi_control/socket.py b/door_pi_control/socket.py new file mode 100644 index 0000000..473dfd8 --- /dev/null +++ b/door_pi_control/socket.py @@ -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 diff --git a/door_pi_control/util.py b/door_pi_control/util.py new file mode 100644 index 0000000..b828e42 --- /dev/null +++ b/door_pi_control/util.py @@ -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__() diff --git a/pyproject.toml b/pyproject.toml index 9787c3b..c644ab5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index d1d61f7..0000000 --- a/setup.cfg +++ /dev/null @@ -1,9 +0,0 @@ -[metadata] -name = door_pi_control -version = 0.0.1 - -[options] -packages = door_pi_control -install_requires = - paho-mqtt - serial diff --git a/setup.py b/setup.py deleted file mode 100644 index a4f49f9..0000000 --- a/setup.py +++ /dev/null @@ -1,2 +0,0 @@ -import setuptools -setuptools.setup()