Mercurial > code > home > repos > front-door-lock
annotate front_door_lock.py @ 13:3014db0a5500 default tip
mv board to proj/micro, rename this repo with dashes
author | drewp@bigasterisk.com |
---|---|
date | Fri, 28 Jun 2024 17:08:09 -0700 |
parents | caea36c8289f |
children |
rev | line source |
---|---|
0 | 1 """ |
3 | 2 Output mqtt messages: |
0 | 3 frontdoorlock/switch/strike/command 'ON' |
4 frontdoorlock/switch/strike/command 'OFF' | |
5 | |
3 | 6 Simple command mode: |
7 PUT /api/simple/unlock | |
8 PUT /api/simple/lock | |
9 PUT /api/simple/stayUnlocked | |
0 | 10 |
3 | 11 Planned rdf mode: |
12 Watch a collector graph that includes the graph from the fingerprint service. | |
13 When that graph contains 'unlockRequest' and an agent, we do our unlock command. | |
0 | 14 """ |
15 | |
16 import asyncio | |
17 import logging | |
18 import time | |
19 from dataclasses import dataclass | |
1 | 20 from functools import partial |
0 | 21 from typing import Optional, cast |
22 | |
23 import aiomqtt | |
24 from patchablegraph import PatchableGraph | |
25 from patchablegraph.handler import GraphEvents, StaticGraph | |
1 | 26 from rdfdb.patch import Patch |
0 | 27 from rdflib import Literal, Namespace, URIRef |
28 from starlette.applications import Starlette | |
1 | 29 from starlette.exceptions import HTTPException |
0 | 30 from starlette.requests import Request |
8
caea36c8289f
don't try to reconnect mqtt (was broken); just fail a k8s health check
drewp@bigasterisk.com
parents:
7
diff
changeset
|
31 from starlette.responses import JSONResponse, PlainTextResponse |
0 | 32 from starlette.routing import Route |
33 from starlette_exporter import PrometheusMiddleware, handle_metrics | |
5 | 34 from prometheus_client import Gauge |
4 | 35 from get_agent import getFoafAgent |
0 | 36 |
37 logging.basicConfig(level=logging.INFO) | |
38 log = logging.getLogger() | |
39 | |
40 ROOM = Namespace('http://projects.bigasterisk.com/room/') | |
41 ctx = ROOM['frontDoorLockGraph'] | |
42 lockUri = ROOM['frontDoorLock'] | |
43 | |
5 | 44 MQTT_CONNECTED = Gauge('mqtt_connected', 'mqtt is connected') |
45 HW_CONNECTED = Gauge('hw_connected', 'esp is connected') | |
0 | 46 |
47 def output(graph: PatchableGraph, request: Request) -> JSONResponse: | |
48 return JSONResponse({"demo": "hello"}) | |
49 | |
50 | |
51 def status(graph: PatchableGraph, request: Request) -> JSONResponse: | |
52 with graph.currentState() as current: | |
53 sneakGraph = current.graph # current doesn't expose __contains__ | |
54 return JSONResponse({ | |
55 "locked": (lockUri, ROOM['state'], ROOM['locked'], ctx) in sneakGraph, | |
56 "unlocked": (lockUri, ROOM['state'], ROOM['unlocked'], ctx) in sneakGraph, | |
57 }) | |
58 | |
59 | |
4 | 60 # missing feature for patchObject |
61 def patchObjectToNone(g: PatchableGraph, ctx, subj, pred): | |
0 | 62 p = g.getObjectPatch(ctx, subj, pred, URIRef('unused')) |
63 g.patch(Patch(delQuads=p.delQuads, addQuads=[])) | |
64 | |
65 | |
66 @dataclass | |
67 class LockHardware: | |
68 graph: PatchableGraph | |
69 mqtt: Optional['MqttConnection'] = None | |
70 | |
71 def __post_init__(self): | |
72 self.writeHwLockStateToGraph(ROOM['unknown']) | |
73 | |
74 def setOnline(self, yes: bool): | |
75 self.graph.patchObject(ctx, lockUri, ROOM['hardwareConnected'], Literal(yes)) | |
76 | |
77 def writeHwLockStateToGraph(self, state: URIRef): | |
78 self.graph.patchObject(ctx, lockUri, ROOM['state'], state) | |
79 | |
4 | 80 async def unlock(self, foafAgent: URIRef | None, autoLock=True): |
81 if foafAgent is None: | |
0 | 82 raise HTTPException(403) |
83 if self.mqtt is None: | |
84 raise TypeError | |
85 log.info("mock: await self.mqtt.sendStrikeCommand(True)") | |
86 await self.mqtt.sendStrikeCommand(True) | |
87 if autoLock: | |
4 | 88 asyncio.create_task(self.autoLockTask(foafAgent, sec=6)) |
0 | 89 |
4 | 90 async def autoLockTask(self, foafAgent: URIRef, sec: float): |
0 | 91 """running more than one of these should be safe""" |
92 end = time.time() + sec | |
93 while now := time.time(): | |
94 if now > end: | |
95 patchObjectToNone(self.graph, ctx, lockUri, ROOM['secondsUntilAutoLock']) | |
4 | 96 await self.lock(foafAgent) |
0 | 97 return |
98 await asyncio.sleep(.7) | |
99 secUntil = round(end - now, 1) | |
100 self.graph.patchObject(ctx, lockUri, ROOM['secondsUntilAutoLock'], Literal(secUntil)) | |
1 | 101 log.info(f"{secUntil} sec until autolock") |
0 | 102 |
4 | 103 async def lock(self, foafAgent: URIRef | None): |
104 if foafAgent is None: | |
0 | 105 raise HTTPException(403) |
106 if self.mqtt is None: | |
107 raise TypeError | |
108 await self.mqtt.sendStrikeCommand(False) | |
109 | |
110 | |
111 @dataclass | |
112 class MqttConnection: | |
113 | |
114 hw: LockHardware | |
115 topicRoot: str = 'frontdoorlock' | |
116 | |
117 def startup(self): | |
8
caea36c8289f
don't try to reconnect mqtt (was broken); just fail a k8s health check
drewp@bigasterisk.com
parents:
7
diff
changeset
|
118 self.task = asyncio.create_task(self._go()) |
0 | 119 |
1 | 120 async def _go(self): |
7 | 121 self.client = aiomqtt.Client("mqtt1", 1883, client_id="lock-service-%s" % time.time(), keepalive=6) |
8
caea36c8289f
don't try to reconnect mqtt (was broken); just fail a k8s health check
drewp@bigasterisk.com
parents:
7
diff
changeset
|
122 try: |
caea36c8289f
don't try to reconnect mqtt (was broken); just fail a k8s health check
drewp@bigasterisk.com
parents:
7
diff
changeset
|
123 async with self.client: |
caea36c8289f
don't try to reconnect mqtt (was broken); just fail a k8s health check
drewp@bigasterisk.com
parents:
7
diff
changeset
|
124 MQTT_CONNECTED.set(1) |
caea36c8289f
don't try to reconnect mqtt (was broken); just fail a k8s health check
drewp@bigasterisk.com
parents:
7
diff
changeset
|
125 await self._handleMessages() |
caea36c8289f
don't try to reconnect mqtt (was broken); just fail a k8s health check
drewp@bigasterisk.com
parents:
7
diff
changeset
|
126 except aiomqtt.MqttError: |
caea36c8289f
don't try to reconnect mqtt (was broken); just fail a k8s health check
drewp@bigasterisk.com
parents:
7
diff
changeset
|
127 MQTT_CONNECTED.set(0) |
caea36c8289f
don't try to reconnect mqtt (was broken); just fail a k8s health check
drewp@bigasterisk.com
parents:
7
diff
changeset
|
128 log.error('mqtt down', exc_info=True) |
0 | 129 |
1 | 130 async def _handleMessages(self): |
0 | 131 async with self.client.messages() as messages: |
132 await self.client.subscribe(self.topicRoot + '/#') | |
133 async for message in messages: | |
134 try: | |
1 | 135 self._onMessage(message) |
0 | 136 except Exception: |
137 log.error(f'onMessage {message=}', exc_info=True) | |
138 await asyncio.sleep(1) | |
139 | |
140 async def sendStrikeCommand(self, value: bool): | |
141 await self.client.publish(self.topicRoot + '/switch/strike/command', 'ON' if value else 'OFF', qos=0, retain=False) | |
142 | |
1 | 143 def _stateFromMqtt(self, payload: str) -> URIRef: |
0 | 144 return { |
5 | 145 '': ROOM['unknownState'], |
0 | 146 'OFF': ROOM['locked'], |
147 'ON': ROOM['unlocked'], | |
148 }[payload] | |
149 | |
1 | 150 def _onMessage(self, message: aiomqtt.Message): |
0 | 151 subtopic = str(message.topic).partition(self.topicRoot + '/')[2] |
152 payload = cast(bytes, message.payload).decode('utf-8') | |
153 match subtopic: | |
154 case 'switch/strike/command': | |
155 log.info(f'command message: {subtopic} {payload=}') | |
156 case 'switch/strike/state': | |
157 log.info(f'hw reports strike state = {payload}') | |
1 | 158 self.hw.writeHwLockStateToGraph(self._stateFromMqtt(payload)) |
0 | 159 case 'status': |
160 self.hw.setOnline(payload == 'online') | |
5 | 161 HW_CONNECTED.set(payload == 'online') |
0 | 162 case 'debug': |
163 log.info(f'hw debug: {payload}') # note: may include ansi colors | |
164 case _: | |
165 raise NotImplementedError(subtopic) | |
166 | |
167 | |
168 async def simpleCommand(hw: LockHardware, req: Request) -> JSONResponse: | |
169 command = req.path_params['command'] | |
4 | 170 |
171 foafAgent = await getFoafAgent(req) | |
172 | |
173 log.info(f'{command=} from {foafAgent=}') | |
0 | 174 match command: |
175 case 'unlock': | |
4 | 176 await hw.unlock(foafAgent) |
0 | 177 case 'lock': |
4 | 178 await hw.lock(foafAgent) |
0 | 179 case 'stayUnlocked': |
4 | 180 await hw.unlock(foafAgent, autoLock=False) |
0 | 181 case _: |
182 raise NotImplementedError(command) | |
183 return JSONResponse({'ok': True}) | |
184 | |
8
caea36c8289f
don't try to reconnect mqtt (was broken); just fail a k8s health check
drewp@bigasterisk.com
parents:
7
diff
changeset
|
185 def health(mqtt: MqttConnection, req: Request) -> PlainTextResponse: |
caea36c8289f
don't try to reconnect mqtt (was broken); just fail a k8s health check
drewp@bigasterisk.com
parents:
7
diff
changeset
|
186 if mqtt.task.done(): |
caea36c8289f
don't try to reconnect mqtt (was broken); just fail a k8s health check
drewp@bigasterisk.com
parents:
7
diff
changeset
|
187 return PlainTextResponse('no mqtt task', status_code=500) |
caea36c8289f
don't try to reconnect mqtt (was broken); just fail a k8s health check
drewp@bigasterisk.com
parents:
7
diff
changeset
|
188 return PlainTextResponse('ok') |
caea36c8289f
don't try to reconnect mqtt (was broken); just fail a k8s health check
drewp@bigasterisk.com
parents:
7
diff
changeset
|
189 |
0 | 190 |
191 def main(): | |
192 graph = PatchableGraph() | |
193 hw = LockHardware(graph) | |
194 mqtt = MqttConnection(hw) | |
195 hw.mqtt = mqtt | |
196 app = Starlette(debug=True, | |
197 on_startup=[mqtt.startup], | |
198 routes=[ | |
8
caea36c8289f
don't try to reconnect mqtt (was broken); just fail a k8s health check
drewp@bigasterisk.com
parents:
7
diff
changeset
|
199 Route('/health', partial(health, mqtt)), |
0 | 200 Route('/api/status', partial(status, graph)), |
201 Route('/api/output', partial(output, graph)), | |
202 Route('/api/graph', StaticGraph(graph)), | |
203 Route('/api/graph/events', GraphEvents(graph)), | |
204 Route('/api/simple/{command:str}', partial(simpleCommand, hw), methods=['PUT']), | |
205 ]) | |
206 | |
207 app.add_middleware(PrometheusMiddleware, app_name='front_door_lock') | |
208 app.add_route("/metrics", handle_metrics) | |
209 | |
210 return app | |
211 | |
212 | |
213 app = main() |