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
|
|
31 from starlette.responses import JSONResponse
|
|
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):
|
1
|
118 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)
|
0
|
122 while True:
|
|
123 try:
|
|
124 async with self.client:
|
5
|
125 MQTT_CONNECTED.set(1)
|
1
|
126 await self._handleMessages()
|
0
|
127 except aiomqtt.MqttError:
|
5
|
128 MQTT_CONNECTED.set(0)
|
0
|
129 log.error('mqtt reconnecting', exc_info=True)
|
|
130 await asyncio.sleep(5)
|
5
|
131 finally:
|
|
132 MQTT_CONNECTED.set(0)
|
0
|
133
|
1
|
134 async def _handleMessages(self):
|
0
|
135 async with self.client.messages() as messages:
|
|
136 await self.client.subscribe(self.topicRoot + '/#')
|
|
137 async for message in messages:
|
|
138 try:
|
1
|
139 self._onMessage(message)
|
0
|
140 except Exception:
|
|
141 log.error(f'onMessage {message=}', exc_info=True)
|
|
142 await asyncio.sleep(1)
|
|
143
|
|
144 async def sendStrikeCommand(self, value: bool):
|
|
145 await self.client.publish(self.topicRoot + '/switch/strike/command', 'ON' if value else 'OFF', qos=0, retain=False)
|
|
146
|
1
|
147 def _stateFromMqtt(self, payload: str) -> URIRef:
|
0
|
148 return {
|
5
|
149 '': ROOM['unknownState'],
|
0
|
150 'OFF': ROOM['locked'],
|
|
151 'ON': ROOM['unlocked'],
|
|
152 }[payload]
|
|
153
|
1
|
154 def _onMessage(self, message: aiomqtt.Message):
|
0
|
155 subtopic = str(message.topic).partition(self.topicRoot + '/')[2]
|
|
156 payload = cast(bytes, message.payload).decode('utf-8')
|
|
157 match subtopic:
|
|
158 case 'switch/strike/command':
|
|
159 log.info(f'command message: {subtopic} {payload=}')
|
|
160 case 'switch/strike/state':
|
|
161 log.info(f'hw reports strike state = {payload}')
|
1
|
162 self.hw.writeHwLockStateToGraph(self._stateFromMqtt(payload))
|
0
|
163 case 'status':
|
|
164 self.hw.setOnline(payload == 'online')
|
5
|
165 HW_CONNECTED.set(payload == 'online')
|
0
|
166 case 'debug':
|
|
167 log.info(f'hw debug: {payload}') # note: may include ansi colors
|
|
168 case _:
|
|
169 raise NotImplementedError(subtopic)
|
|
170
|
|
171
|
|
172 async def simpleCommand(hw: LockHardware, req: Request) -> JSONResponse:
|
|
173 command = req.path_params['command']
|
4
|
174
|
|
175 foafAgent = await getFoafAgent(req)
|
|
176
|
|
177 log.info(f'{command=} from {foafAgent=}')
|
0
|
178 match command:
|
|
179 case 'unlock':
|
4
|
180 await hw.unlock(foafAgent)
|
0
|
181 case 'lock':
|
4
|
182 await hw.lock(foafAgent)
|
0
|
183 case 'stayUnlocked':
|
4
|
184 await hw.unlock(foafAgent, autoLock=False)
|
0
|
185 case _:
|
|
186 raise NotImplementedError(command)
|
|
187 return JSONResponse({'ok': True})
|
|
188
|
|
189
|
|
190 def main():
|
|
191 graph = PatchableGraph()
|
|
192 hw = LockHardware(graph)
|
|
193 mqtt = MqttConnection(hw)
|
|
194 hw.mqtt = mqtt
|
|
195 app = Starlette(debug=True,
|
|
196 on_startup=[mqtt.startup],
|
|
197 routes=[
|
|
198 Route('/api/status', partial(status, graph)),
|
|
199 Route('/api/output', partial(output, graph)),
|
|
200 Route('/api/graph', StaticGraph(graph)),
|
|
201 Route('/api/graph/events', GraphEvents(graph)),
|
|
202 Route('/api/simple/{command:str}', partial(simpleCommand, hw), methods=['PUT']),
|
|
203 ])
|
|
204
|
|
205 app.add_middleware(PrometheusMiddleware, app_name='front_door_lock')
|
|
206 app.add_route("/metrics", handle_metrics)
|
|
207
|
|
208 return app
|
|
209
|
|
210
|
|
211 app = main()
|