sticklink 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. sticklink/__init__.py +1 -0
  2. sticklink/__main__.py +3 -0
  3. sticklink/analysis.py +152 -0
  4. sticklink/api.py +284 -0
  5. sticklink/cli.py +115 -0
  6. sticklink/config.py +11 -0
  7. sticklink/fxconfig.py +242 -0
  8. sticklink/geo.py +19 -0
  9. sticklink/gui.py +132 -0
  10. sticklink/obs.py +193 -0
  11. sticklink/openapi.py +342 -0
  12. sticklink/pipeline.py +34 -0
  13. sticklink/protocol/__init__.py +3 -0
  14. sticklink/protocol/ddlog.py +112 -0
  15. sticklink/radio/DDSTK.lua +197 -0
  16. sticklink/recorder.py +108 -0
  17. sticklink/scenes.py +275 -0
  18. sticklink/server.py +104 -0
  19. sticklink/service.py +83 -0
  20. sticklink/sinks/__init__.py +0 -0
  21. sticklink/sinks/jsonl_log.py +46 -0
  22. sticklink/sources/__init__.py +0 -0
  23. sticklink/sources/base.py +11 -0
  24. sticklink/sources/demo.py +60 -0
  25. sticklink/sources/replay.py +46 -0
  26. sticklink/sources/serial_port.py +37 -0
  27. sticklink/state.py +172 -0
  28. sticklink/web/client.js +45 -0
  29. sticklink/web/components.js +180 -0
  30. sticklink/web/docs.html +16 -0
  31. sticklink/web/fonts/LICENSES/bungee-OFL.txt +93 -0
  32. sticklink/web/fonts/LICENSES/fredoka-OFL.txt +93 -0
  33. sticklink/web/fonts/LICENSES/orbitron-OFL.txt +93 -0
  34. sticklink/web/fonts/LICENSES/vt323-OFL.txt +93 -0
  35. sticklink/web/fonts/bungee-latin-400-normal.woff2 +0 -0
  36. sticklink/web/fonts/fredoka-latin-700-normal.woff2 +0 -0
  37. sticklink/web/fonts/orbitron-latin-900-normal.woff2 +0 -0
  38. sticklink/web/fonts/vt323-latin-400-normal.woff2 +0 -0
  39. sticklink/web/fx.html +17 -0
  40. sticklink/web/hud.html +17 -0
  41. sticklink/web/hud.js +32 -0
  42. sticklink/web/modes.html +53 -0
  43. sticklink/web/modes.js +21 -0
  44. sticklink/web/overlay.html +16 -0
  45. sticklink/web/setup.html +33 -0
  46. sticklink/web/setup.js +21 -0
  47. sticklink/web/stickfx.js +29 -0
  48. sticklink/web/swagger/LICENSE +202 -0
  49. sticklink/web/swagger/NOTICE +2 -0
  50. sticklink/web/swagger/README.txt +1 -0
  51. sticklink/web/swagger/favicon-32x32.png +0 -0
  52. sticklink/web/swagger/init.js +6 -0
  53. sticklink/web/swagger/swagger-ui-bundle.js +2 -0
  54. sticklink/web/swagger/swagger-ui.css +3 -0
  55. sticklink-0.1.1.dist-info/METADATA +135 -0
  56. sticklink-0.1.1.dist-info/RECORD +59 -0
  57. sticklink-0.1.1.dist-info/WHEEL +5 -0
  58. sticklink-0.1.1.dist-info/entry_points.txt +2 -0
  59. sticklink-0.1.1.dist-info/top_level.txt +1 -0
sticklink/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = '0.1.1'
sticklink/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
sticklink/analysis.py ADDED
@@ -0,0 +1,152 @@
1
+ """check-log: judge whether a recorded session is good enough data."""
2
+ from .sources.replay import read_log
3
+
4
+ STICKS = ('roll', 'pitch', 'yaw', 'throttle')
5
+ CHANNELS = STICKS + ('arm', 'crash')
6
+
7
+
8
+ def percentile(sorted_values, q):
9
+ if not sorted_values:
10
+ return None
11
+ return sorted_values[min(len(sorted_values)-1, int(q*len(sorted_values)))]
12
+
13
+
14
+ def gap_stats(gaps_ms):
15
+ gaps = sorted(gaps_ms)
16
+ return dict(median=percentile(gaps, .5), p95=percentile(gaps, .95),
17
+ p99=percentile(gaps, .99), max=gaps[-1] if gaps else None)
18
+
19
+
20
+ def analyze(path):
21
+ n = dict(records=0, samples=0, events=0, telemetry=0, connections=0, disconnects=0)
22
+ first_mono = last_mono = None
23
+ last_tick = last_seq = last_sample_tick = last_sample_mono = None
24
+ missing = resets = 0
25
+ tick_gaps, host_gaps = [], []
26
+ ranges = {}
27
+ sensors = {}
28
+ sessions = set()
29
+ notes = []
30
+ out_ranges = {}
31
+ for item in read_log(path):
32
+ mono = item['received_monotonic_ns']
33
+ first_mono = mono if first_mono is None else first_mono
34
+ last_mono = mono
35
+ sessions.add(item.get('session'))
36
+ if 'connection' in item:
37
+ n['connections'] += 1
38
+ n['disconnects'] += not item['connection']['connected']
39
+ last_tick = last_seq = last_sample_tick = last_sample_mono = None
40
+ continue
41
+ rec = item['record']
42
+ n['records'] += 1
43
+ tick = rec['tick']
44
+ if last_tick is not None and tick < last_tick:
45
+ resets += 1
46
+ last_seq = last_sample_tick = None
47
+ last_tick = tick
48
+ if rec['type'] == 'H':
49
+ if last_seq is not None:
50
+ resets += 1
51
+ last_seq = last_sample_tick = None
52
+ continue
53
+ step = None if last_seq is None else (rec['seq']-last_seq) % 65536
54
+ if step is not None and step >= 32768:
55
+ resets += 1
56
+ last_sample_tick = None
57
+ elif step:
58
+ missing += step-1
59
+ last_seq = rec['seq']
60
+ if rec['type'] == 'S':
61
+ n['samples'] += 1
62
+ if last_sample_tick is not None:
63
+ tick_gaps.append((tick-last_sample_tick)*10) # ticks are 10 ms
64
+ host_gaps.append((mono-last_sample_mono)/1e6)
65
+ last_sample_tick, last_sample_mono = tick, mono
66
+ for name, value in rec['channels'].items():
67
+ lo, hi = ranges.get(name, (value, value))
68
+ ranges[name] = (min(lo, value), max(hi, value))
69
+ elif rec['type'] == 'E':
70
+ n['events'] += 1
71
+ elif rec['type'] == 'G':
72
+ n['gps'] = n.get('gps', 0) + 1
73
+ elif rec['type'] == 'D':
74
+ notes.append(rec['message'])
75
+ elif rec['type'] == 'C':
76
+ n['outputs'] = n.get('outputs', 0) + 1
77
+ for k, value in enumerate(rec['outputs']):
78
+ ch = rec['first'] + k
79
+ lo, hi = out_ranges.get(ch, (value, value))
80
+ out_ranges[ch] = (min(lo, value), max(hi, value))
81
+ else:
82
+ n['telemetry'] += 1
83
+ s = sensors.setdefault(rec['sensor'], dict(count=0, current=0,
84
+ first=mono, last=mono, lo=rec['value'], hi=rec['value']))
85
+ s['count'] += 1
86
+ s['current'] += rec['current']
87
+ s['last'] = mono
88
+ s['lo'], s['hi'] = min(s['lo'], rec['value']), max(s['hi'], rec['value'])
89
+ duration = 0 if first_mono is None else (last_mono-first_mono)/1e9
90
+ for s in sensors.values():
91
+ span = (s['last']-s['first'])/1e9
92
+ s['rate_hz'] = (s['count']-1)/span if span > 0 else None
93
+ result = dict(path=str(path), duration_s=duration, counts=n,
94
+ sample_rate_hz=(n['samples']-1)/duration if duration > 0 and n['samples'] > 1 else None,
95
+ radio_gap_ms=gap_stats(tick_gaps), host_gap_ms=gap_stats(host_gaps),
96
+ missing_seq=missing, resets=resets, sessions=len(sessions - {None}),
97
+ stick_range={k: ranges[k] for k in CHANNELS if k in ranges}, telemetry=sensors, radio_notes=notes, output_ranges=out_ranges)
98
+ result['warnings'] = warnings(result)
99
+ return result
100
+
101
+
102
+ def warnings(r):
103
+ out = []
104
+ if r['counts']['samples'] < 2:
105
+ return ['no control samples recorded']
106
+ if r['sample_rate_hz'] is not None and r['sample_rate_hz'] < 15: # None: too short (or too coarse a clock) to tell
107
+ out.append(f"average sample rate {r['sample_rate_hz']:.1f} Hz is low (EdgeTX runs the script every 50 ms, so about 20 Hz is normal)")
108
+ if r['radio_gap_ms']['p95'] and r['radio_gap_ms']['p95'] > 100:
109
+ out.append(f"95th percentile gap {r['radio_gap_ms']['p95']} ms: stalls in the Lua script")
110
+ if r['missing_seq']:
111
+ out.append(f"{r['missing_seq']} records missing (sequence gaps): serial data lost")
112
+ if r['resets']:
113
+ out.append(f"{r['resets']} radio session resets")
114
+ if not r['telemetry']:
115
+ out.append('no telemetry records: check sensor names in DDSTK.lua')
116
+ for name, (lo, hi) in r['stick_range'].items():
117
+ if lo == hi and name in STICKS: # switches may legitimately stay put
118
+ out.append(f'{name} never changed ({lo}): wrong source or stick not moved')
119
+ return out
120
+
121
+
122
+ def format_report(r):
123
+ def ms(v):
124
+ return '—' if v is None else f'{v:.1f} ms'
125
+ c = r['counts']
126
+ lines = [f"Log: {r['path']}",
127
+ f"Duration: {r['duration_s']:.1f} s sessions: {r['sessions']} "
128
+ f"resets: {r['resets']} disconnects: {c['disconnects']}",
129
+ f"Records: {c['records']} (control {c['samples']}, events {c['events']}, "
130
+ f"telemetry {c['telemetry']}) missing seq: {r['missing_seq']}"]
131
+ if r['sample_rate_hz']:
132
+ lines.append(f"Control rate: {r['sample_rate_hz']:.1f} Hz")
133
+ for label, key in (('Radio-clock gaps', 'radio_gap_ms'), ('Host-clock gaps ', 'host_gap_ms')):
134
+ g = r[key]
135
+ lines.append(f"{label}: median {ms(g['median'])}, p95 {ms(g['p95'])}, "
136
+ f"p99 {ms(g['p99'])}, max {ms(g['max'])}")
137
+ if c.get('gps'):
138
+ lines.append(f"GPS records: {c['gps']}")
139
+ lines.append('Channel ranges: ' + (', '.join(
140
+ f'{k} {lo}..{hi}' for k, (lo, hi) in r['stick_range'].items()) or '—'))
141
+ for name, s in sorted(r['telemetry'].items()):
142
+ rate = '—' if s['rate_hz'] is None else f"{s['rate_hz']:.1f} Hz"
143
+ lines.append(f"Telemetry {name}: {s['count']} records, {rate}, "
144
+ f"range {s['lo']:g}..{s['hi']:g}, current {s['current']}/{s['count']}")
145
+ moved = {ch: rg for ch, rg in sorted(r['output_ranges'].items()) if rg[0] != rg[1]}
146
+ if r['output_ranges']:
147
+ lines.append('Output channels that moved: ' + (', '.join(f'CH{ch} {lo}..{hi}' for ch, (lo, hi) in moved.items()) or 'none'))
148
+ for note in r['radio_notes']:
149
+ lines.append(f'Radio note: {note}')
150
+ lines.append('')
151
+ lines += [f'WARNING: {w}' for w in r['warnings']] or ['OK: no problems found']
152
+ return '\n'.join(lines)
sticklink/api.py ADDED
@@ -0,0 +1,284 @@
1
+ """REST API handlers (see openapi.py for the contract; /docs for Swagger UI)."""
2
+ import json
3
+ import re
4
+ import time
5
+
6
+ from aiohttp import web
7
+
8
+ from . import __version__
9
+ from .analysis import analyze
10
+ from .fxconfig import STYLE_INFO, validate
11
+ from .openapi import build
12
+ from .obs import ObsError
13
+ from .recorder import RecorderError
14
+ from .scenes import validate_modes, validate_obs
15
+
16
+ SENSOR = re.compile(r'[A-Za-z0-9_%-]{1,24}')
17
+ LOCAL_HOSTS = {'localhost', '127.0.0.1', '[::1]'}
18
+ STATUS_FOR = dict(already_recording=409, not_recording=409, recording_active=409, invalid_label=400,
19
+ invalid_name=400, not_found=404)
20
+
21
+
22
+ def error(status, code, message):
23
+ return web.json_response(dict(error=dict(code=code, message=message)), status=status)
24
+
25
+
26
+ @web.middleware
27
+ async def guard(request, handler):
28
+ """Reject foreign Host headers (DNS rebinding) and give API errors a JSON body."""
29
+ host = request.host.rsplit(':', 1)[0] if not request.host.endswith(']') else request.host
30
+ if host not in LOCAL_HOSTS:
31
+ return error(403, 'forbidden_host', 'this server only answers to localhost')
32
+ try:
33
+ return await handler(request)
34
+ except web.HTTPException as exc:
35
+ if request.path.startswith('/api/') and not exc.empty_body:
36
+ reason = exc.reason.lower().replace(' ', '_')
37
+ return error(exc.status, reason, exc.text if exc.text and exc.text != f'{exc.status}: {exc.reason}' else exc.reason)
38
+ raise
39
+
40
+
41
+ async def json_body(request, allow_empty=False):
42
+ # Always demand the JSON content type, even for an empty body: a cross-site HTML form cannot send it without a
43
+ # CORS pre-flight that this server never answers, so a web page you visit cannot trigger POST actions.
44
+ if request.content_type != 'application/json':
45
+ raise web.HTTPUnsupportedMediaType(text='send Content-Type: application/json')
46
+ if allow_empty and not request.can_read_body:
47
+ return {}
48
+ try:
49
+ data = await request.json()
50
+ except ValueError:
51
+ raise web.HTTPBadRequest(text='body is not valid JSON')
52
+ if not isinstance(data, dict):
53
+ raise web.HTTPBadRequest(text='body must be a JSON object')
54
+ return data
55
+
56
+
57
+ def register(app, server):
58
+ pipe, fx, rec = server.pipeline, server.fx_config, server.recorder
59
+ started = time.monotonic()
60
+ snap = pipe.snapshot
61
+ ok = web.json_response
62
+
63
+ async def status(request):
64
+ s = snap()
65
+ st = pipe.state
66
+ return ok(dict(version=__version__, uptime_s=round(time.monotonic()-started, 1), source=server.source.label,
67
+ radio=dict(status=s['status'], connected=st.connected, session=s['session'], age_ms=s['age_ms'],
68
+ error=s['error'], input=s['source'], notes=s['notes']),
69
+ diagnostics=s['diagnostics'], websocket_clients=len(server.clients), recording=rec.status()))
70
+
71
+ async def state(request):
72
+ return ok(snap())
73
+
74
+ async def controls(request):
75
+ s = snap()
76
+ return ok({k: s[k] for k in ('status', 'source', 'controls', 'raw', 'commands', 'age_ms', 'tick', 'seq')})
77
+
78
+ async def telemetry(request):
79
+ return ok(dict(sensors=snap()['telemetry']))
80
+
81
+ async def sensor(request):
82
+ name = request.match_info['sensor']
83
+ if not SENSOR.fullmatch(name):
84
+ return error(400, 'invalid_sensor', 'sensor names are 1-24 characters of A-Z a-z 0-9 _ % -')
85
+ item = snap()['telemetry'].get(name)
86
+ if item is None:
87
+ return error(404, 'not_found', f'no sensor named {name}')
88
+ return ok(dict(name=name, **item))
89
+
90
+ async def gps(request):
91
+ return ok(snap()['gps'])
92
+
93
+ def track_body():
94
+ st = pipe.state
95
+ return dict(home=None if st.home is None else dict(lat=st.home[0], lon=st.home[1]),
96
+ points=[[round(a, 6), round(b, 6)] for a, b in st.track])
97
+
98
+ async def gps_track(request):
99
+ return ok(track_body())
100
+
101
+ async def gps_track_clear(request):
102
+ pipe.state.reset_gps()
103
+ return ok(track_body())
104
+
105
+ # ------------------------------------------------------------ OBS scenes
106
+ obs, engine, scene_store = server.obs, server.scenes, server.scene_store
107
+
108
+ async def obs_status(request):
109
+ return ok(obs.snapshot())
110
+
111
+ async def obs_connection(request):
112
+ data = await json_body(request)
113
+ try:
114
+ settings = scene_store.save_obs(data)
115
+ except ValueError as exc:
116
+ return error(400, 'invalid_obs_settings', str(exc))
117
+ obs.configure(settings)
118
+ return ok(obs.snapshot())
119
+
120
+ async def obs_scene(request):
121
+ data = await json_body(request)
122
+ scene = data.get('scene')
123
+ if not isinstance(scene, str) or not scene or set(data) != {'scene'}:
124
+ return error(400, 'invalid_scene', 'send {"scene": "<name>"}')
125
+ try:
126
+ await obs.set_scene(scene)
127
+ except ObsError as exc:
128
+ if obs.status != 'connected':
129
+ return error(409, 'obs_not_connected', str(exc))
130
+ return error(400, 'scene_not_found' if exc.code == 600 else 'obs_error', str(exc))
131
+ return ok(dict(scene=scene))
132
+
133
+ async def modes(request):
134
+ return ok(engine.config)
135
+
136
+ async def modes_put(request):
137
+ data = await json_body(request)
138
+ try:
139
+ cfg = scene_store.save_modes(validate_modes(data))
140
+ except ValueError as exc:
141
+ return error(400, 'invalid_scene_modes', str(exc))
142
+ engine.configure(cfg)
143
+ return ok(cfg)
144
+
145
+ async def modes_state(request):
146
+ return ok(dict(engine.status(), obs=obs.status))
147
+
148
+ async def modes_apply(request):
149
+ await json_body(request, allow_empty=True)
150
+ if engine.blocked == 'obs_not_connected' or obs.status != 'connected':
151
+ return error(409, 'obs_not_connected', 'OBS is not connected')
152
+ try:
153
+ scene = engine.apply_now()
154
+ except ValueError as exc:
155
+ return error(409, 'nothing_to_apply', str(exc))
156
+ return ok(dict(scene=scene))
157
+
158
+ async def channels(request):
159
+ return ok(dict(channels=snap()['channels']))
160
+
161
+ async def channel(request):
162
+ try:
163
+ n = int(request.match_info['n'])
164
+ except ValueError:
165
+ n = 0
166
+ if not 1 <= n <= 16:
167
+ return error(400, 'invalid_channel', 'channel must be a number from 1 to 16')
168
+ values = snap()['channels']
169
+ return ok(dict(channel=n, value=None if values is None else values[n-1]))
170
+
171
+ async def styles(request):
172
+ return ok([dict(id=i, name=n, blurb=b) for i, n, b in STYLE_INFO])
173
+
174
+ async def settings(request):
175
+ return ok(fx.merged())
176
+
177
+ def settings_write(action):
178
+ async def handler(request):
179
+ data = await json_body(request)
180
+ try:
181
+ action(data)
182
+ except ValueError as exc:
183
+ return error(400, 'invalid_settings', str(exc))
184
+ return ok(fx.merged())
185
+ return handler
186
+
187
+ async def settings_reset(request):
188
+ fx.reset()
189
+ return ok(fx.merged())
190
+
191
+ async def mapping(request):
192
+ return ok(fx.merged()['mapping'])
193
+
194
+ async def mapping_put(request):
195
+ data = await json_body(request)
196
+ try:
197
+ fx.replace_mapping(data)
198
+ except ValueError as exc:
199
+ return error(400, 'invalid_mapping', str(exc))
200
+ return ok(fx.merged()['mapping'])
201
+
202
+ def recorder_call(fn, status_ok=200):
203
+ async def handler(request):
204
+ try:
205
+ return await fn(request, status_ok)
206
+ except RecorderError as exc:
207
+ return error(STATUS_FOR.get(exc.code, 400), exc.code, exc.message)
208
+ return handler
209
+
210
+ async def recording(request, _):
211
+ return ok(rec.status())
212
+
213
+ async def recording_start(request, code):
214
+ body = await json_body(request, allow_empty=True)
215
+ unknown = set(body) - {'label'}
216
+ if unknown:
217
+ raise RecorderError('invalid_label', 'unknown field: ' + ', '.join(sorted(unknown)))
218
+ label = body.get('label')
219
+ if label is not None and not isinstance(label, str):
220
+ raise RecorderError('invalid_label', 'label must be a string')
221
+ return ok(rec.start(label), status=code)
222
+
223
+ async def recording_stop(request, _):
224
+ return ok(rec.stop())
225
+
226
+ async def recordings(request, _):
227
+ return ok(rec.list())
228
+
229
+ async def recording_file(request, _):
230
+ path = rec.path_for(request.match_info['name'])
231
+ return web.FileResponse(path, headers={'Content-Type': 'application/x-ndjson',
232
+ 'Content-Disposition': f'attachment; filename="{path.name}"'})
233
+
234
+ async def recording_delete(request, _):
235
+ rec.delete(request.match_info['name'])
236
+ return web.Response(status=204)
237
+
238
+ async def recording_report(request, _):
239
+ report = analyze(rec.path_for(request.match_info['name']))
240
+ return web.Response(text=json.dumps(report, allow_nan=False), content_type='application/json')
241
+
242
+ spec_json = json.dumps(build())
243
+
244
+ async def openapi(request):
245
+ return web.Response(text=spec_json, content_type='application/json')
246
+
247
+ v1 = '/api/v1'
248
+ routes = [
249
+ ('GET', '/status', status), ('GET', '/state', state), ('GET', '/controls', controls),
250
+ ('GET', '/telemetry', telemetry), ('GET', '/telemetry/{sensor}', sensor),
251
+ ('GET', '/gps', gps), ('GET', '/gps/track', gps_track), ('DELETE', '/gps/track', gps_track_clear),
252
+ ('GET', '/obs', obs_status), ('PATCH', '/obs/connection', obs_connection), ('POST', '/obs/scene', obs_scene),
253
+ ('GET', '/scene-modes', modes), ('PUT', '/scene-modes', modes_put), ('GET', '/scene-modes/state', modes_state),
254
+ ('POST', '/scene-modes/apply', modes_apply),
255
+ ('GET', '/channels', channels), ('GET', '/channels/{n}', channel), ('GET', '/styles', styles),
256
+ ('GET', '/settings', settings),
257
+ ('PUT', '/settings', settings_write(fx.replace)), ('PATCH', '/settings', settings_write(fx.save)),
258
+ ('DELETE', '/settings', settings_reset),
259
+ ('GET', '/settings/mapping', mapping), ('PUT', '/settings/mapping', mapping_put),
260
+ ('GET', '/recording', recorder_call(recording)), ('POST', '/recording', recorder_call(recording_start, 201)),
261
+ ('DELETE', '/recording', recorder_call(recording_stop)),
262
+ ('GET', '/recordings', recorder_call(recordings)),
263
+ ('GET', '/recordings/{name}', recorder_call(recording_file)),
264
+ ('DELETE', '/recordings/{name}', recorder_call(recording_delete)),
265
+ ('GET', '/recordings/{name}/report', recorder_call(recording_report)),
266
+ ('GET', '/openapi.json', openapi),
267
+ ]
268
+ for method, path, handler in routes:
269
+ app.router.add_route(method, v1 + path, handler)
270
+
271
+ # Legacy aliases used by the bundled pages.
272
+ async def legacy_get(request):
273
+ return ok(fx.load(), headers={'Cache-Control': 'no-store'})
274
+
275
+ async def legacy_post(request):
276
+ data = await json_body(request)
277
+ try:
278
+ return ok(fx.save(data))
279
+ except ValueError as exc:
280
+ return error(400, 'invalid_settings', str(exc))
281
+
282
+ app.router.add_get('/api/state', state)
283
+ app.router.add_get('/api/fx-config', legacy_get)
284
+ app.router.add_post('/api/fx-config', legacy_post)
sticklink/cli.py ADDED
@@ -0,0 +1,115 @@
1
+ import argparse
2
+ from pathlib import Path
3
+ import math
4
+ import shutil
5
+ import sys
6
+
7
+ from aiohttp import web
8
+
9
+ from .analysis import analyze, format_report
10
+ from .config import Config
11
+ from .fxconfig import FxConfigStore
12
+ from .pipeline import Pipeline
13
+ from .server import OverlayServer
14
+ from .recorder import Recorder
15
+ from .scenes import SceneStore
16
+ from .sources.demo import DemoSource
17
+ from .sources.replay import ReplaySource
18
+ from .sources.serial_port import SerialSource
19
+
20
+
21
+ def parser():
22
+ p = argparse.ArgumentParser(prog='sticklink', description='Live stick and telemetry overlays for OBS, from an EdgeTX radio.')
23
+ sub = p.add_subparsers(dest='command', required=True)
24
+
25
+ def serving(sp):
26
+ sp.add_argument('--http-port', type=int, default=8765)
27
+ sp.add_argument('--stale-ms', type=int, default=500)
28
+ sp.add_argument('--scale', type=float, default=1024)
29
+ sp.add_argument('--arm-threshold', type=int, default=0)
30
+ sp.add_argument('--crash-threshold', type=int, default=0)
31
+ sp.add_argument('--input-label', choices=['sticks', 'outputs', 'unknown'],
32
+ default='unknown')
33
+ sp.add_argument('--log', help='start recording a JSONL session log to this file right away')
34
+ sp.add_argument('--recordings-dir', help='folder for recordings made through the API (default ~/.local/share/sticklink/recordings)')
35
+ sp.add_argument('--fx-config', help='overlay settings file (default ~/.config/sticklink/fx.json)')
36
+ sp.add_argument('--scenes-config', help='OBS connection and scene-mode settings, incl. the OBS password (default ~/.config/sticklink/scenes.json)')
37
+
38
+ run = sub.add_parser('run', help='serve the overlay from a radio or the demo')
39
+ run.add_argument('--port', help='COM5, /dev/ttyACM0, /dev/cu.usbmodem…')
40
+ run.add_argument('--demo', action='store_true', help='synthetic data; no radio')
41
+ run.add_argument('--baud', type=int, default=115200)
42
+ serving(run)
43
+
44
+ replay = sub.add_parser('replay', help='serve a recorded log as if it were live')
45
+ replay.add_argument('file')
46
+ replay.add_argument('--speed', type=float, default=1.0)
47
+ replay.add_argument('--loop', action='store_true')
48
+ serving(replay)
49
+
50
+ check = sub.add_parser('check-log', help='report on the quality of a recorded log')
51
+ check.add_argument('file')
52
+
53
+ script = sub.add_parser('radio-script', help='copy the EdgeTX Lua script (DDSTK.lua) to a folder, e.g. the radio SD card')
54
+ script.add_argument('dest', nargs='?', default='.',
55
+ help='folder to copy into (default: here). Use <SD card>/SCRIPTS/FUNCTIONS for the radio')
56
+
57
+ sub.add_parser('list-ports', help='list serial ports')
58
+ sub.add_parser('gui', help='a small window with Start / Stop, status and links (also what you get with no command)')
59
+ return p
60
+
61
+
62
+ def main(argv=None):
63
+ p = parser()
64
+ argv = sys.argv[1:] if argv is None else argv
65
+ if not argv:
66
+ argv = ['gui'] # double-clicked program: open the window
67
+ args = p.parse_args(argv)
68
+ if args.command == 'gui':
69
+ try:
70
+ from . import gui
71
+ except ImportError: # Python without tkinter (some Linux distributions package it separately)
72
+ p.error('the window needs tkinter (Linux: install python3-tk). The other commands work without it.')
73
+ return gui.main()
74
+ if args.command == 'list-ports':
75
+ from serial.tools.list_ports import comports
76
+ for port in comports():
77
+ print(f'{port.device}\t{port.description}')
78
+ return
79
+ if args.command == 'radio-script':
80
+ source = Path(__file__).resolve().parent/'radio'/'DDSTK.lua'
81
+ dest = Path(args.dest)
82
+ if not dest.is_dir():
83
+ p.error(f'{dest} is not a folder')
84
+ shutil.copyfile(source, dest/'DDSTK.lua')
85
+ print(f'Copied DDSTK.lua to {dest/"DDSTK.lua"}')
86
+ print('On the radio it belongs in /SCRIPTS/FUNCTIONS/ (see docs/radio-setup.md).')
87
+ return
88
+ if args.command == 'check-log':
89
+ print(format_report(analyze(args.file)))
90
+ return
91
+ if not math.isfinite(args.scale) or args.scale <= 0 or args.stale_ms <= 0:
92
+ p.error('scale and stale-ms must be positive finite values')
93
+ if not 1 <= args.http_port <= 65535:
94
+ p.error('invalid http port')
95
+ if args.command == 'run':
96
+ if bool(args.port) == args.demo:
97
+ p.error('choose exactly one of --port COM5 or --demo')
98
+ if args.baud <= 0:
99
+ p.error('invalid baud rate')
100
+ source = DemoSource() if args.demo else SerialSource(args.port, args.baud)
101
+ else:
102
+ if args.speed <= 0:
103
+ p.error('speed must be positive')
104
+ source = ReplaySource(args.file, args.speed, args.loop)
105
+ config = Config(scale=args.scale, stale_ms=args.stale_ms,
106
+ arm_threshold=args.arm_threshold, crash_threshold=args.crash_threshold,
107
+ input_label=args.input_label, demo=args.command == 'run' and args.demo)
108
+ pipeline = Pipeline(config)
109
+ recorder = Recorder(pipeline, args.recordings_dir, source=source.label)
110
+ if args.log:
111
+ recorder.start_path(args.log)
112
+ print(f'OBS browser source: http://127.0.0.1:{args.http_port}/overlay API docs: http://127.0.0.1:{args.http_port}/docs', flush=True)
113
+ web.run_app(OverlayServer(pipeline, source, fx_config=FxConfigStore(args.fx_config), recorder=recorder,
114
+ scene_store=SceneStore(args.scenes_config)).app(),
115
+ host='127.0.0.1', port=args.http_port)
sticklink/config.py ADDED
@@ -0,0 +1,11 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class Config:
6
+ scale: float = 1024 # channel value that maps to +/-1.0
7
+ stale_ms: int = 500 # no control sample for this long = paused
8
+ arm_threshold: int = 0
9
+ crash_threshold: int = 0
10
+ input_label: str = 'unknown' # sticks | outputs | unknown (when hello doesn't say)
11
+ demo: bool = False # marks snapshots as synthetic ('demo' status)