-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.py
More file actions
executable file
·160 lines (118 loc) · 5.85 KB
/
Copy pathserver.py
File metadata and controls
executable file
·160 lines (118 loc) · 5.85 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
#!/usr/bin/env python3
"""AntScopeZ Web -- browser/iPhone bridge to AntScopeZ's Remote API.
Runs as its own process on the same machine as AntScopeZ. Connects
upstream to AntScopeZ's Remote API over 127.0.0.1 (same trust model
the Remote API itself uses) and serves a small web UI on 0.0.0.0:<port>
so a phone on the same Wi-Fi can reach it. See README.md.
"""
import argparse
import asyncio
import json
import logging
from pathlib import Path
from aiohttp import web, WSMsgType
from antscopez_client import AntScopeZBridge
logger = logging.getLogger("antscopez_web")
STATIC_DIR = Path(__file__).parent / "static"
# Commands a browser is allowed to trigger directly. subscribe/unsubscribe
# are deliberately excluded -- the bridge subscribes once for everyone
# up front (see AntScopeZBridge._connect_once) so a browser can't
# accidentally unsubscribe the shared stream for every other tab/phone.
ALLOWED_COMMANDS = {"status", "devices", "connect", "disconnect", "sweep", "stop", "last"}
async def ws_handler(request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse(heartbeat=30)
await ws.prepare(request)
bridge: AntScopeZBridge = request.app["bridge"]
websockets: set = request.app["websockets"]
websockets.add(ws)
def on_event(name: str, fields: dict) -> None:
_fire_and_forget(ws.send_json({"event": name, **fields}))
bridge.add_event_listener(on_event)
try:
await ws.send_json({"event": "bridge_status", "upstream_connected": bridge.connected})
async for msg in ws:
if msg.type == WSMsgType.TEXT:
await _handle_message(ws, bridge, msg.data)
elif msg.type == WSMsgType.ERROR:
logger.warning("websocket closed with exception %s", ws.exception())
finally:
bridge.remove_event_listener(on_event)
websockets.discard(ws)
return ws
def _fire_and_forget(coro) -> None:
# Sends to a websocket that's mid-close routinely raise -- log at debug
# rather than let asyncio print "exception never retrieved" warnings.
task = asyncio.create_task(coro)
def _log_if_failed(t: asyncio.Task) -> None:
if not t.cancelled() and t.exception() is not None:
logger.debug("websocket send failed: %s", t.exception())
task.add_done_callback(_log_if_failed)
async def _handle_message(ws: web.WebSocketResponse, bridge: AntScopeZBridge, raw: str) -> None:
try:
msg = json.loads(raw)
except json.JSONDecodeError:
await ws.send_json({"ok": False, "error": "invalid JSON"})
return
client_id = msg.get("id")
cmd = msg.get("cmd")
if cmd not in ALLOWED_COMMANDS:
await ws.send_json({"id": client_id, "ok": False, "error": f"unknown or unsupported command: {cmd!r}"})
return
params = {k: v for k, v in msg.items() if k not in ("cmd", "id")}
try:
reply = await bridge.request(cmd, **params)
except (ConnectionError, TimeoutError) as exc:
await ws.send_json({"id": client_id, "ok": False, "error": str(exc)})
return
except RuntimeError as exc:
await ws.send_json({"id": client_id, "ok": False, "error": str(exc)})
return
reply["id"] = client_id
await ws.send_json(reply)
async def on_startup(app: web.Application) -> None:
bridge: AntScopeZBridge = app["bridge"]
websockets: set = app["websockets"]
def broadcast_status(connected: bool) -> None:
for ws in list(websockets):
_fire_and_forget(ws.send_json({"event": "bridge_status", "upstream_connected": connected}))
bridge.add_status_listener(broadcast_status)
bridge.start()
async def on_cleanup(app: web.Application) -> None:
bridge: AntScopeZBridge = app["bridge"]
await bridge.stop()
for ws in list(app["websockets"]):
await ws.close()
async def index_handler(request: web.Request) -> web.FileResponse:
return web.FileResponse(STATIC_DIR / "index.html")
def build_app(antscopez_host: str, antscopez_port: int) -> web.Application:
app = web.Application()
app["bridge"] = AntScopeZBridge(antscopez_host, antscopez_port)
app["websockets"] = set()
app.on_startup.append(on_startup)
app.on_cleanup.append(on_cleanup)
app.router.add_get("/ws", ws_handler)
# aiohttp's static mount doesn't serve index.html for "/" on its own
# (unlike e.g. nginx), so that exact path needs an explicit route.
app.router.add_get("/", index_handler)
app.router.add_static("/", STATIC_DIR, show_index=False, name="static")
return app
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--antscopez-host", default="127.0.0.1",
help="AntScopeZ Remote API host (default: 127.0.0.1 -- it only ever binds loopback)")
parser.add_argument("--antscopez-port", type=int, default=7443,
help="AntScopeZ Remote API port (default: 7443, matches AntScopeZ's own default)")
parser.add_argument("--listen-host", default="0.0.0.0",
help="address this bridge's web UI listens on (default: 0.0.0.0, so your phone can reach it)")
parser.add_argument("--listen-port", type=int, default=8000,
help="port this bridge's web UI listens on (default: 8000)")
parser.add_argument("-v", "--verbose", action="store_true", help="debug logging")
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
app = build_app(args.antscopez_host, args.antscopez_port)
logger.info("serving web UI on http://%s:%d/ (upstream AntScopeZ at %s:%d)",
args.listen_host, args.listen_port, args.antscopez_host, args.antscopez_port)
web.run_app(app, host=args.listen_host, port=args.listen_port, print=None)
if __name__ == "__main__":
main()