annotate front_door_lock.py @ 12:53e4020141fd

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