#!/usr/bin/python import os, serial, socket, subprocess, select, datetime, sys import paho.mqtt.client as mqcl import argparse OPEN_THRESHOLD = 35 CLOSED_THRESHOLD = 60 CLOSED_WANT = 135 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("--repeat_time", type=float, default=5) parser.add_argument("--mqtt_host", default="10.10.21.2") config = parser.parse_args() mqttc = mqcl.Client() def timestamp(): return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Log data to stdout and the log file def log(*args): data = "%s %s" % (timestamp(), " ".join(str(i) for i in args)) lgf.write(data + "\n") lgf.flush() print(data) def mqtt(topic, msg, persistent = True): mqttc.publish("door/" + topic, msg, qos=2, retain=persistent) # Opens the socket that can control the daemon # Commands are TBD def open_control_socket(): global control_socket global config global comm_channels 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 = [] # Opens the serial port to talk to the lock actuator # Might return a failure. In that case, it will be tried again later def open_serial_port(): global serial_port global config try: serial_port = serial.Serial(config.serial_port, timeout=2) except: serial_port = None pass # Detected tokens are passed in through a FIFO def open_nfc_fifo(): global nfc_fifo global config nfc_fifo = open(config.nfc_fifo, "r") # Refresh valid tokens from the configured file def read_valid_tokens(): global valid global config try: log("Loading tokens") valid = {} 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) except Exception: log(f"Could not parse valid thru date for token {token} in line {i}") valid_thru = None log(f"Got token {token} associated with {name} <{email}> of {organization}, valid thru {valid_thru}") if token in valid: log(f"Warning: Overwriting token {token}") valid[token] = {'name': name, 'organization': organization, 'email': email, 'valid_thru': valid_thru} else: log(f"Skipping line {i} ({line}) since it does not contain exactly 5 data field") except: valid = {} log("Error reading token file") raise # Opens the log file for writing def open_logfile(): global lgf global config lgf = open(config.log_file, "a+") # Actions IDLE, CLOSE, OPEN_THEN_CLOSE, OPEN, CLOSE_THEN_OPEN = range(5) state_names = { OPEN: "open", CLOSE: "close" } action_names = { IDLE: "idling", OPEN: "waiting for open door", CLOSE: "waiting for closed door", OPEN_THEN_CLOSE: "waiting for open door, then closing again", CLOSE_THEN_OPEN: "waiting for closed door, then opening again" } # Current door state state = None state_pos = 0 # Current target action action = CLOSE # Start time of the current action start_time = None # How often the action was repeated repeats = 0 # Read the state of the door from the serial port def poll_door_state(): global state global state_pos global serial_port serial_port.reset_input_buffer() serial_port.write(b"R") data = serial_port.readline().strip().split() if data[0] == b'pos:': data = int(data[1]) if state_pos != data: mqtt("position/value", data, True) state_pos = data changed = False if data < OPEN_THRESHOLD and state != OPEN: state = OPEN changed = True mqtt("state/value", "open", True) elif data > CLOSED_THRESHOLD and state != CLOSE: state = CLOSE changed = True mqtt("state/value", "closed", True) return state # Check the door state and send commands through a state machine def handle_door_state(): global action, target, serial_port, start_time, repeats # Commands associated with each target state target_state_cmd = { OPEN: b'O', CLOSE: b'C' } old_state = state # If no serial port, try to open it or return if not serial_port: try: open_serial_port() except: return # Get new state poll_door_state() # Idle + change = key? if action == IDLE: if state != old_state: log("Door changed unexpectedly:", state_names[state]) start_time = datetime.datetime.now() if start_time and (datetime.datetime.now() - start_time).total_seconds() >= config.state_timeout: start_time = None if state_pos >= CLOSED_THRESHOLD and state_pos < CLOSED_WANT: log("Closing door a bit more") serial_port.write(target_state_cmd[CLOSE]) return # Target state, next action, timeout action actions = { OPEN: (OPEN, IDLE, CLOSE_THEN_OPEN ), CLOSE: (CLOSE, IDLE, OPEN_THEN_CLOSE ), OPEN_THEN_CLOSE: (OPEN, CLOSE, CLOSE ), CLOSE_THEN_OPEN: (CLOSE, OPEN, OPEN ) } if start_time == None: start_time = datetime.datetime.now() serial_port.write(target_state_cmd[actions[action][0]]) # Target state reached if state == actions[action][0]: # Select next action, reset start time and repetitions action = actions[action][1] start_time = datetime.datetime.now() repeats = 0 # On idle, we're done if action == IDLE: log("Reached target position:", state_names[state]) return # Execution time t = (datetime.datetime.now() - start_time).total_seconds() if t >= config.state_timeout: # Timeout -> switch to timeout action action = actions[action][2] start_time = None repeats = 0 log("Timeout. Switching to", action_names[action]) elif t >= (1 + repeats) * config.repeat_time: # Repeat every couple of seconds repeats += 1 serial_port.write(target_state_cmd[actions[action][0]]) log("Repeating command:", target_state_cmd[actions[action][0]]) def open_door(): global action global start_time log("Opening the door") action = OPEN start_time = None mqtt("state/target", "open", True) handle_door_state() def close_door(): global action global start_time log("Closing the door") action = CLOSE start_time = None mqtt("state/target", "closed", True) handle_door_state() def toggle_door_state(): global state, action if state == CLOSE: open_door() else: close_door() def handle_nfc_token(token = None): global valid if not token: token = nfc_fifo.readline() if token == "": open_nfc_fifo() token = token.strip() if token in valid: data = valid[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: log(f"Valid token {token} of {data['name']}") toggle_door_state() else: log(f"Token {token} of {data['name']} expired on {data['valid_thru']}") else: log("Invalid token:", token) mqtt("token/last_invalid", "%s;%s" % (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(comm, data): cmd = data.decode('utf8').split() cmd, args = cmd[0], cmd[1:] log("Got command:", data) send = lambda x: comm.send(x.encode('utf8')) if cmd == 'fake': log("Faking token", args[0]) send("Handling token\n") handle_nfc_token(args[0]) elif cmd == 'open': log("Control socket opening door") send("Opening door") open_door() elif cmd == 'close': log("Control socket closing door") send("Closing door") close_door() elif cmd == 'rld': log("Reloading tokens") send("Reloading tokens") read_valid_tokens() elif cmd == 'stat': send("Door status is %s, position is %d. Current action: %s (%g seconds ago)\n" % ( state_names.get(state, "None"), state_pos, action_names[action], (datetime.datetime.now() - start_time).total_seconds())) open_logfile() read_valid_tokens() open_nfc_fifo() open_serial_port() open_control_socket() mqttc.on_connect = lambda client, userdata, flags, rc: log("Connected to mqtt host with result %s" % (str(rc), )) mqttc.connect_async(config.mqtt_host, keepalive = 60) mqttc.loop_start() buffers = {} while True: readable = select.select([ nfc_fifo, control_socket ] + comm_channels, [], [], 1)[0] for c in readable: if c == nfc_fifo: handle_nfc_token() elif c == control_socket: log("Got connection") sock = control_socket.accept()[0] buffers[sock] = LineBuffer(sock, handle_cmd) comm_channels += [sock] else: if not buffers[c].update(): log("Lost connection") del buffers[c] comm_channels.remove(c) handle_door_state()