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 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)
@@ -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)