Files
room_glitch/setup/pi/tools/pi_gpio/pi_gpio_bridge.py
T

177 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""Local Godot -> gpiozero bridge. BCM pins; run on the Pi, not the PC."""
import argparse
import json
import signal
import threading
import time
import hashlib
import sqlite3
from pathlib import Path
from http.server import BaseHTTPRequestHandler, HTTPServer
class Outputs:
def __init__(self, factory, controller, allowed, clock=time.monotonic, ledger=":memory:"):
self.factory, self.controller, self.allowed = factory, controller, set(allowed)
self.clock = clock
self.last_seen = clock()
self.pins = {}
self.lock = threading.RLock()
self.ledger = sqlite3.connect(ledger, check_same_thread=False)
self.ledger.execute("CREATE TABLE IF NOT EXISTS requests (id TEXT PRIMARY KEY, digest TEXT NOT NULL, result TEXT)")
def heartbeat(self):
with self.lock:
self.last_seen = self.clock()
def command(self, data):
if not isinstance(data, dict): raise ValueError("Expected a JSON object")
request_id = data.get("request_id")
if request_id is None: return self._apply_command(data)
if not isinstance(request_id, str) or not 1 <= len(request_id) <= 100:
raise ValueError("Invalid request_id")
digest = hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()
with self.lock:
row = self.ledger.execute("SELECT digest, result FROM requests WHERE id=?", (request_id,)).fetchone()
if row:
if row[0] != digest: raise ValueError("request_id reused with a different output")
return json.loads(row[1]) if row[1] else {"ok": False, "error": "Prior output outcome uncertain; refusing to pulse again"}
# Commit before touching the pin. A crash can miss a reward, but cannot replay it.
with self.ledger:
self.ledger.execute("INSERT INTO requests VALUES (?, ?, NULL)", (request_id, digest))
try:
result = self._apply_command(data)
except Exception as exc:
result = {"ok": False, "error": str(exc)}
with self.ledger:
self.ledger.execute("UPDATE requests SET result=? WHERE id=?", (json.dumps(result), request_id))
return result
def _apply_command(self, data):
if not isinstance(data, dict):
raise ValueError("Expected a JSON object")
if data.get("controller") != self.controller:
raise ValueError("Wrong controller_id for this Pi")
pin = data.get("pin")
if type(pin) is not int or pin not in self.allowed:
raise ValueError("BCM pin is not enabled in the bridge --pins list")
polarity = data.get("active_high")
if type(polarity) is not bool:
raise ValueError("active_high must be boolean")
mode = data.get("mode")
if mode not in ("pulse", "set"):
raise ValueError("mode must be pulse or set")
duration = data.get("duration_ms", 0)
if mode == "pulse" and (type(duration) is not int or not 1 <= duration <= 10000):
raise ValueError("Pulse duration must be 1..10000 ms")
active = data.get("active", True)
if type(active) is not bool:
raise ValueError("active must be boolean")
with self.lock:
if pin not in self.pins:
device = self.factory(pin, active_high=polarity, initial_value=False)
self.pins[pin] = {"device": device, "polarity": polarity, "deadline": None}
entry = self.pins[pin]
if entry["polarity"] != polarity:
raise ValueError("Pin polarity differs from its earlier configuration; restart bridge to change")
self.last_seen = self.clock()
if mode == "pulse":
entry["device"].on()
# Latest pulse restarts the duration; an older timer cannot switch it off.
entry["deadline"] = self.clock() + duration / 1000
else:
entry["device"].on() if active else entry["device"].off()
entry["deadline"] = None
return {"ok": True, "pin": pin, "active": mode == "pulse" or active}
def tick(self):
with self.lock:
now = self.clock()
for entry in self.pins.values():
if now - self.last_seen > 3 or (entry["deadline"] is not None and now >= entry["deadline"]):
entry["device"].off()
entry["deadline"] = None
def close(self):
with self.lock:
for entry in self.pins.values():
try:
entry["device"].off()
finally:
entry["device"].close()
self.ledger.close()
def handler_for(outputs):
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
try:
if self.headers.get("Origin"):
raise ValueError("Browser requests are not supported")
length = int(self.headers.get("Content-Length", "0"))
if not 0 <= length <= 4096:
raise ValueError("Request is too large")
data = json.loads(self.rfile.read(length) or b"{}")
if self.path == "/heartbeat":
outputs.heartbeat()
result = {"ok": True}
elif self.path == "/output":
result = outputs.command(data)
print("GPIO:", data, flush=True)
else:
raise ValueError("Unknown endpoint")
code = 200
except Exception as exc:
result, code = {"ok": False, "error": str(exc)}, 400
print("GPIO ERROR:", exc, flush=True)
body = json.dumps(result).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *_args):
pass
return Handler
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--controller", default="cabinet_01_gpio")
parser.add_argument("--pins", default="17", help="BCM pin allowlist, comma separated")
parser.add_argument("--ledger", default=str(Path.home() / ".local/state/arcade_gpio/requests.sqlite3"))
args = parser.parse_args()
pins = [int(n) for n in args.pins.split(",")]
if not pins or any(pin < 0 or pin > 27 for pin in pins):
parser.error("Use BCM header GPIO numbers 0..27")
from gpiozero import OutputDevice
Path(args.ledger).parent.mkdir(parents=True, exist_ok=True)
outputs = Outputs(OutputDevice, args.controller, pins, ledger=args.ledger)
server = HTTPServer(("127.0.0.1", 8765), handler_for(outputs))
server.timeout = .05
stop = threading.Event()
signal.signal(signal.SIGTERM, lambda *_: stop.set())
signal.signal(signal.SIGINT, lambda *_: stop.set())
def watchdog():
while not stop.wait(.02):
outputs.tick()
worker = threading.Thread(target=watchdog, daemon=True)
worker.start()
print(f"GPIO bridge ready: controller={args.controller}, BCM pins={pins}, localhost:8765", flush=True)
try:
while not stop.is_set():
server.handle_request()
finally:
stop.set()
worker.join()
outputs.close()
server.server_close()
if __name__ == "__main__":
main()