embedded-lab-control 0.2.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.
Files changed (64) hide show
  1. embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
  2. embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
  3. embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
  4. embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
  5. embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
  7. labctl/__init__.py +8 -0
  8. labctl/actuators/__init__.py +56 -0
  9. labctl/actuators/base.py +183 -0
  10. labctl/actuators/lcus1.py +232 -0
  11. labctl/actuators/mock.py +81 -0
  12. labctl/actuators/runtime.py +1178 -0
  13. labctl/cli.py +7485 -0
  14. labctl/core/__init__.py +36 -0
  15. labctl/core/admin_files.py +388 -0
  16. labctl/core/audit.py +269 -0
  17. labctl/core/claim_keepalive.py +342 -0
  18. labctl/core/config.py +1068 -0
  19. labctl/core/database.py +880 -0
  20. labctl/core/images.py +503 -0
  21. labctl/core/manager.py +2815 -0
  22. labctl/core/models.py +815 -0
  23. labctl/core/safe_open.py +109 -0
  24. labctl/extras.py +128 -0
  25. labctl/health/__init__.py +43 -0
  26. labctl/health/alerts.py +275 -0
  27. labctl/health/checks.py +533 -0
  28. labctl/health/daemon.py +397 -0
  29. labctl/mcp_server.py +5254 -0
  30. labctl/power/__init__.py +13 -0
  31. labctl/power/base.py +145 -0
  32. labctl/power/kasa.py +446 -0
  33. labctl/power/shelly.py +89 -0
  34. labctl/power/tasmota.py +95 -0
  35. labctl/sdwire/__init__.py +10 -0
  36. labctl/sdwire/controller.py +1116 -0
  37. labctl/sdwire/power_guard.py +190 -0
  38. labctl/serial/__init__.py +18 -0
  39. labctl/serial/boot_test.py +342 -0
  40. labctl/serial/capture.py +430 -0
  41. labctl/serial/keys.py +89 -0
  42. labctl/serial/proxy.py +802 -0
  43. labctl/serial/ser2net.py +338 -0
  44. labctl/serial/udev.py +273 -0
  45. labctl/services.py +210 -0
  46. labctl/web/__init__.py +9 -0
  47. labctl/web/activity_broadcaster.py +204 -0
  48. labctl/web/api.py +1003 -0
  49. labctl/web/app.py +202 -0
  50. labctl/web/auth.py +97 -0
  51. labctl/web/claims.py +232 -0
  52. labctl/web/static/css/style.css +477 -0
  53. labctl/web/static/js/app.js +76 -0
  54. labctl/web/templates/activity.html +202 -0
  55. labctl/web/templates/base.html +49 -0
  56. labctl/web/templates/claim_refused.html +38 -0
  57. labctl/web/templates/console.html +237 -0
  58. labctl/web/templates/dashboard.html +100 -0
  59. labctl/web/templates/login.html +25 -0
  60. labctl/web/templates/sbc_detail.html +306 -0
  61. labctl/web/templates/settings.html +78 -0
  62. labctl/web/templates/status_history.html +65 -0
  63. labctl/web/views.py +496 -0
  64. labctl/web/websocket.py +208 -0
@@ -0,0 +1,880 @@
1
+ """
2
+ Database management for lab controller.
3
+
4
+ Provides SQLite connection management, schema initialization, and migrations.
5
+
6
+ Concurrency notes
7
+ =================
8
+ labctl is multi-process by design (CLI, monitor daemon, MCP server, web app,
9
+ batch jobs all share one SQLite file). To keep concurrent readers and
10
+ writers from blocking each other:
11
+
12
+ * **WAL journal mode** is applied on first ``initialize()``. WAL is
13
+ persisted in the database file header, so any process opening the
14
+ file inherits it. With WAL, readers never block writers and vice
15
+ versa — only writer-vs-writer is serialized, and labctl writes are
16
+ all single-statement (sub-ms) or short transactions.
17
+ * **Per-connection busy_timeout** (default 10s, config-driven via
18
+ ``database.timeout_seconds``) backstops the narrow windows where
19
+ WAL still serializes — schema migrations and WAL checkpoints.
20
+ * **``synchronous=NORMAL``** trades a few-second worst-case write loss
21
+ on power failure for substantially faster commits. Safe with WAL.
22
+ * **``initialize()`` is cached per-process** so short-lived CLI
23
+ invocations don't repeatedly re-probe ``sqlite_master``.
24
+ """
25
+
26
+ import logging
27
+ import sqlite3
28
+ import threading
29
+ from contextlib import contextmanager
30
+ from pathlib import Path
31
+ from typing import Generator, Optional
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # Current schema version
36
+ SCHEMA_VERSION = 12
37
+
38
+ # Default SQLite busy-timeout when the Database is constructed without an
39
+ # explicit value (e.g. tests, direct callers). Matches Config.database
40
+ # default; raise only if a migration on a contended DB is timing out.
41
+ DEFAULT_TIMEOUT_SECONDS = 10.0
42
+
43
+ # SQL statements for schema creation
44
+ SCHEMA_SQL = """
45
+ -- Schema version tracking
46
+ CREATE TABLE IF NOT EXISTS schema_version (
47
+ version INTEGER PRIMARY KEY,
48
+ applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
49
+ );
50
+
51
+ -- SBC (Single Board Computer) records
52
+ CREATE TABLE IF NOT EXISTS sbcs (
53
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
54
+ name TEXT UNIQUE NOT NULL,
55
+ project TEXT,
56
+ description TEXT,
57
+ ssh_user TEXT DEFAULT 'root',
58
+ status TEXT DEFAULT 'unknown',
59
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
60
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
61
+ -- Cached power observation written by the monitor daemon every cycle.
62
+ -- Read by `labctl status --fast` to avoid live network probes.
63
+ last_power_state TEXT, -- on | off | unknown
64
+ last_power_at TIMESTAMP, -- when the daemon last observed power
65
+ -- Per-SBC power-cycle off→on delay. Used as both the default when
66
+ -- --delay is omitted and the floor when --delay is passed (the CLI
67
+ -- raises smaller values to this with a warning).
68
+ power_cycle_delay_seconds REAL
69
+ );
70
+
71
+ -- Registered USB-serial adapters
72
+ CREATE TABLE IF NOT EXISTS serial_devices (
73
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
74
+ name TEXT UNIQUE NOT NULL,
75
+ usb_path TEXT UNIQUE NOT NULL,
76
+ vendor TEXT,
77
+ model TEXT,
78
+ serial_number TEXT,
79
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
80
+ );
81
+
82
+ -- Serial port assignments
83
+ CREATE TABLE IF NOT EXISTS serial_ports (
84
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
85
+ sbc_id INTEGER NOT NULL,
86
+ port_type TEXT NOT NULL, -- console, jtag, debug
87
+ device_path TEXT NOT NULL, -- /dev/lab/sbc1-console
88
+ tcp_port INTEGER,
89
+ baud_rate INTEGER DEFAULT 115200,
90
+ alias TEXT, -- human-friendly name for this assignment
91
+ serial_device_id INTEGER, -- FK to serial_devices
92
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
93
+ FOREIGN KEY (sbc_id) REFERENCES sbcs(id) ON DELETE CASCADE,
94
+ FOREIGN KEY (serial_device_id) REFERENCES serial_devices(id) ON DELETE SET NULL,
95
+ UNIQUE (sbc_id, port_type)
96
+ );
97
+
98
+ -- Network addresses
99
+ CREATE TABLE IF NOT EXISTS network_addresses (
100
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
101
+ sbc_id INTEGER NOT NULL,
102
+ address_type TEXT NOT NULL, -- ethernet, wifi
103
+ ip_address TEXT NOT NULL,
104
+ mac_address TEXT,
105
+ hostname TEXT,
106
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
107
+ FOREIGN KEY (sbc_id) REFERENCES sbcs(id) ON DELETE CASCADE,
108
+ UNIQUE (sbc_id, address_type)
109
+ );
110
+
111
+ -- Power plug assignments
112
+ CREATE TABLE IF NOT EXISTS power_plugs (
113
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
114
+ sbc_id INTEGER UNIQUE NOT NULL,
115
+ plug_type TEXT NOT NULL, -- tasmota, kasa, shelly
116
+ address TEXT NOT NULL, -- IP or hostname
117
+ plug_index INTEGER DEFAULT 1, -- For multi-outlet strips
118
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
119
+ FOREIGN KEY (sbc_id) REFERENCES sbcs(id) ON DELETE CASCADE
120
+ );
121
+
122
+ -- SDWire (SD card multiplexer) devices
123
+ CREATE TABLE IF NOT EXISTS sdwire_devices (
124
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
125
+ name TEXT UNIQUE NOT NULL,
126
+ serial_number TEXT UNIQUE NOT NULL,
127
+ device_type TEXT NOT NULL DEFAULT 'sdwirec', -- sdwire, sdwirec, sdwire3
128
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
129
+ );
130
+
131
+ -- SDWire assignments to SBCs
132
+ CREATE TABLE IF NOT EXISTS sdwire_assignments (
133
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
134
+ sbc_id INTEGER UNIQUE NOT NULL,
135
+ sdwire_device_id INTEGER NOT NULL,
136
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
137
+ FOREIGN KEY (sbc_id) REFERENCES sbcs(id) ON DELETE CASCADE,
138
+ FOREIGN KEY (sdwire_device_id) REFERENCES sdwire_devices(id) ON DELETE CASCADE
139
+ );
140
+
141
+ -- Status history log
142
+ CREATE TABLE IF NOT EXISTS status_log (
143
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
144
+ sbc_id INTEGER NOT NULL,
145
+ status TEXT NOT NULL,
146
+ details TEXT,
147
+ logged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
148
+ FOREIGN KEY (sbc_id) REFERENCES sbcs(id) ON DELETE CASCADE
149
+ );
150
+
151
+ -- Audit log / activity stream — records every state-changing action
152
+ -- across CLI, MCP, web API, and the monitor daemon.
153
+ CREATE TABLE IF NOT EXISTS audit_log (
154
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
155
+ action TEXT NOT NULL, -- create, update, delete, power_on, power_off, etc.
156
+ entity_type TEXT NOT NULL, -- sbc, serial_port, power_plug, etc.
157
+ entity_id INTEGER,
158
+ entity_name TEXT,
159
+ details TEXT,
160
+ logged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
161
+ actor TEXT NOT NULL DEFAULT 'internal', -- e.g. "cli:john", "mcp-stdio:12345-..."
162
+ source TEXT NOT NULL DEFAULT 'internal', -- cli | mcp | api | daemon | internal
163
+ result TEXT NOT NULL DEFAULT 'ok', -- ok | error | forbidden
164
+ claim_id INTEGER REFERENCES claims(id)
165
+ );
166
+
167
+ -- Hardware claims (exclusive access coordination)
168
+ CREATE TABLE IF NOT EXISTS claims (
169
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
170
+ sbc_id INTEGER NOT NULL,
171
+ agent_name TEXT NOT NULL,
172
+ session_id TEXT NOT NULL,
173
+ session_kind TEXT NOT NULL, -- mcp-stdio, mcp-http, cli, web
174
+ owner TEXT, -- authenticated labctl username (MCP HTTP, REST, web), else NULL
175
+ reason TEXT NOT NULL,
176
+ context_json TEXT,
177
+ acquired_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
178
+ duration_seconds INTEGER NOT NULL,
179
+ last_activity TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
180
+ expires_at TIMESTAMP NOT NULL, -- materialized = last_activity + duration_seconds
181
+ renewal_count INTEGER NOT NULL DEFAULT 0,
182
+ released_at TIMESTAMP,
183
+ release_reason TEXT, -- released, expired, force-released, session-lost
184
+ released_by TEXT,
185
+ FOREIGN KEY (sbc_id) REFERENCES sbcs(id) ON DELETE CASCADE
186
+ );
187
+
188
+ -- Actuators (USB relays and similar single-bit hardware resources)
189
+ CREATE TABLE IF NOT EXISTS actuators (
190
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
191
+ name TEXT UNIQUE NOT NULL,
192
+ kind TEXT NOT NULL DEFAULT 'relay', -- relay | gpio | …
193
+ driver TEXT NOT NULL, -- lcus1_serial | numato_acm | …
194
+ device_path TEXT, -- /dev/lab/relay-rack1-a
195
+ vid TEXT, -- USB vendor id (hex string)
196
+ pid TEXT, -- USB product id (hex string)
197
+ serial_no TEXT, -- USB serial number (when present)
198
+ last_probe_at TIMESTAMP, -- last health-probe timestamp
199
+ last_probe_result TEXT, -- ok | unreachable | …
200
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
201
+ );
202
+
203
+ -- Channels (outlets) on an actuator
204
+ CREATE TABLE IF NOT EXISTS actuator_channels (
205
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
206
+ actuator_id INTEGER NOT NULL,
207
+ channel_index INTEGER NOT NULL, -- 1-based
208
+ label TEXT,
209
+ default_state TEXT NOT NULL DEFAULT 'open'
210
+ CHECK(default_state IN ('open','closed')),
211
+ last_state TEXT
212
+ CHECK(last_state IN ('open','closed') OR last_state IS NULL),
213
+ last_changed_at TIMESTAMP,
214
+ cycle_count INTEGER NOT NULL DEFAULT 0,
215
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
216
+ FOREIGN KEY (actuator_id) REFERENCES actuators(id) ON DELETE CASCADE,
217
+ UNIQUE (actuator_id, channel_index)
218
+ );
219
+
220
+ -- Bindings: (SBC, purpose) → actuator_channel
221
+ CREATE TABLE IF NOT EXISTS bindings (
222
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
223
+ sbc_id INTEGER NOT NULL,
224
+ purpose TEXT NOT NULL, -- recovery_mode | boot_select | …
225
+ actuator_channel_id INTEGER NOT NULL,
226
+ shape_mode TEXT NOT NULL
227
+ CHECK(shape_mode IN ('latch','momentary')),
228
+ shape_active TEXT NOT NULL
229
+ CHECK(shape_active IN ('closed','open')),
230
+ momentary_pulse_ms INTEGER,
231
+ sample_phase TEXT NOT NULL DEFAULT 'none'
232
+ CHECK(sample_phase IN ('pre_power','post_power','none')),
233
+ desired_state TEXT NOT NULL DEFAULT 'released'
234
+ CHECK(desired_state IN ('asserted','released','following_power')),
235
+ notes TEXT,
236
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
237
+ FOREIGN KEY (sbc_id) REFERENCES sbcs(id) ON DELETE CASCADE,
238
+ FOREIGN KEY (actuator_channel_id)
239
+ REFERENCES actuator_channels(id) ON DELETE CASCADE,
240
+ UNIQUE (sbc_id, purpose),
241
+ UNIQUE (actuator_channel_id)
242
+ );
243
+
244
+ -- Release requests (polite nudges without forced eviction)
245
+ CREATE TABLE IF NOT EXISTS claim_requests (
246
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
247
+ claim_id INTEGER NOT NULL,
248
+ requested_by TEXT NOT NULL,
249
+ reason TEXT NOT NULL,
250
+ requested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
251
+ acknowledged INTEGER NOT NULL DEFAULT 0, -- SQLite boolean
252
+ FOREIGN KEY (claim_id) REFERENCES claims(id) ON DELETE CASCADE
253
+ );
254
+
255
+ -- Images uploaded over MCP HTTP (mcp.upload_dir): who uploaded each one,
256
+ -- so agents can replace or delete only their own (D014). file_id
257
+ -- (device:inode:mtime_ns:size) ties a row to the file it describes.
258
+ CREATE TABLE IF NOT EXISTS images (
259
+ name TEXT PRIMARY KEY,
260
+ uploaded_by TEXT,
261
+ size INTEGER NOT NULL,
262
+ sha256 TEXT NOT NULL,
263
+ file_id TEXT,
264
+ uploaded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
265
+ );
266
+
267
+ -- Indexes for common queries
268
+ CREATE INDEX IF NOT EXISTS idx_sbcs_project ON sbcs(project);
269
+ CREATE INDEX IF NOT EXISTS idx_sbcs_status ON sbcs(status);
270
+ CREATE INDEX IF NOT EXISTS idx_serial_devices_usb_path ON serial_devices(usb_path);
271
+ CREATE INDEX IF NOT EXISTS idx_serial_ports_sbc ON serial_ports(sbc_id);
272
+ CREATE INDEX IF NOT EXISTS idx_serial_ports_device ON serial_ports(device_path);
273
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_serial_ports_alias ON serial_ports(alias) WHERE alias IS NOT NULL;
274
+ CREATE INDEX IF NOT EXISTS idx_sdwire_assignments_sbc ON sdwire_assignments(sbc_id);
275
+ CREATE INDEX IF NOT EXISTS idx_status_log_sbc ON status_log(sbc_id);
276
+ CREATE INDEX IF NOT EXISTS idx_status_log_logged_at ON status_log(logged_at);
277
+ CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log(entity_type, entity_id);
278
+ CREATE INDEX IF NOT EXISTS idx_audit_log_logged_at ON audit_log(logged_at DESC);
279
+ CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor, logged_at DESC);
280
+ CREATE INDEX IF NOT EXISTS idx_audit_log_source ON audit_log(source, logged_at DESC);
281
+
282
+ -- At most one active claim per SBC (partial unique index)
283
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_claims_active_sbc
284
+ ON claims(sbc_id) WHERE released_at IS NULL;
285
+ CREATE INDEX IF NOT EXISTS idx_claims_session
286
+ ON claims(session_id) WHERE released_at IS NULL;
287
+ CREATE INDEX IF NOT EXISTS idx_claims_agent
288
+ ON claims(agent_name) WHERE released_at IS NULL;
289
+ CREATE INDEX IF NOT EXISTS idx_claims_expiry
290
+ ON claims(expires_at) WHERE released_at IS NULL;
291
+ CREATE INDEX IF NOT EXISTS idx_claim_requests_claim
292
+ ON claim_requests(claim_id);
293
+ CREATE INDEX IF NOT EXISTS idx_actuator_channels_actuator
294
+ ON actuator_channels(actuator_id);
295
+ CREATE INDEX IF NOT EXISTS idx_bindings_sbc ON bindings(sbc_id);
296
+ CREATE INDEX IF NOT EXISTS idx_bindings_actuator_channel
297
+ ON bindings(actuator_channel_id);
298
+ """ # noqa: E501
299
+
300
+
301
+ class Database:
302
+ """SQLite database manager for lab controller."""
303
+
304
+ def __init__(
305
+ self,
306
+ db_path: Path,
307
+ *,
308
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
309
+ ):
310
+ """
311
+ Initialize database manager.
312
+
313
+ Args:
314
+ db_path: Path to SQLite database file.
315
+ timeout_seconds: SQLite busy-wait used when another process
316
+ holds an incompatible lock. Defaults to 10s; usually
317
+ wired from ``Config.database.timeout_seconds``.
318
+ """
319
+ self.db_path = db_path
320
+ self.timeout_seconds = timeout_seconds
321
+ # Cache: only run the migration-probe block once per process per
322
+ # Database instance. Short-lived CLI invocations still pay one
323
+ # initialize(); long-lived processes (daemon, MCP, web) pay it
324
+ # exactly once total. Reset by tests via _reset_initialized().
325
+ self._initialized = False
326
+ # RLock (not Lock) so a future migration or test helper that
327
+ # happens to call back into initialize() while holding the lock
328
+ # doesn't self-deadlock. Nothing in the current codebase does
329
+ # this, but Lock made it a sharp edge.
330
+ self._init_lock = threading.RLock()
331
+
332
+ def initialize(self) -> None:
333
+ """Initialize database schema if needed.
334
+
335
+ Idempotent across calls within a single process: the first call
336
+ runs the WAL/synchronous pragmas and the migration probe; every
337
+ subsequent call is a near-no-op (only the cache flag is read
338
+ under a lock). Multiple Python processes still each call this
339
+ once on first DB access — which is fine because the pragmas are
340
+ idempotent and the migration probe early-exits when current.
341
+ """
342
+ with self._init_lock:
343
+ if self._initialized:
344
+ return
345
+
346
+ # Ensure directory exists
347
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
348
+
349
+ with self.connect() as conn:
350
+ self._apply_persistent_pragmas(conn)
351
+ # Check current schema version
352
+ query = (
353
+ "SELECT name FROM sqlite_master "
354
+ "WHERE type='table' AND name='schema_version'"
355
+ )
356
+ cursor = conn.execute(query)
357
+ if cursor.fetchone() is None:
358
+ # Fresh database - apply full schema.
359
+ # OR IGNORE on the version insert defends against the
360
+ # rare cross-process race where two fresh-DB
361
+ # initialize() calls interleave between the
362
+ # sqlite_master probe and the INSERT — both see no
363
+ # schema_version table, both run SCHEMA_SQL (idempotent
364
+ # via IF NOT EXISTS), then the second INSERT would
365
+ # otherwise fail with a PRIMARY KEY conflict.
366
+ _executescript_atomic(conn, SCHEMA_SQL)
367
+ conn.execute(
368
+ "INSERT OR IGNORE INTO schema_version (version) " "VALUES (?)",
369
+ (SCHEMA_VERSION,),
370
+ )
371
+ else:
372
+ # Check for migrations
373
+ cursor = conn.execute("SELECT MAX(version) FROM schema_version")
374
+ current_version = cursor.fetchone()[0] or 0
375
+ if current_version < SCHEMA_VERSION:
376
+ self._apply_migrations(conn, current_version)
377
+
378
+ self._initialized = True
379
+
380
+ def _apply_persistent_pragmas(self, conn: sqlite3.Connection) -> None:
381
+ """Switch the database to WAL journal mode.
382
+
383
+ ``journal_mode=WAL`` is persisted in the database file header,
384
+ so any subsequent process that opens the file inherits it.
385
+ Running it every ``initialize()`` is cheap and ensures a
386
+ freshly-restored rollback-mode backup gets upgraded back to WAL
387
+ on next start. ``synchronous`` is per-connection and set in
388
+ ``connect()`` instead.
389
+ """
390
+ try:
391
+ mode = conn.execute("PRAGMA journal_mode=WAL").fetchone()[0]
392
+ if mode.lower() != "wal":
393
+ # Read-only filesystem or otherwise unable to switch —
394
+ # warn but keep going. Lock contention will fall back to
395
+ # the connect()-level busy_timeout backstop.
396
+ logger.warning(
397
+ "DB %s: requested journal_mode=WAL but got %r — "
398
+ "concurrent readers/writers may block each other.",
399
+ self.db_path,
400
+ mode,
401
+ )
402
+ except sqlite3.OperationalError as e:
403
+ logger.warning("DB %s: pragma setup failed: %s", self.db_path, e)
404
+
405
+ def _reset_initialized(self) -> None:
406
+ """Test-only: force the next initialize() to re-run."""
407
+ with self._init_lock:
408
+ self._initialized = False
409
+
410
+ def _apply_migrations(self, conn: sqlite3.Connection, from_version: int) -> None:
411
+ """Apply database migrations."""
412
+ if from_version < 2:
413
+ # v2: Add serial_devices table, add alias/serial_device_id to serial_ports
414
+ _executescript_atomic(
415
+ conn,
416
+ """
417
+ CREATE TABLE IF NOT EXISTS serial_devices (
418
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
419
+ name TEXT UNIQUE NOT NULL,
420
+ usb_path TEXT UNIQUE NOT NULL,
421
+ vendor TEXT,
422
+ model TEXT,
423
+ serial_number TEXT,
424
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
425
+ );
426
+ CREATE INDEX IF NOT EXISTS idx_serial_devices_usb_path
427
+ ON serial_devices(usb_path);
428
+ """,
429
+ )
430
+ # ALTER TABLE cannot add FK constraints in SQLite, but the column works fine
431
+ try:
432
+ conn.execute("ALTER TABLE serial_ports ADD COLUMN alias TEXT")
433
+ except sqlite3.OperationalError:
434
+ pass # Column already exists
435
+ try:
436
+ conn.execute(
437
+ "ALTER TABLE serial_ports ADD COLUMN serial_device_id INTEGER"
438
+ )
439
+ except sqlite3.OperationalError:
440
+ pass # Column already exists
441
+ try:
442
+ conn.execute(
443
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_serial_ports_alias "
444
+ "ON serial_ports(alias) WHERE alias IS NOT NULL"
445
+ )
446
+ except sqlite3.OperationalError:
447
+ pass # Index already exists
448
+
449
+ if from_version < 3:
450
+ # v3: Add SDWire device and assignment tables
451
+ _executescript_atomic(
452
+ conn,
453
+ """
454
+ CREATE TABLE IF NOT EXISTS sdwire_devices (
455
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
456
+ name TEXT UNIQUE NOT NULL,
457
+ serial_number TEXT UNIQUE NOT NULL,
458
+ device_type TEXT NOT NULL DEFAULT 'sdwirec',
459
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
460
+ );
461
+ CREATE TABLE IF NOT EXISTS sdwire_assignments (
462
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
463
+ sbc_id INTEGER UNIQUE NOT NULL,
464
+ sdwire_device_id INTEGER NOT NULL,
465
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
466
+ FOREIGN KEY (sbc_id) REFERENCES sbcs(id) ON DELETE CASCADE,
467
+ FOREIGN KEY (sdwire_device_id) REFERENCES sdwire_devices(id)
468
+ ON DELETE CASCADE
469
+ );
470
+ CREATE INDEX IF NOT EXISTS idx_sdwire_assignments_sbc
471
+ ON sdwire_assignments(sbc_id);
472
+ """,
473
+ )
474
+
475
+ if from_version < 4:
476
+ # v4: Hardware claims (exclusive access coordination)
477
+ _executescript_atomic(
478
+ conn,
479
+ """
480
+ CREATE TABLE IF NOT EXISTS claims (
481
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
482
+ sbc_id INTEGER NOT NULL,
483
+ agent_name TEXT NOT NULL,
484
+ session_id TEXT NOT NULL,
485
+ session_kind TEXT NOT NULL,
486
+ reason TEXT NOT NULL,
487
+ context_json TEXT,
488
+ acquired_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
489
+ duration_seconds INTEGER NOT NULL,
490
+ last_activity TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
491
+ expires_at TIMESTAMP NOT NULL,
492
+ renewal_count INTEGER NOT NULL DEFAULT 0,
493
+ released_at TIMESTAMP,
494
+ release_reason TEXT,
495
+ released_by TEXT,
496
+ FOREIGN KEY (sbc_id) REFERENCES sbcs(id) ON DELETE CASCADE
497
+ );
498
+ CREATE TABLE IF NOT EXISTS claim_requests (
499
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
500
+ claim_id INTEGER NOT NULL,
501
+ requested_by TEXT NOT NULL,
502
+ reason TEXT NOT NULL,
503
+ requested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
504
+ acknowledged INTEGER NOT NULL DEFAULT 0,
505
+ FOREIGN KEY (claim_id) REFERENCES claims(id) ON DELETE CASCADE
506
+ );
507
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_claims_active_sbc
508
+ ON claims(sbc_id) WHERE released_at IS NULL;
509
+ CREATE INDEX IF NOT EXISTS idx_claims_session
510
+ ON claims(session_id) WHERE released_at IS NULL;
511
+ CREATE INDEX IF NOT EXISTS idx_claims_agent
512
+ ON claims(agent_name) WHERE released_at IS NULL;
513
+ CREATE INDEX IF NOT EXISTS idx_claims_expiry
514
+ ON claims(expires_at) WHERE released_at IS NULL;
515
+ CREATE INDEX IF NOT EXISTS idx_claim_requests_claim
516
+ ON claim_requests(claim_id);
517
+ """,
518
+ )
519
+
520
+ if from_version < 5:
521
+ # v5: Activity stream — extend audit_log with actor/source/result/claim_id
522
+ _executescript_atomic(
523
+ conn,
524
+ """
525
+ CREATE TABLE IF NOT EXISTS audit_log (
526
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
527
+ action TEXT NOT NULL,
528
+ entity_type TEXT NOT NULL,
529
+ entity_id INTEGER,
530
+ entity_name TEXT,
531
+ details TEXT,
532
+ logged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
533
+ actor TEXT NOT NULL DEFAULT 'internal',
534
+ source TEXT NOT NULL DEFAULT 'internal',
535
+ result TEXT NOT NULL DEFAULT 'ok',
536
+ claim_id INTEGER REFERENCES claims(id)
537
+ );
538
+ CREATE INDEX IF NOT EXISTS idx_audit_log_entity
539
+ ON audit_log(entity_type, entity_id);
540
+ """,
541
+ )
542
+ for column_sql in (
543
+ "ALTER TABLE audit_log ADD COLUMN actor "
544
+ "TEXT NOT NULL DEFAULT 'internal'",
545
+ "ALTER TABLE audit_log ADD COLUMN source "
546
+ "TEXT NOT NULL DEFAULT 'internal'",
547
+ "ALTER TABLE audit_log ADD COLUMN result TEXT NOT NULL DEFAULT 'ok'",
548
+ "ALTER TABLE audit_log ADD COLUMN claim_id "
549
+ "INTEGER REFERENCES claims(id)",
550
+ ):
551
+ try:
552
+ conn.execute(column_sql)
553
+ except sqlite3.OperationalError:
554
+ pass # Column already exists
555
+ _executescript_atomic(
556
+ conn,
557
+ """
558
+ CREATE INDEX IF NOT EXISTS idx_audit_log_logged_at
559
+ ON audit_log(logged_at DESC);
560
+ CREATE INDEX IF NOT EXISTS idx_audit_log_actor
561
+ ON audit_log(actor, logged_at DESC);
562
+ CREATE INDEX IF NOT EXISTS idx_audit_log_source
563
+ ON audit_log(source, logged_at DESC);
564
+ """,
565
+ )
566
+
567
+ if from_version < 6:
568
+ # v6: cache the daemon's most recent power observation on each
569
+ # SBC so `labctl status --fast` can render without live probes.
570
+ for column_sql in (
571
+ "ALTER TABLE sbcs ADD COLUMN last_power_state TEXT",
572
+ "ALTER TABLE sbcs ADD COLUMN last_power_at TIMESTAMP",
573
+ ):
574
+ try:
575
+ conn.execute(column_sql)
576
+ except sqlite3.OperationalError:
577
+ pass # column already exists
578
+
579
+ if from_version < 7:
580
+ # v7: per-SBC power-cycle delay (used as both default and floor).
581
+ try:
582
+ conn.execute(
583
+ "ALTER TABLE sbcs ADD COLUMN power_cycle_delay_seconds REAL"
584
+ )
585
+ except sqlite3.OperationalError:
586
+ pass # column already exists
587
+
588
+ if from_version < 8:
589
+ # v8: actuators, actuator_channels, bindings — first-class
590
+ # support for USB relays and per-target purpose bindings.
591
+ _executescript_atomic(
592
+ conn,
593
+ """
594
+ CREATE TABLE IF NOT EXISTS actuators (
595
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
596
+ name TEXT UNIQUE NOT NULL,
597
+ kind TEXT NOT NULL DEFAULT 'relay',
598
+ driver TEXT NOT NULL,
599
+ device_path TEXT,
600
+ vid TEXT,
601
+ pid TEXT,
602
+ serial_no TEXT,
603
+ last_probe_at TIMESTAMP,
604
+ last_probe_result TEXT,
605
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
606
+ );
607
+ CREATE TABLE IF NOT EXISTS actuator_channels (
608
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
609
+ actuator_id INTEGER NOT NULL,
610
+ channel_index INTEGER NOT NULL,
611
+ label TEXT,
612
+ default_state TEXT NOT NULL DEFAULT 'open'
613
+ CHECK(default_state IN ('open','closed')),
614
+ last_state TEXT
615
+ CHECK(last_state IN ('open','closed') OR last_state IS NULL),
616
+ last_changed_at TIMESTAMP,
617
+ cycle_count INTEGER NOT NULL DEFAULT 0,
618
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
619
+ FOREIGN KEY (actuator_id) REFERENCES actuators(id)
620
+ ON DELETE CASCADE,
621
+ UNIQUE (actuator_id, channel_index)
622
+ );
623
+ CREATE TABLE IF NOT EXISTS bindings (
624
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
625
+ sbc_id INTEGER NOT NULL,
626
+ purpose TEXT NOT NULL,
627
+ actuator_channel_id INTEGER NOT NULL,
628
+ shape_mode TEXT NOT NULL
629
+ CHECK(shape_mode IN ('latch','momentary')),
630
+ shape_active TEXT NOT NULL
631
+ CHECK(shape_active IN ('closed','open')),
632
+ momentary_pulse_ms INTEGER,
633
+ sample_phase TEXT NOT NULL DEFAULT 'none'
634
+ CHECK(sample_phase IN ('pre_power','post_power','none')),
635
+ desired_state TEXT NOT NULL DEFAULT 'released'
636
+ CHECK(desired_state IN ('asserted','released','following_power')),
637
+ notes TEXT,
638
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
639
+ FOREIGN KEY (sbc_id) REFERENCES sbcs(id) ON DELETE CASCADE,
640
+ FOREIGN KEY (actuator_channel_id)
641
+ REFERENCES actuator_channels(id) ON DELETE CASCADE,
642
+ UNIQUE (sbc_id, purpose),
643
+ UNIQUE (actuator_channel_id)
644
+ );
645
+ CREATE INDEX IF NOT EXISTS idx_actuator_channels_actuator
646
+ ON actuator_channels(actuator_id);
647
+ CREATE INDEX IF NOT EXISTS idx_bindings_sbc ON bindings(sbc_id);
648
+ CREATE INDEX IF NOT EXISTS idx_bindings_actuator_channel
649
+ ON bindings(actuator_channel_id);
650
+ """, # noqa: E501
651
+ )
652
+
653
+ if from_version < 9:
654
+ # v9: claims.owner — the authenticated labctl username, so one
655
+ # user is one claimant across MCP, REST and server restarts.
656
+ try:
657
+ conn.execute("ALTER TABLE claims ADD COLUMN owner TEXT")
658
+ except sqlite3.OperationalError:
659
+ pass # column already exists
660
+ # Authenticated HTTP sessions already ended in ":<username>"
661
+ # ("mcp-stdio:<pid>-<epoch>:<user>" before v9): backfill the
662
+ # owner of open claims so their users still hold them after
663
+ # the upgrade restarts the server.
664
+ cols = {r[1] for r in conn.execute("PRAGMA table_info(claims)")}
665
+ rows = (
666
+ conn.execute(
667
+ "SELECT id, session_id FROM claims"
668
+ " WHERE released_at IS NULL AND owner IS NULL"
669
+ " AND session_id LIKE 'mcp-%:%:%'"
670
+ ).fetchall()
671
+ if {"session_id", "released_at"} <= cols
672
+ else []
673
+ )
674
+ for claim_id, session_id in rows:
675
+ user = session_id.split(":", 2)[2]
676
+ if user:
677
+ conn.execute(
678
+ "UPDATE claims SET owner = ? WHERE id = ?", (user, claim_id)
679
+ )
680
+
681
+ if from_version < 11:
682
+ # v10: images uploaded over MCP HTTP and who uploaded them.
683
+ # v11: a pre-release v10, created by test runs that reached a
684
+ # lab host's real database, had an images table without
685
+ # file_id (uploads then failed with 500): add it. Rows recorded
686
+ # without one count as unverified (operator-only), as a
687
+ # mismatch would. Both steps are no-ops on a released v10.
688
+ conn.execute("""
689
+ CREATE TABLE IF NOT EXISTS images (
690
+ name TEXT PRIMARY KEY,
691
+ uploaded_by TEXT,
692
+ size INTEGER NOT NULL,
693
+ sha256 TEXT NOT NULL,
694
+ file_id TEXT,
695
+ uploaded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
696
+ )
697
+ """)
698
+ try:
699
+ conn.execute("ALTER TABLE images ADD COLUMN file_id TEXT")
700
+ except sqlite3.OperationalError as e:
701
+ if "duplicate column" not in str(e):
702
+ raise
703
+
704
+ if from_version < 12:
705
+ # v12: the monitor prunes status history by age
706
+ # (health.status_retention_days). A hand-made database may
707
+ # lack the table; there is nothing to index then.
708
+ has_table = conn.execute(
709
+ "SELECT 1 FROM sqlite_master WHERE type = 'table'"
710
+ " AND name = 'status_log'"
711
+ ).fetchone()
712
+ if has_table:
713
+ conn.execute(
714
+ "CREATE INDEX IF NOT EXISTS idx_status_log_logged_at"
715
+ " ON status_log(logged_at)"
716
+ )
717
+
718
+ # OR IGNORE: tolerate a concurrent process that has already
719
+ # bumped schema_version to the current value during a race.
720
+ conn.execute(
721
+ "INSERT OR IGNORE INTO schema_version (version) VALUES (?)",
722
+ (SCHEMA_VERSION,),
723
+ )
724
+
725
+ @contextmanager
726
+ def connect(self) -> Generator[sqlite3.Connection, None, None]:
727
+ """
728
+ Get a database connection as a context manager.
729
+
730
+ The ``timeout=`` arg makes ``sqlite3`` poll the busy lock for
731
+ up to ``self.timeout_seconds`` before raising
732
+ ``OperationalError``; the per-connection ``busy_timeout`` pragma
733
+ is a belt-and-suspenders for sqlite3 stdlib versions where the
734
+ ``timeout=`` plumbing has historically been buggy.
735
+
736
+ Yields:
737
+ SQLite connection with row factory set to sqlite3.Row.
738
+ """
739
+ conn = sqlite3.connect(self.db_path, timeout=self.timeout_seconds)
740
+ conn.row_factory = sqlite3.Row
741
+ # Best-effort per-connection tuning. A caller without OS-level
742
+ # write permission on the DB file opens the connection just
743
+ # fine for reads, but SQLite refuses to execute write-side
744
+ # pragmas on that connection ("attempt to write a readonly
745
+ # database"). We log at debug and continue so unprivileged
746
+ # readers (callers outside the ``labctl`` group, ad-hoc CLI
747
+ # users, etc.) keep working after the WAL switch. Writable
748
+ # connections still get the full tuning.
749
+ for pragma in (
750
+ # Mirrors the connect() timeout; belt-and-suspenders for
751
+ # sqlite3 stdlib versions where `timeout=` plumbing has
752
+ # historically been buggy.
753
+ f"PRAGMA busy_timeout = {int(self.timeout_seconds * 1000)}",
754
+ # synchronous=NORMAL is per-connection (not persisted in
755
+ # the file like journal_mode), so it must be set on every
756
+ # connect for writers. Pairs with WAL for substantially
757
+ # faster commits at the cost of a few seconds of write
758
+ # loss on a hard power failure — acceptable for a lab DB.
759
+ "PRAGMA synchronous = NORMAL",
760
+ # FK enforcement is per-connection. Irrelevant for a
761
+ # read-only connection (no writes to enforce against).
762
+ "PRAGMA foreign_keys = ON",
763
+ ):
764
+ try:
765
+ conn.execute(pragma)
766
+ except sqlite3.OperationalError as e:
767
+ logger.debug(
768
+ "connect(%s): pragma %r failed (read-only connection?): %s",
769
+ self.db_path,
770
+ pragma,
771
+ e,
772
+ )
773
+ try:
774
+ yield conn
775
+ conn.commit()
776
+ except Exception:
777
+ conn.rollback()
778
+ raise
779
+ finally:
780
+ conn.close()
781
+
782
+ def execute(
783
+ self,
784
+ sql: str,
785
+ params: tuple = (),
786
+ ) -> list[sqlite3.Row]:
787
+ """
788
+ Execute SQL and return all results.
789
+
790
+ Args:
791
+ sql: SQL statement
792
+ params: Query parameters
793
+
794
+ Returns:
795
+ List of result rows
796
+ """
797
+ with self.connect() as conn:
798
+ cursor = conn.execute(sql, params)
799
+ return cursor.fetchall()
800
+
801
+ def execute_one(
802
+ self,
803
+ sql: str,
804
+ params: tuple = (),
805
+ ) -> Optional[sqlite3.Row]:
806
+ """
807
+ Execute SQL and return single result.
808
+
809
+ Args:
810
+ sql: SQL statement
811
+ params: Query parameters
812
+
813
+ Returns:
814
+ Single result row or None
815
+ """
816
+ with self.connect() as conn:
817
+ cursor = conn.execute(sql, params)
818
+ return cursor.fetchone()
819
+
820
+ def execute_insert(
821
+ self,
822
+ sql: str,
823
+ params: tuple = (),
824
+ ) -> int:
825
+ """
826
+ Execute INSERT and return last row ID.
827
+
828
+ Args:
829
+ sql: INSERT statement
830
+ params: Query parameters
831
+
832
+ Returns:
833
+ ID of inserted row
834
+ """
835
+ with self.connect() as conn:
836
+ cursor = conn.execute(sql, params)
837
+ return cursor.lastrowid
838
+
839
+ def execute_modify(
840
+ self,
841
+ sql: str,
842
+ params: tuple = (),
843
+ ) -> int:
844
+ """
845
+ Execute UPDATE/DELETE and return affected row count.
846
+
847
+ Args:
848
+ sql: UPDATE or DELETE statement
849
+ params: Query parameters
850
+
851
+ Returns:
852
+ Number of affected rows
853
+ """
854
+ with self.connect() as conn:
855
+ cursor = conn.execute(sql, params)
856
+ return cursor.rowcount
857
+
858
+
859
+ def get_database(
860
+ db_path: Path, *, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
861
+ ) -> Database:
862
+ """
863
+ Get an initialized database instance.
864
+
865
+ Args:
866
+ db_path: Path to database file.
867
+ timeout_seconds: SQLite busy-wait. Usually wired from
868
+ ``Config.database.timeout_seconds``; default 10s.
869
+
870
+ Returns:
871
+ Initialized Database instance.
872
+ """
873
+ db = Database(db_path, timeout_seconds=timeout_seconds)
874
+ db.initialize()
875
+ return db
876
+
877
+
878
+ def _executescript_atomic(conn: sqlite3.Connection, sql: str) -> None:
879
+ """Run DDL in one explicit transaction to avoid slow statement commits."""
880
+ conn.executescript(f"BEGIN;\n{sql}\nCOMMIT;")