#!/usr/bin/env python3 ## ## Holds one BLE connection to a strip open and applies commands dropped into ## its queue directory, so a colour change costs a GATT write rather than a ## fresh connect. Started by "ledassist -a listen". ## ## Argv: address queue-root idle-seconds ## import asyncio import os import sys import time from bleak import BleakClient from bleak.exc import BleakError from led import WRITE_UUID, command POLL_SECONDS = 0.05 BEAT_SECONDS = 1.0 CONNECT_SECONDS = 20 RETRY_SECONDS = 2 ## A command that waited out a long reconnect is not worth applying: the colour ## it asked for has been superseded by whatever the user typed since. COMMAND_STALE_SECONDS = 10 ## Windows Python turns \n into \r\n in text mode, and the shell reading these ## back would carry the \r into whatever it parsed out. def log(root, message): with open(os.path.join(root, "log"), "a", newline="\n") as handle: handle.write("%s %s\n" % (time.strftime("%H:%M:%S"), message)) ## Clients read this to decide whether queueing beats connecting themselves. ## Written whole then renamed, so a reader never sees half a line. ## Windows can refuse the rename; a missed beat must not end the heartbeat. def beat(root): new = os.path.join(root, "beat.new") try: with open(new, "w", newline="\n") as handle: handle.write("%d %d\n" % (time.time(), os.getpid())) os.replace(new, os.path.join(root, "beat")) except OSError as e: log(root, "missed beat: %r" % e) def discard(root, name): try: os.remove(os.path.join(root, name)) except OSError: pass ## Present only while the strip is connected; "ledassist -a listen" waits on it. def mark_connected(root): open(os.path.join(root, "connected"), "w").close() def age(path): try: return os.path.getmtime(path) except OSError: return 0 ## Tells a client waiting on a command named *.wait how it ended: sent, ## rejected or expired. def acknowledge(root, name, outcome): if name.endswith(".wait"): try: open(os.path.join(root, "done", name + "." + outcome), "w").close() except OSError as e: log(root, "missed acknowledgement: %r" % e) ## Drains the queue oldest first, so a written sequence applies in order. ## Returns a list of (file name, [action, name, speed]). def take_commands(root): queue = os.path.join(root, "cmd") try: names = os.listdir(queue) except OSError: return [] taken = [] for name in sorted(names, key=lambda n: age(os.path.join(queue, n))): path = os.path.join(queue, name) stale = time.time() - age(path) > COMMAND_STALE_SECONDS try: with open(path) as handle: line = handle.read().strip() os.remove(path) except OSError: continue if stale: acknowledge(root, name, "expired") elif line: taken.append((name, line.split("\t"))) return taken ## command() exits the process on input it does not like, which would take the ## daemon with it. async def apply(client, root, file_name, parts): action, name, speed = (list(parts) + ["", "", ""])[:3] try: payload = command(action, name, speed) except (SystemExit, ValueError) as e: log(root, "bad command %r: %s" % (parts, e)) acknowledge(root, file_name, "rejected") return await client.write_gatt_char(WRITE_UUID, payload, response=False) acknowledge(root, file_name, "sent") ## The heartbeat has to run whatever the connection is doing, including the ## twenty seconds a first connect can take: a client that reads a stale beat ## falls back to connecting itself and then fights the daemon for the strip. async def beater(root, stop): while not stop.is_set(): beat(root) try: await asyncio.wait_for(stop.wait(), timeout=BEAT_SECONDS) except (asyncio.TimeoutError, TimeoutError): pass ## An idle_seconds of 0 never expires. def expired(idle_from, idle_seconds): return idle_seconds > 0 and time.monotonic() - idle_from >= idle_seconds async def connect_loop(address, root, idle_seconds): idle_from = time.monotonic() while not expired(idle_from, idle_seconds): try: async with BleakClient(address, timeout=CONNECT_SECONDS) as client: ## A connection can come up before service discovery has filled ## in. Reconnecting here costs a second; writing here would ## swallow whatever command was queued. if client.services.get_characteristic(WRITE_UUID) is None: raise BleakError("write characteristic not discovered yet") log(root, "connected") mark_connected(root) while True: if expired(idle_from, idle_seconds): return for file_name, parts in take_commands(root): await apply(client, root, file_name, parts) idle_from = time.monotonic() await asyncio.sleep(POLL_SECONDS) except (TimeoutError, BleakError, OSError) as e: discard(root, "connected") log(root, "reconnect: %r" % e) await asyncio.sleep(RETRY_SECONDS) ## Cancels session when "ledassist -a hangup" drops its file, even mid-connect. async def hang_up_when_asked(root, session): while not os.path.exists(os.path.join(root, "hangup")): await asyncio.sleep(BEAT_SECONDS) log(root, "hangup") session.cancel() async def serve(address, root, idle_seconds): stop = asyncio.Event() pulse = asyncio.create_task(beater(root, stop)) session = asyncio.create_task(connect_loop(address, root, idle_seconds)) watcher = asyncio.create_task(hang_up_when_asked(root, session)) try: await session except asyncio.CancelledError: pass finally: watcher.cancel() stop.set() await pulse def main(): address, root, idle = sys.argv[1:4] os.makedirs(os.path.join(root, "cmd"), exist_ok=True) os.makedirs(os.path.join(root, "done"), exist_ok=True) beat(root) try: asyncio.run(serve(address, root, float(idle))) finally: discard(root, "connected") discard(root, "beat") log(root, "exit") main()