pca9685-debugging-panel 0.1.0__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.
- backend/__init__.py +0 -0
- backend/app.py +391 -0
- backend/config_store.py +234 -0
- backend/pca9685.py +234 -0
- backend/schemas.py +104 -0
- frontend/app.js +791 -0
- frontend/index.html +120 -0
- frontend/styles.css +452 -0
- main.py +50 -0
- pca9685_debugging_panel-0.1.0.dist-info/METADATA +201 -0
- pca9685_debugging_panel-0.1.0.dist-info/RECORD +14 -0
- pca9685_debugging_panel-0.1.0.dist-info/WHEEL +4 -0
- pca9685_debugging_panel-0.1.0.dist-info/entry_points.txt +2 -0
- pca9685_debugging_panel-0.1.0.dist-info/licenses/LICENSE +201 -0
backend/__init__.py
ADDED
|
File without changes
|
backend/app.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""FastAPI entry point — REST + SSE server for the PCA9685 debug panel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
13
|
+
from fastapi.responses import HTMLResponse, StreamingResponse
|
|
14
|
+
from fastapi.staticfiles import StaticFiles
|
|
15
|
+
|
|
16
|
+
from backend.config_store import store
|
|
17
|
+
from backend.pca9685 import get_driver, PCA9685Driver
|
|
18
|
+
from backend.schemas import (
|
|
19
|
+
CalibrateRequest,
|
|
20
|
+
ChannelsResponse,
|
|
21
|
+
DeviceStatus,
|
|
22
|
+
FrequencyRequest,
|
|
23
|
+
MessageResponse,
|
|
24
|
+
OutputChannelRequest,
|
|
25
|
+
OutputGlobalRequest,
|
|
26
|
+
PulseRangeRequest,
|
|
27
|
+
SetNameRequest,
|
|
28
|
+
SetServoRequest,
|
|
29
|
+
StatusResponse,
|
|
30
|
+
WorkspaceData,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# ── SSE Manager ───────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
class SSEManager:
|
|
36
|
+
"""Manages connected SSE clients and broadcasts server-pushed events."""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._queues: list[asyncio.Queue[tuple[str, str]]] = []
|
|
40
|
+
self._lock = asyncio.Lock()
|
|
41
|
+
|
|
42
|
+
async def subscribe(self) -> asyncio.Queue[tuple[str, str]]:
|
|
43
|
+
q: asyncio.Queue[tuple[str, str]] = asyncio.Queue(maxsize=64)
|
|
44
|
+
async with self._lock:
|
|
45
|
+
self._queues.append(q)
|
|
46
|
+
return q
|
|
47
|
+
|
|
48
|
+
async def unsubscribe(self, q: asyncio.Queue[tuple[str, str]]) -> None:
|
|
49
|
+
async with self._lock:
|
|
50
|
+
if q in self._queues:
|
|
51
|
+
self._queues.remove(q)
|
|
52
|
+
|
|
53
|
+
async def broadcast(self, event: str, data: str) -> None:
|
|
54
|
+
async with self._lock:
|
|
55
|
+
queues = list(self._queues)
|
|
56
|
+
for q in queues:
|
|
57
|
+
try:
|
|
58
|
+
q.put_nowait((event, data))
|
|
59
|
+
except asyncio.QueueFull:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
sse = SSEManager()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── Heartbeat background task ────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
async def heartbeat_loop(driver: PCA9685Driver) -> None:
|
|
69
|
+
"""Periodically check device health and broadcast status changes."""
|
|
70
|
+
previous_online: Optional[bool] = None
|
|
71
|
+
while True:
|
|
72
|
+
await asyncio.sleep(2)
|
|
73
|
+
is_online = driver.check_heartbeat()
|
|
74
|
+
await sse.broadcast(
|
|
75
|
+
"status",
|
|
76
|
+
json.dumps({
|
|
77
|
+
"status": "online" if is_online else "offline",
|
|
78
|
+
"last_heartbeat": _iso_now() if is_online else None,
|
|
79
|
+
}),
|
|
80
|
+
)
|
|
81
|
+
previous_online = is_online
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ── Helpers ──────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
def _iso_now() -> str:
|
|
87
|
+
return datetime.now(timezone.utc).isoformat()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _get_status(driver: PCA9685Driver) -> StatusResponse:
|
|
91
|
+
if driver.online:
|
|
92
|
+
status = DeviceStatus.ONLINE
|
|
93
|
+
else:
|
|
94
|
+
status = DeviceStatus.OFFLINE
|
|
95
|
+
return StatusResponse(
|
|
96
|
+
status=status,
|
|
97
|
+
i2c_address=store.i2c_address,
|
|
98
|
+
frequency_hz=store.frequency_hz,
|
|
99
|
+
min_pulse_us=store.min_pulse_us,
|
|
100
|
+
max_pulse_us=store.max_pulse_us,
|
|
101
|
+
output_enabled=store.output_enabled,
|
|
102
|
+
last_heartbeat=_iso_now() if driver.last_heartbeat > 0 else None,
|
|
103
|
+
last_error=driver.last_error,
|
|
104
|
+
mock_mode=driver.mock_mode,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _check_driver(driver: PCA9685Driver) -> None:
|
|
109
|
+
if not driver.online and not driver.mock_mode:
|
|
110
|
+
raise HTTPException(status_code=503, detail="PCA9685 device offline")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ── Lifespan ─────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
@asynccontextmanager
|
|
116
|
+
async def lifespan(app: FastAPI):
|
|
117
|
+
# Startup
|
|
118
|
+
driver = get_driver(store.i2c_address)
|
|
119
|
+
app.state.driver = driver
|
|
120
|
+
hb_task = asyncio.create_task(heartbeat_loop(driver))
|
|
121
|
+
app.state.hb_task = hb_task
|
|
122
|
+
# Apply persisted config to hardware
|
|
123
|
+
_apply_config(driver)
|
|
124
|
+
yield
|
|
125
|
+
# Shutdown
|
|
126
|
+
hb_task.cancel()
|
|
127
|
+
try:
|
|
128
|
+
await hb_task
|
|
129
|
+
except asyncio.CancelledError:
|
|
130
|
+
pass
|
|
131
|
+
driver.close()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _apply_config(driver: PCA9685Driver) -> None:
|
|
135
|
+
"""Push persisted config to the hardware. Channels are only restored
|
|
136
|
+
when the global output-enable flag is True."""
|
|
137
|
+
try:
|
|
138
|
+
driver.set_frequency(store.frequency_hz)
|
|
139
|
+
if store.output_enabled:
|
|
140
|
+
_restore_all_channels(driver)
|
|
141
|
+
else:
|
|
142
|
+
driver.all_off()
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
print(f"[startup] Failed to apply config to hardware: {exc}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _restore_all_channels(driver: PCA9685Driver) -> None:
|
|
148
|
+
"""Write persisted outputs to all enabled channels."""
|
|
149
|
+
for ch in range(16):
|
|
150
|
+
_restore_channel(driver, ch)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _restore_channel(driver: PCA9685Driver, channel: int) -> None:
|
|
154
|
+
"""Restore a single channel's output if it is enabled, else kill it."""
|
|
155
|
+
if not store.get_channel_enabled(channel):
|
|
156
|
+
driver.set_channel_duty(channel, 0)
|
|
157
|
+
return
|
|
158
|
+
raw = store.get_channel_raw(channel)
|
|
159
|
+
if raw.get("angle") is not None:
|
|
160
|
+
calib = raw.get("calibration", {})
|
|
161
|
+
if calib:
|
|
162
|
+
driver.set_channel_angle(
|
|
163
|
+
channel, raw["angle"],
|
|
164
|
+
calib["min_angle"], calib["max_angle"],
|
|
165
|
+
calib["min_pulse"], calib["max_pulse"],
|
|
166
|
+
)
|
|
167
|
+
elif raw.get("duty") is not None:
|
|
168
|
+
driver.set_channel_duty(channel, raw["duty"])
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ── App ──────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
174
|
+
FRONTEND_DIR = BASE_DIR / "frontend"
|
|
175
|
+
if not FRONTEND_DIR.is_dir():
|
|
176
|
+
# Fallback: maybe frontend is alongside the backend package (alternative install layout)
|
|
177
|
+
FRONTEND_DIR = Path(__file__).resolve().parent / "frontend"
|
|
178
|
+
|
|
179
|
+
app = FastAPI(title="PCA9685 Debug Panel", lifespan=lifespan)
|
|
180
|
+
|
|
181
|
+
# Static files (CSS, JS) — served before the catch-all
|
|
182
|
+
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ── REST endpoints ───────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
@app.get("/api/status", response_model=StatusResponse)
|
|
188
|
+
async def api_status(request: Request) -> StatusResponse:
|
|
189
|
+
driver: PCA9685Driver = request.app.state.driver
|
|
190
|
+
return _get_status(driver)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@app.get("/api/servo/channels", response_model=ChannelsResponse)
|
|
194
|
+
async def api_channels(request: Request) -> ChannelsResponse:
|
|
195
|
+
"""Return state for all 16 channels (config + driver state)."""
|
|
196
|
+
channels = [store.get_channel(i) for i in range(16)]
|
|
197
|
+
return ChannelsResponse(channels=channels)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.post("/api/servo/set", response_model=MessageResponse)
|
|
201
|
+
async def api_servo_set(body: SetServoRequest, request: Request) -> MessageResponse:
|
|
202
|
+
"""Set a channel output by angle (if calibrated) or duty (0–1).
|
|
203
|
+
|
|
204
|
+
Values are always persisted. The hardware is only updated when both
|
|
205
|
+
the global output-enable flag and the per-channel enable flag are on.
|
|
206
|
+
"""
|
|
207
|
+
driver: PCA9685Driver = request.app.state.driver
|
|
208
|
+
_check_driver(driver)
|
|
209
|
+
|
|
210
|
+
can_write = store.output_enabled and store.get_channel_enabled(body.channel)
|
|
211
|
+
|
|
212
|
+
if body.angle is not None:
|
|
213
|
+
calib = store.get_channel_raw(body.channel).get("calibration")
|
|
214
|
+
if not calib:
|
|
215
|
+
raise HTTPException(
|
|
216
|
+
status_code=400,
|
|
217
|
+
detail=f"Channel {body.channel} not calibrated. Use 'duty' instead.",
|
|
218
|
+
)
|
|
219
|
+
store.set_channel_output(body.channel, angle=body.angle)
|
|
220
|
+
store.save()
|
|
221
|
+
if can_write:
|
|
222
|
+
driver.set_channel_angle(
|
|
223
|
+
body.channel, body.angle,
|
|
224
|
+
calib["min_angle"], calib["max_angle"],
|
|
225
|
+
calib["min_pulse"], calib["max_pulse"],
|
|
226
|
+
)
|
|
227
|
+
return MessageResponse(
|
|
228
|
+
detail=f"Channel {body.channel} set to {body.angle}°"
|
|
229
|
+
)
|
|
230
|
+
elif body.duty is not None:
|
|
231
|
+
store.set_channel_output(body.channel, duty=body.duty)
|
|
232
|
+
store.save()
|
|
233
|
+
if can_write:
|
|
234
|
+
driver.set_channel_duty(body.channel, body.duty)
|
|
235
|
+
return MessageResponse(
|
|
236
|
+
detail=f"Channel {body.channel} set to duty {body.duty:.3f}"
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
raise HTTPException(
|
|
240
|
+
status_code=400,
|
|
241
|
+
detail="Either 'angle' or 'duty' must be provided.",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@app.post("/api/servo/name", response_model=MessageResponse)
|
|
246
|
+
async def api_servo_name(body: SetNameRequest) -> MessageResponse:
|
|
247
|
+
store.set_channel_name(body.channel, body.name)
|
|
248
|
+
store.save()
|
|
249
|
+
return MessageResponse(
|
|
250
|
+
detail=f"Channel {body.channel} renamed to '{body.name}'"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@app.post("/api/servo/calibrate", response_model=MessageResponse)
|
|
255
|
+
async def api_servo_calibrate(body: CalibrateRequest, request: Request) -> MessageResponse:
|
|
256
|
+
driver: PCA9685Driver = request.app.state.driver
|
|
257
|
+
_check_driver(driver)
|
|
258
|
+
|
|
259
|
+
if body.min_angle >= body.max_angle:
|
|
260
|
+
raise HTTPException(status_code=400, detail="min_angle must be < max_angle")
|
|
261
|
+
if body.min_pulse >= body.max_pulse:
|
|
262
|
+
raise HTTPException(status_code=400, detail="min_pulse must be < max_pulse")
|
|
263
|
+
|
|
264
|
+
store.set_channel_calibration(
|
|
265
|
+
body.channel,
|
|
266
|
+
min_angle=body.min_angle,
|
|
267
|
+
max_angle=body.max_angle,
|
|
268
|
+
min_pulse=body.min_pulse,
|
|
269
|
+
max_pulse=body.max_pulse,
|
|
270
|
+
)
|
|
271
|
+
store.save()
|
|
272
|
+
return MessageResponse(
|
|
273
|
+
detail=f"Channel {body.channel} calibrated: "
|
|
274
|
+
f"{body.min_angle}°→{body.min_pulse}µs, "
|
|
275
|
+
f"{body.max_angle}°→{body.max_pulse}µs"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@app.post("/api/pca9685/frequency", response_model=MessageResponse)
|
|
280
|
+
async def api_set_frequency(body: FrequencyRequest, request: Request) -> MessageResponse:
|
|
281
|
+
driver: PCA9685Driver = request.app.state.driver
|
|
282
|
+
_check_driver(driver)
|
|
283
|
+
driver.set_frequency(body.frequency_hz)
|
|
284
|
+
store.frequency_hz = body.frequency_hz
|
|
285
|
+
store.save()
|
|
286
|
+
return MessageResponse(detail=f"Frequency set to {body.frequency_hz} Hz")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@app.post("/api/pca9685/pulse_range", response_model=MessageResponse)
|
|
290
|
+
async def api_set_pulse_range(body: PulseRangeRequest, request: Request) -> MessageResponse:
|
|
291
|
+
if body.min_pulse_us >= body.max_pulse_us:
|
|
292
|
+
raise HTTPException(status_code=400, detail="min_pulse_us must be < max_pulse_us")
|
|
293
|
+
store.min_pulse_us = body.min_pulse_us
|
|
294
|
+
store.max_pulse_us = body.max_pulse_us
|
|
295
|
+
store.save()
|
|
296
|
+
return MessageResponse(
|
|
297
|
+
detail=f"Pulse range set to {body.min_pulse_us}–{body.max_pulse_us} µs"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@app.post("/api/output/global", response_model=MessageResponse)
|
|
302
|
+
async def api_output_global(body: OutputGlobalRequest, request: Request) -> MessageResponse:
|
|
303
|
+
"""Master switch: enable or kill all channel outputs."""
|
|
304
|
+
driver: PCA9685Driver = request.app.state.driver
|
|
305
|
+
_check_driver(driver)
|
|
306
|
+
store.output_enabled = body.enabled
|
|
307
|
+
store.save()
|
|
308
|
+
if body.enabled:
|
|
309
|
+
_restore_all_channels(driver)
|
|
310
|
+
else:
|
|
311
|
+
driver.all_off()
|
|
312
|
+
return MessageResponse(
|
|
313
|
+
detail=f"Output {'enabled' if body.enabled else 'disabled'}"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@app.post("/api/output/channel", response_model=MessageResponse)
|
|
318
|
+
async def api_output_channel(body: OutputChannelRequest, request: Request) -> MessageResponse:
|
|
319
|
+
"""Enable or disable a single channel."""
|
|
320
|
+
driver: PCA9685Driver = request.app.state.driver
|
|
321
|
+
_check_driver(driver)
|
|
322
|
+
store.set_channel_enabled(body.channel, body.enabled)
|
|
323
|
+
store.save()
|
|
324
|
+
if store.output_enabled:
|
|
325
|
+
_restore_channel(driver, body.channel)
|
|
326
|
+
else:
|
|
327
|
+
driver.set_channel_duty(body.channel, 0)
|
|
328
|
+
return MessageResponse(
|
|
329
|
+
detail=f"Channel {body.channel} {'enabled' if body.enabled else 'disabled'}"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@app.get("/api/workspace/export", response_model=WorkspaceData)
|
|
334
|
+
async def api_workspace_export() -> WorkspaceData:
|
|
335
|
+
"""Return the full configuration snapshot for client download."""
|
|
336
|
+
return store.export_workspace()
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@app.post("/api/workspace/import", response_model=MessageResponse)
|
|
340
|
+
async def api_workspace_import(body: WorkspaceData, request: Request) -> MessageResponse:
|
|
341
|
+
"""Apply a workspace snapshot uploaded from the client."""
|
|
342
|
+
driver: PCA9685Driver = request.app.state.driver
|
|
343
|
+
store.import_workspace(body)
|
|
344
|
+
_apply_config(driver)
|
|
345
|
+
return MessageResponse(detail="Workspace imported and applied")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# ── SSE endpoint ─────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
@app.get("/api/events")
|
|
351
|
+
async def api_events(request: Request) -> StreamingResponse:
|
|
352
|
+
"""Server-Sent Events stream for device status pushes."""
|
|
353
|
+
|
|
354
|
+
async def event_generator():
|
|
355
|
+
q = await sse.subscribe()
|
|
356
|
+
try:
|
|
357
|
+
# Send initial status
|
|
358
|
+
driver: PCA9685Driver = request.app.state.driver
|
|
359
|
+
status = _get_status(driver)
|
|
360
|
+
yield f"event: status\ndata: {status.model_dump_json()}\n\n"
|
|
361
|
+
while True:
|
|
362
|
+
if await request.is_disconnected():
|
|
363
|
+
break
|
|
364
|
+
try:
|
|
365
|
+
event, data = await asyncio.wait_for(q.get(), timeout=15)
|
|
366
|
+
yield f"event: {event}\ndata: {data}\n\n"
|
|
367
|
+
except asyncio.TimeoutError:
|
|
368
|
+
# Send keepalive comment
|
|
369
|
+
yield ": keepalive\n\n"
|
|
370
|
+
finally:
|
|
371
|
+
await sse.unsubscribe(q)
|
|
372
|
+
|
|
373
|
+
return StreamingResponse(
|
|
374
|
+
event_generator(),
|
|
375
|
+
media_type="text/event-stream",
|
|
376
|
+
headers={
|
|
377
|
+
"Cache-Control": "no-cache",
|
|
378
|
+
"Connection": "keep-alive",
|
|
379
|
+
"X-Accel-Buffering": "no",
|
|
380
|
+
},
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ── Root — serve index.html ──────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
@app.get("/")
|
|
387
|
+
async def root() -> HTMLResponse:
|
|
388
|
+
index_path = FRONTEND_DIR / "index.html"
|
|
389
|
+
if index_path.exists():
|
|
390
|
+
return HTMLResponse(content=index_path.read_text(encoding="utf-8"))
|
|
391
|
+
return HTMLResponse(content="<h1>Frontend not found</h1>", status_code=404)
|
backend/config_store.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Server-side configuration persistence.
|
|
2
|
+
|
|
3
|
+
config.json stores the current running configuration so that settings
|
|
4
|
+
survive a restart. Workspace export/import produces a full snapshot
|
|
5
|
+
that the user can save to / load from the client machine.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import threading
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from backend.schemas import ChannelState, WorkspaceData
|
|
16
|
+
|
|
17
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
18
|
+
"i2c_address": 0x40,
|
|
19
|
+
"frequency_hz": 50.0,
|
|
20
|
+
"min_pulse_us": 600.0,
|
|
21
|
+
"max_pulse_us": 2400.0,
|
|
22
|
+
"output_enabled": False,
|
|
23
|
+
"channels": {},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfigStore:
|
|
28
|
+
"""Thread-safe JSON file store for PCA9685 configuration."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, path: Path) -> None:
|
|
31
|
+
self._path = path
|
|
32
|
+
self._lock = threading.Lock()
|
|
33
|
+
self._data: dict[str, Any] = {**DEFAULT_CONFIG, "channels": {}}
|
|
34
|
+
self.load()
|
|
35
|
+
|
|
36
|
+
# ── low-level helpers ──────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
def _default_channels(self) -> dict[str, Any]:
|
|
39
|
+
"""Return an empty per-channel dict with defaults."""
|
|
40
|
+
return {}
|
|
41
|
+
|
|
42
|
+
def load(self) -> None:
|
|
43
|
+
"""Read config.json, merging missing keys from defaults."""
|
|
44
|
+
if not self._path.exists():
|
|
45
|
+
self.save()
|
|
46
|
+
return
|
|
47
|
+
try:
|
|
48
|
+
with open(self._path) as f:
|
|
49
|
+
loaded = json.load(f)
|
|
50
|
+
except (json.JSONDecodeError, OSError):
|
|
51
|
+
loaded = {}
|
|
52
|
+
with self._lock:
|
|
53
|
+
self._data = {**DEFAULT_CONFIG, **loaded}
|
|
54
|
+
# Ensure channels sub-dict exists
|
|
55
|
+
if not isinstance(self._data.get("channels"), dict):
|
|
56
|
+
self._data["channels"] = {}
|
|
57
|
+
# Convert channel keys to int (JSON only allows string keys)
|
|
58
|
+
self._data["channels"] = {
|
|
59
|
+
int(k): v for k, v in self._data["channels"].items()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
def save(self) -> None:
|
|
63
|
+
"""Persist current config to disk."""
|
|
64
|
+
with self._lock:
|
|
65
|
+
data = dict(self._data)
|
|
66
|
+
with open(self._path, "w", encoding="utf-8") as f:
|
|
67
|
+
json.dump(data, f, indent=2, ensure_ascii=False, default=str)
|
|
68
|
+
|
|
69
|
+
# ── global settings ────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def i2c_address(self) -> int:
|
|
73
|
+
with self._lock:
|
|
74
|
+
return self._data["i2c_address"]
|
|
75
|
+
|
|
76
|
+
@i2c_address.setter
|
|
77
|
+
def i2c_address(self, value: int) -> None:
|
|
78
|
+
with self._lock:
|
|
79
|
+
self._data["i2c_address"] = value
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def frequency_hz(self) -> float:
|
|
83
|
+
with self._lock:
|
|
84
|
+
return self._data["frequency_hz"]
|
|
85
|
+
|
|
86
|
+
@frequency_hz.setter
|
|
87
|
+
def frequency_hz(self, value: float) -> None:
|
|
88
|
+
with self._lock:
|
|
89
|
+
self._data["frequency_hz"] = value
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def min_pulse_us(self) -> float:
|
|
93
|
+
with self._lock:
|
|
94
|
+
return self._data["min_pulse_us"]
|
|
95
|
+
|
|
96
|
+
@min_pulse_us.setter
|
|
97
|
+
def min_pulse_us(self, value: float) -> None:
|
|
98
|
+
with self._lock:
|
|
99
|
+
self._data["min_pulse_us"] = value
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def max_pulse_us(self) -> float:
|
|
103
|
+
with self._lock:
|
|
104
|
+
return self._data["max_pulse_us"]
|
|
105
|
+
|
|
106
|
+
@max_pulse_us.setter
|
|
107
|
+
def max_pulse_us(self, value: float) -> None:
|
|
108
|
+
with self._lock:
|
|
109
|
+
self._data["max_pulse_us"] = value
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def output_enabled(self) -> bool:
|
|
113
|
+
with self._lock:
|
|
114
|
+
return self._data.get("output_enabled", False)
|
|
115
|
+
|
|
116
|
+
@output_enabled.setter
|
|
117
|
+
def output_enabled(self, value: bool) -> None:
|
|
118
|
+
with self._lock:
|
|
119
|
+
self._data["output_enabled"] = value
|
|
120
|
+
|
|
121
|
+
# ── per-channel helpers ────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
def _ensure_channel(self, channel: int) -> dict[str, Any]:
|
|
124
|
+
key = str(channel)
|
|
125
|
+
with self._lock:
|
|
126
|
+
if key not in self._data["channels"]:
|
|
127
|
+
self._data["channels"][key] = {}
|
|
128
|
+
return dict(self._data["channels"][key])
|
|
129
|
+
|
|
130
|
+
def get_channel_raw(self, channel: int) -> dict[str, Any]:
|
|
131
|
+
"""Return the raw dict for a channel (safe for external reads)."""
|
|
132
|
+
return self._ensure_channel(channel)
|
|
133
|
+
|
|
134
|
+
def set_channel_field(self, channel: int, field: str, value: Any) -> None:
|
|
135
|
+
with self._lock:
|
|
136
|
+
key = str(channel)
|
|
137
|
+
if key not in self._data["channels"]:
|
|
138
|
+
self._data["channels"][key] = {}
|
|
139
|
+
self._data["channels"][key][field] = value
|
|
140
|
+
|
|
141
|
+
def get_channel_enabled(self, channel: int) -> bool:
|
|
142
|
+
raw = self.get_channel_raw(channel)
|
|
143
|
+
return raw.get("enabled", False)
|
|
144
|
+
|
|
145
|
+
def set_channel_enabled(self, channel: int, enabled: bool) -> None:
|
|
146
|
+
self.set_channel_field(channel, "enabled", enabled)
|
|
147
|
+
|
|
148
|
+
def get_channel(self, channel: int) -> ChannelState:
|
|
149
|
+
"""Build a ChannelState from stored config + runtime state."""
|
|
150
|
+
raw = self.get_channel_raw(channel)
|
|
151
|
+
calib = raw.get("calibration", {})
|
|
152
|
+
has_calib = bool(calib.get("min_angle") is not None
|
|
153
|
+
and calib.get("max_angle") is not None)
|
|
154
|
+
return ChannelState(
|
|
155
|
+
channel=channel,
|
|
156
|
+
name=raw.get("name", f"Channel {channel}"),
|
|
157
|
+
enabled=raw.get("enabled", False),
|
|
158
|
+
angle=raw.get("angle"),
|
|
159
|
+
duty=raw.get("duty"),
|
|
160
|
+
min_angle=calib.get("min_angle"),
|
|
161
|
+
max_angle=calib.get("max_angle"),
|
|
162
|
+
min_pulse=calib.get("min_pulse"),
|
|
163
|
+
max_pulse=calib.get("max_pulse"),
|
|
164
|
+
calibrated=has_calib,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def set_channel_output(self, channel: int, *,
|
|
168
|
+
angle: Optional[float] = None,
|
|
169
|
+
duty: Optional[float] = None) -> None:
|
|
170
|
+
"""Record the current output state of a channel."""
|
|
171
|
+
if angle is not None:
|
|
172
|
+
self.set_channel_field(channel, "angle", angle)
|
|
173
|
+
self.set_channel_field(channel, "duty", None)
|
|
174
|
+
elif duty is not None:
|
|
175
|
+
self.set_channel_field(channel, "duty", duty)
|
|
176
|
+
self.set_channel_field(channel, "angle", None)
|
|
177
|
+
|
|
178
|
+
def set_channel_name(self, channel: int, name: str) -> None:
|
|
179
|
+
self.set_channel_field(channel, "name", name)
|
|
180
|
+
|
|
181
|
+
def set_channel_calibration(self, channel: int, *,
|
|
182
|
+
min_angle: float, max_angle: float,
|
|
183
|
+
min_pulse: float, max_pulse: float) -> None:
|
|
184
|
+
self.set_channel_field(channel, "calibration", {
|
|
185
|
+
"min_angle": min_angle,
|
|
186
|
+
"max_angle": max_angle,
|
|
187
|
+
"min_pulse": min_pulse,
|
|
188
|
+
"max_pulse": max_pulse,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
# ── workspace ──────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
def export_workspace(self) -> WorkspaceData:
|
|
194
|
+
"""Build a full snapshot suitable for client-side download."""
|
|
195
|
+
channels = [self.get_channel(i) for i in range(16)]
|
|
196
|
+
return WorkspaceData(
|
|
197
|
+
i2c_address=self.i2c_address,
|
|
198
|
+
frequency_hz=self.frequency_hz,
|
|
199
|
+
min_pulse_us=self.min_pulse_us,
|
|
200
|
+
max_pulse_us=self.max_pulse_us,
|
|
201
|
+
channels=channels,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def import_workspace(self, ws: WorkspaceData) -> None:
|
|
205
|
+
"""Apply a full workspace snapshot and persist."""
|
|
206
|
+
with self._lock:
|
|
207
|
+
self._data["i2c_address"] = ws.i2c_address
|
|
208
|
+
self._data["frequency_hz"] = ws.frequency_hz
|
|
209
|
+
self._data["min_pulse_us"] = ws.min_pulse_us
|
|
210
|
+
self._data["max_pulse_us"] = ws.max_pulse_us
|
|
211
|
+
self._data["channels"] = {}
|
|
212
|
+
for ch in ws.channels:
|
|
213
|
+
cdata: dict[str, Any] = {
|
|
214
|
+
"name": ch.name,
|
|
215
|
+
"angle": ch.angle,
|
|
216
|
+
"duty": ch.duty,
|
|
217
|
+
}
|
|
218
|
+
if ch.calibrated:
|
|
219
|
+
cdata["calibration"] = {
|
|
220
|
+
"min_angle": ch.min_angle,
|
|
221
|
+
"max_angle": ch.max_angle,
|
|
222
|
+
"min_pulse": ch.min_pulse,
|
|
223
|
+
"max_pulse": ch.max_pulse,
|
|
224
|
+
}
|
|
225
|
+
# Only store if there's meaningful data
|
|
226
|
+
if any(v is not None for v in cdata.values()):
|
|
227
|
+
self._data["channels"][str(ch.channel)] = cdata
|
|
228
|
+
self.save()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ── Default instance ──────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
_config_path = Path(__file__).resolve().parent.parent / "config.json"
|
|
234
|
+
store = ConfigStore(_config_path)
|