annotate front_door_lock.py @ 0:4365c72c59f6

start
author drewp@bigasterisk.com
date Sun, 27 Aug 2023 11:12:20 -0700
parents
children 3b82ee3b9d79
Ignore whitespace changes - Everywhere: Within whitespace: At end of lines:
rev   line source
0
drewp@bigasterisk.com
parents:
diff changeset
1 """
drewp@bigasterisk.com
parents:
diff changeset
2 When a client requests
drewp@bigasterisk.com
parents:
diff changeset
3 PUT /output
drewp@bigasterisk.com
parents:
diff changeset
4 body room:unlocked
drewp@bigasterisk.com
parents:
diff changeset
5 head x-foaf-agent: <uri>
drewp@bigasterisk.com
parents:
diff changeset
6
drewp@bigasterisk.com
parents:
diff changeset
7 Then send
drewp@bigasterisk.com
parents:
diff changeset
8 frontdoorlock/switch/strike/command 'ON'
drewp@bigasterisk.com
parents:
diff changeset
9
drewp@bigasterisk.com
parents:
diff changeset
10 Then after a time send
drewp@bigasterisk.com
parents:
diff changeset
11 frontdoorlock/switch/strike/command 'OFF'
drewp@bigasterisk.com
parents:
diff changeset
12
drewp@bigasterisk.com
parents:
diff changeset
13 Also report on
drewp@bigasterisk.com
parents:
diff changeset
14 frontdoorlock/status 'online'
drewp@bigasterisk.com
parents:
diff changeset
15 --
drewp@bigasterisk.com
parents:
diff changeset
16
drewp@bigasterisk.com
parents:
diff changeset
17 Plus, for reliability, a simpler web control ui.
drewp@bigasterisk.com
parents:
diff changeset
18 """
drewp@bigasterisk.com
parents:
diff changeset
19
drewp@bigasterisk.com
parents:
diff changeset
20 import asyncio
drewp@bigasterisk.com
parents:
diff changeset
21 from functools import partial
drewp@bigasterisk.com
parents:
diff changeset
22 import logging
drewp@bigasterisk.com
parents:
diff changeset
23 import time
drewp@bigasterisk.com
parents:
diff changeset
24 from dataclasses import dataclass
drewp@bigasterisk.com
parents:
diff changeset
25 from typing import Optional, cast
drewp@bigasterisk.com
parents:
diff changeset
26
drewp@bigasterisk.com
parents:
diff changeset
27 import aiomqtt
drewp@bigasterisk.com
parents:
diff changeset
28 import background_loop
drewp@bigasterisk.com
parents:
diff changeset
29 from patchablegraph import PatchableGraph
drewp@bigasterisk.com
parents:
diff changeset
30 from patchablegraph.handler import GraphEvents, StaticGraph
drewp@bigasterisk.com
parents:
diff changeset
31 from rdflib import Literal, Namespace, URIRef
drewp@bigasterisk.com
parents:
diff changeset
32 from starlette.applications import Starlette
drewp@bigasterisk.com
parents:
diff changeset
33 from starlette.requests import Request
drewp@bigasterisk.com
parents:
diff changeset
34 from starlette.responses import JSONResponse
drewp@bigasterisk.com
parents:
diff changeset
35 from starlette.routing import Route
drewp@bigasterisk.com
parents:
diff changeset
36 from starlette.exceptions import HTTPException
drewp@bigasterisk.com
parents:
diff changeset
37 from starlette_exporter import PrometheusMiddleware, handle_metrics
drewp@bigasterisk.com
parents:
diff changeset
38
drewp@bigasterisk.com
parents:
diff changeset
39 from get_agent import Agent, getAgent
drewp@bigasterisk.com
parents:
diff changeset
40 from rdfdb.patch import Patch
drewp@bigasterisk.com
parents:
diff changeset
41
drewp@bigasterisk.com
parents:
diff changeset
42 logging.basicConfig(level=logging.INFO)
drewp@bigasterisk.com
parents:
diff changeset
43 log = logging.getLogger()
drewp@bigasterisk.com
parents:
diff changeset
44
drewp@bigasterisk.com
parents:
diff changeset
45 ROOM = Namespace('http://projects.bigasterisk.com/room/')
drewp@bigasterisk.com
parents:
diff changeset
46 ctx = ROOM['frontDoorLockGraph']
drewp@bigasterisk.com
parents:
diff changeset
47 lockUri = ROOM['frontDoorLock']
drewp@bigasterisk.com
parents:
diff changeset
48
drewp@bigasterisk.com
parents:
diff changeset
49
drewp@bigasterisk.com
parents:
diff changeset
50 def output(graph: PatchableGraph, request: Request) -> JSONResponse:
drewp@bigasterisk.com
parents:
diff changeset
51 return JSONResponse({"demo": "hello"})
drewp@bigasterisk.com
parents:
diff changeset
52
drewp@bigasterisk.com
parents:
diff changeset
53
drewp@bigasterisk.com
parents:
diff changeset
54 def status(graph: PatchableGraph, request: Request) -> JSONResponse:
drewp@bigasterisk.com
parents:
diff changeset
55 with graph.currentState() as current:
drewp@bigasterisk.com
parents:
diff changeset
56 sneakGraph = current.graph # current doesn't expose __contains__
drewp@bigasterisk.com
parents:
diff changeset
57 return JSONResponse({
drewp@bigasterisk.com
parents:
diff changeset
58 "locked": (lockUri, ROOM['state'], ROOM['locked'], ctx) in sneakGraph,
drewp@bigasterisk.com
parents:
diff changeset
59 "unlocked": (lockUri, ROOM['state'], ROOM['unlocked'], ctx) in sneakGraph,
drewp@bigasterisk.com
parents:
diff changeset
60 })
drewp@bigasterisk.com
parents:
diff changeset
61
drewp@bigasterisk.com
parents:
diff changeset
62
drewp@bigasterisk.com
parents:
diff changeset
63
drewp@bigasterisk.com
parents:
diff changeset
64 def patchObjectToNone(g: PatchableGraph, ctx, subj, pred): #missing feature for patchObject
drewp@bigasterisk.com
parents:
diff changeset
65 p = g.getObjectPatch(ctx, subj, pred, URIRef('unused'))
drewp@bigasterisk.com
parents:
diff changeset
66 g.patch(Patch(delQuads=p.delQuads, addQuads=[]))
drewp@bigasterisk.com
parents:
diff changeset
67
drewp@bigasterisk.com
parents:
diff changeset
68
drewp@bigasterisk.com
parents:
diff changeset
69 @dataclass
drewp@bigasterisk.com
parents:
diff changeset
70 class LockHardware:
drewp@bigasterisk.com
parents:
diff changeset
71 graph: PatchableGraph
drewp@bigasterisk.com
parents:
diff changeset
72 mqtt: Optional['MqttConnection'] = None
drewp@bigasterisk.com
parents:
diff changeset
73
drewp@bigasterisk.com
parents:
diff changeset
74 def __post_init__(self):
drewp@bigasterisk.com
parents:
diff changeset
75 self.writeHwLockStateToGraph(ROOM['unknown'])
drewp@bigasterisk.com
parents:
diff changeset
76
drewp@bigasterisk.com
parents:
diff changeset
77 def setOnline(self, yes: bool):
drewp@bigasterisk.com
parents:
diff changeset
78 self.graph.patchObject(ctx, lockUri, ROOM['hardwareConnected'], Literal(yes))
drewp@bigasterisk.com
parents:
diff changeset
79
drewp@bigasterisk.com
parents:
diff changeset
80 def writeHwLockStateToGraph(self, state: URIRef):
drewp@bigasterisk.com
parents:
diff changeset
81 self.graph.patchObject(ctx, lockUri, ROOM['state'], state)
drewp@bigasterisk.com
parents:
diff changeset
82
drewp@bigasterisk.com
parents:
diff changeset
83 async def unlock(self, agent: Agent | None, autoLock=True):
drewp@bigasterisk.com
parents:
diff changeset
84 if agent is None:
drewp@bigasterisk.com
parents:
diff changeset
85 raise HTTPException(403)
drewp@bigasterisk.com
parents:
diff changeset
86 if self.mqtt is None:
drewp@bigasterisk.com
parents:
diff changeset
87 raise TypeError
drewp@bigasterisk.com
parents:
diff changeset
88 log.info("mock: await self.mqtt.sendStrikeCommand(True)")
drewp@bigasterisk.com
parents:
diff changeset
89 await self.mqtt.sendStrikeCommand(True)
drewp@bigasterisk.com
parents:
diff changeset
90 if autoLock:
drewp@bigasterisk.com
parents:
diff changeset
91 asyncio.create_task(self.autoLockTask(agent, sec=6))
drewp@bigasterisk.com
parents:
diff changeset
92
drewp@bigasterisk.com
parents:
diff changeset
93 async def autoLockTask(self, agent: Agent, sec: float):
drewp@bigasterisk.com
parents:
diff changeset
94 """running more than one of these should be safe"""
drewp@bigasterisk.com
parents:
diff changeset
95 end = time.time() + sec
drewp@bigasterisk.com
parents:
diff changeset
96 while now := time.time():
drewp@bigasterisk.com
parents:
diff changeset
97 if now > end:
drewp@bigasterisk.com
parents:
diff changeset
98 patchObjectToNone(self.graph, ctx, lockUri, ROOM['secondsUntilAutoLock'])
drewp@bigasterisk.com
parents:
diff changeset
99 await self.lock(agent)
drewp@bigasterisk.com
parents:
diff changeset
100 return
drewp@bigasterisk.com
parents:
diff changeset
101 await asyncio.sleep(.7)
drewp@bigasterisk.com
parents:
diff changeset
102 secUntil = round(end - now, 1)
drewp@bigasterisk.com
parents:
diff changeset
103 self.graph.patchObject(ctx, lockUri, ROOM['secondsUntilAutoLock'], Literal(secUntil))
drewp@bigasterisk.com
parents:
diff changeset
104 log.info(f"{end-now} sec until autolock")
drewp@bigasterisk.com
parents:
diff changeset
105
drewp@bigasterisk.com
parents:
diff changeset
106 async def lock(self, agent: Agent | None):
drewp@bigasterisk.com
parents:
diff changeset
107 if agent is None:
drewp@bigasterisk.com
parents:
diff changeset
108 raise HTTPException(403)
drewp@bigasterisk.com
parents:
diff changeset
109 if self.mqtt is None:
drewp@bigasterisk.com
parents:
diff changeset
110 raise TypeError
drewp@bigasterisk.com
parents:
diff changeset
111 await self.mqtt.sendStrikeCommand(False)
drewp@bigasterisk.com
parents:
diff changeset
112
drewp@bigasterisk.com
parents:
diff changeset
113
drewp@bigasterisk.com
parents:
diff changeset
114 @dataclass
drewp@bigasterisk.com
parents:
diff changeset
115 class MqttConnection:
drewp@bigasterisk.com
parents:
diff changeset
116
drewp@bigasterisk.com
parents:
diff changeset
117 hw: LockHardware
drewp@bigasterisk.com
parents:
diff changeset
118 topicRoot: str = 'frontdoorlock'
drewp@bigasterisk.com
parents:
diff changeset
119
drewp@bigasterisk.com
parents:
diff changeset
120 def startup(self):
drewp@bigasterisk.com
parents:
diff changeset
121 asyncio.create_task(self.go())
drewp@bigasterisk.com
parents:
diff changeset
122
drewp@bigasterisk.com
parents:
diff changeset
123 async def go(self):
drewp@bigasterisk.com
parents:
diff changeset
124 self.client = aiomqtt.Client("mosquitto-frontdoor", 10210, client_id="lock-service-%s" % time.time(), keepalive=6)
drewp@bigasterisk.com
parents:
diff changeset
125 while True:
drewp@bigasterisk.com
parents:
diff changeset
126 try:
drewp@bigasterisk.com
parents:
diff changeset
127 async with self.client:
drewp@bigasterisk.com
parents:
diff changeset
128 await self.handleMessages()
drewp@bigasterisk.com
parents:
diff changeset
129 except aiomqtt.MqttError:
drewp@bigasterisk.com
parents:
diff changeset
130 log.error('mqtt reconnecting', exc_info=True)
drewp@bigasterisk.com
parents:
diff changeset
131 await asyncio.sleep(5)
drewp@bigasterisk.com
parents:
diff changeset
132
drewp@bigasterisk.com
parents:
diff changeset
133 async def handleMessages(self):
drewp@bigasterisk.com
parents:
diff changeset
134 async with self.client.messages() as messages:
drewp@bigasterisk.com
parents:
diff changeset
135 await self.client.subscribe(self.topicRoot + '/#')
drewp@bigasterisk.com
parents:
diff changeset
136 async for message in messages:
drewp@bigasterisk.com
parents:
diff changeset
137 try:
drewp@bigasterisk.com
parents:
diff changeset
138 self.onMessage(message)
drewp@bigasterisk.com
parents:
diff changeset
139 except Exception:
drewp@bigasterisk.com
parents:
diff changeset
140 log.error(f'onMessage {message=}', exc_info=True)
drewp@bigasterisk.com
parents:
diff changeset
141 await asyncio.sleep(1)
drewp@bigasterisk.com
parents:
diff changeset
142
drewp@bigasterisk.com
parents:
diff changeset
143 async def sendStrikeCommand(self, value: bool):
drewp@bigasterisk.com
parents:
diff changeset
144 await self.client.publish(self.topicRoot + '/switch/strike/command', 'ON' if value else 'OFF', qos=0, retain=False)
drewp@bigasterisk.com
parents:
diff changeset
145
drewp@bigasterisk.com
parents:
diff changeset
146 def stateFromMqtt(self, payload: str) -> URIRef:
drewp@bigasterisk.com
parents:
diff changeset
147 return {
drewp@bigasterisk.com
parents:
diff changeset
148 'OFF': ROOM['locked'],
drewp@bigasterisk.com
parents:
diff changeset
149 'ON': ROOM['unlocked'],
drewp@bigasterisk.com
parents:
diff changeset
150 }[payload]
drewp@bigasterisk.com
parents:
diff changeset
151
drewp@bigasterisk.com
parents:
diff changeset
152 def onMessage(self, message: aiomqtt.Message):
drewp@bigasterisk.com
parents:
diff changeset
153 subtopic = str(message.topic).partition(self.topicRoot + '/')[2]
drewp@bigasterisk.com
parents:
diff changeset
154 payload = cast(bytes, message.payload).decode('utf-8')
drewp@bigasterisk.com
parents:
diff changeset
155 match subtopic:
drewp@bigasterisk.com
parents:
diff changeset
156 case 'switch/strike/command':
drewp@bigasterisk.com
parents:
diff changeset
157 log.info(f'command message: {subtopic} {payload=}')
drewp@bigasterisk.com
parents:
diff changeset
158 case 'switch/strike/state':
drewp@bigasterisk.com
parents:
diff changeset
159 log.info(f'hw reports strike state = {payload}')
drewp@bigasterisk.com
parents:
diff changeset
160 self.hw.writeHwLockStateToGraph(self.stateFromMqtt(payload))
drewp@bigasterisk.com
parents:
diff changeset
161 case 'status':
drewp@bigasterisk.com
parents:
diff changeset
162 self.hw.setOnline(payload == 'online')
drewp@bigasterisk.com
parents:
diff changeset
163 case 'debug':
drewp@bigasterisk.com
parents:
diff changeset
164 log.info(f'hw debug: {payload}') # note: may include ansi colors
drewp@bigasterisk.com
parents:
diff changeset
165 case _:
drewp@bigasterisk.com
parents:
diff changeset
166 raise NotImplementedError(subtopic)
drewp@bigasterisk.com
parents:
diff changeset
167
drewp@bigasterisk.com
parents:
diff changeset
168
drewp@bigasterisk.com
parents:
diff changeset
169 async def simpleCommand(hw: LockHardware, req: Request) -> JSONResponse:
drewp@bigasterisk.com
parents:
diff changeset
170 command = req.path_params['command']
drewp@bigasterisk.com
parents:
diff changeset
171 agent = await getAgent(req)
drewp@bigasterisk.com
parents:
diff changeset
172 log.info(f'{command=} from {agent.asDict() if agent else agent}')
drewp@bigasterisk.com
parents:
diff changeset
173 match command:
drewp@bigasterisk.com
parents:
diff changeset
174 case 'unlock':
drewp@bigasterisk.com
parents:
diff changeset
175 await hw.unlock(agent)
drewp@bigasterisk.com
parents:
diff changeset
176 case 'lock':
drewp@bigasterisk.com
parents:
diff changeset
177 await hw.lock(agent)
drewp@bigasterisk.com
parents:
diff changeset
178 case 'stayUnlocked':
drewp@bigasterisk.com
parents:
diff changeset
179 await hw.unlock(agent, autoLock=False)
drewp@bigasterisk.com
parents:
diff changeset
180 case _:
drewp@bigasterisk.com
parents:
diff changeset
181 raise NotImplementedError(command)
drewp@bigasterisk.com
parents:
diff changeset
182 return JSONResponse({'ok': True})
drewp@bigasterisk.com
parents:
diff changeset
183
drewp@bigasterisk.com
parents:
diff changeset
184
drewp@bigasterisk.com
parents:
diff changeset
185 def main():
drewp@bigasterisk.com
parents:
diff changeset
186 graph = PatchableGraph()
drewp@bigasterisk.com
parents:
diff changeset
187 hw = LockHardware(graph)
drewp@bigasterisk.com
parents:
diff changeset
188 mqtt = MqttConnection(hw)
drewp@bigasterisk.com
parents:
diff changeset
189 hw.mqtt = mqtt
drewp@bigasterisk.com
parents:
diff changeset
190 app = Starlette(debug=True,
drewp@bigasterisk.com
parents:
diff changeset
191 on_startup=[mqtt.startup],
drewp@bigasterisk.com
parents:
diff changeset
192 routes=[
drewp@bigasterisk.com
parents:
diff changeset
193 Route('/api/status', partial(status, graph)),
drewp@bigasterisk.com
parents:
diff changeset
194 Route('/api/output', partial(output, graph)),
drewp@bigasterisk.com
parents:
diff changeset
195 Route('/api/graph', StaticGraph(graph)),
drewp@bigasterisk.com
parents:
diff changeset
196 Route('/api/graph/events', GraphEvents(graph)),
drewp@bigasterisk.com
parents:
diff changeset
197 Route('/api/simple/{command:str}', partial(simpleCommand, hw), methods=['PUT']),
drewp@bigasterisk.com
parents:
diff changeset
198 ])
drewp@bigasterisk.com
parents:
diff changeset
199
drewp@bigasterisk.com
parents:
diff changeset
200 app.add_middleware(PrometheusMiddleware, app_name='front_door_lock')
drewp@bigasterisk.com
parents:
diff changeset
201 app.add_route("/metrics", handle_metrics)
drewp@bigasterisk.com
parents:
diff changeset
202
drewp@bigasterisk.com
parents:
diff changeset
203 return app
drewp@bigasterisk.com
parents:
diff changeset
204
drewp@bigasterisk.com
parents:
diff changeset
205
drewp@bigasterisk.com
parents:
diff changeset
206 app = main()