embedded-lab-control 0.2.0__tar.gz

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 (145) hide show
  1. embedded_lab_control-0.2.0/CHANGELOG.md +1514 -0
  2. embedded_lab_control-0.2.0/LICENSE +21 -0
  3. embedded_lab_control-0.2.0/MANIFEST.in +20 -0
  4. embedded_lab_control-0.2.0/PKG-INFO +855 -0
  5. embedded_lab_control-0.2.0/README.md +808 -0
  6. embedded_lab_control-0.2.0/SECURITY.md +110 -0
  7. embedded_lab_control-0.2.0/config/labctl.yaml.example +197 -0
  8. embedded_lab_control-0.2.0/config/systemd/labctl-mcp.service +31 -0
  9. embedded_lab_control-0.2.0/config/systemd/labctl-monitor.service +31 -0
  10. embedded_lab_control-0.2.0/config/systemd/labctl-web.service +38 -0
  11. embedded_lab_control-0.2.0/config/udev/port-mapping.yaml.example +35 -0
  12. embedded_lab_control-0.2.0/docs/AGENT_RULES.md +602 -0
  13. embedded_lab_control-0.2.0/docs/CLAUDE_CODE_AGENTS.md +430 -0
  14. embedded_lab_control-0.2.0/docs/DECISIONS.md +285 -0
  15. embedded_lab_control-0.2.0/docs/HARDWARE_MAP.md +213 -0
  16. embedded_lab_control-0.2.0/docs/IMPLEMENTATION.md +956 -0
  17. embedded_lab_control-0.2.0/docs/MCP_SERVER.md +916 -0
  18. embedded_lab_control-0.2.0/docs/OPERATIONS.md +374 -0
  19. embedded_lab_control-0.2.0/docs/RELEASING.md +115 -0
  20. embedded_lab_control-0.2.0/docs/RESOLVED_BUG_ser2net_missing_dev_prefix.md +75 -0
  21. embedded_lab_control-0.2.0/docs/RESOLVED_FEATURE_sdwire_power_safety.md +90 -0
  22. embedded_lab_control-0.2.0/docs/RESOLVED_SPEC_activity_stream.md +341 -0
  23. embedded_lab_control-0.2.0/docs/RESOLVED_SPEC_claims.md +642 -0
  24. embedded_lab_control-0.2.0/docs/RESOLVED_SPEC_sdwire_read_access.md +297 -0
  25. embedded_lab_control-0.2.0/docs/SPEC_actuators.md +527 -0
  26. embedded_lab_control-0.2.0/docs/SPEC_storage_device_guardrails.md +371 -0
  27. embedded_lab_control-0.2.0/pyproject.toml +105 -0
  28. embedded_lab_control-0.2.0/scripts/discover-usb-serial.sh +86 -0
  29. embedded_lab_control-0.2.0/scripts/generate-udev-rules.py +270 -0
  30. embedded_lab_control-0.2.0/scripts/install-services.sh +233 -0
  31. embedded_lab_control-0.2.0/scripts/install-udev.sh +101 -0
  32. embedded_lab_control-0.2.0/scripts/kasa-debug.py +393 -0
  33. embedded_lab_control-0.2.0/scripts/update.sh +445 -0
  34. embedded_lab_control-0.2.0/setup.cfg +4 -0
  35. embedded_lab_control-0.2.0/skills/deploy-and-test.md +176 -0
  36. embedded_lab_control-0.2.0/src/embedded_lab_control.egg-info/PKG-INFO +855 -0
  37. embedded_lab_control-0.2.0/src/embedded_lab_control.egg-info/SOURCES.txt +143 -0
  38. embedded_lab_control-0.2.0/src/embedded_lab_control.egg-info/dependency_links.txt +1 -0
  39. embedded_lab_control-0.2.0/src/embedded_lab_control.egg-info/entry_points.txt +2 -0
  40. embedded_lab_control-0.2.0/src/embedded_lab_control.egg-info/requires.txt +33 -0
  41. embedded_lab_control-0.2.0/src/embedded_lab_control.egg-info/top_level.txt +1 -0
  42. embedded_lab_control-0.2.0/src/labctl/__init__.py +8 -0
  43. embedded_lab_control-0.2.0/src/labctl/actuators/__init__.py +56 -0
  44. embedded_lab_control-0.2.0/src/labctl/actuators/base.py +183 -0
  45. embedded_lab_control-0.2.0/src/labctl/actuators/lcus1.py +232 -0
  46. embedded_lab_control-0.2.0/src/labctl/actuators/mock.py +81 -0
  47. embedded_lab_control-0.2.0/src/labctl/actuators/runtime.py +1178 -0
  48. embedded_lab_control-0.2.0/src/labctl/cli.py +7485 -0
  49. embedded_lab_control-0.2.0/src/labctl/core/__init__.py +36 -0
  50. embedded_lab_control-0.2.0/src/labctl/core/admin_files.py +388 -0
  51. embedded_lab_control-0.2.0/src/labctl/core/audit.py +269 -0
  52. embedded_lab_control-0.2.0/src/labctl/core/claim_keepalive.py +342 -0
  53. embedded_lab_control-0.2.0/src/labctl/core/config.py +1068 -0
  54. embedded_lab_control-0.2.0/src/labctl/core/database.py +880 -0
  55. embedded_lab_control-0.2.0/src/labctl/core/images.py +503 -0
  56. embedded_lab_control-0.2.0/src/labctl/core/manager.py +2815 -0
  57. embedded_lab_control-0.2.0/src/labctl/core/models.py +815 -0
  58. embedded_lab_control-0.2.0/src/labctl/core/safe_open.py +109 -0
  59. embedded_lab_control-0.2.0/src/labctl/extras.py +128 -0
  60. embedded_lab_control-0.2.0/src/labctl/health/__init__.py +43 -0
  61. embedded_lab_control-0.2.0/src/labctl/health/alerts.py +275 -0
  62. embedded_lab_control-0.2.0/src/labctl/health/checks.py +533 -0
  63. embedded_lab_control-0.2.0/src/labctl/health/daemon.py +397 -0
  64. embedded_lab_control-0.2.0/src/labctl/mcp_server.py +5254 -0
  65. embedded_lab_control-0.2.0/src/labctl/power/__init__.py +13 -0
  66. embedded_lab_control-0.2.0/src/labctl/power/base.py +145 -0
  67. embedded_lab_control-0.2.0/src/labctl/power/kasa.py +446 -0
  68. embedded_lab_control-0.2.0/src/labctl/power/shelly.py +89 -0
  69. embedded_lab_control-0.2.0/src/labctl/power/tasmota.py +95 -0
  70. embedded_lab_control-0.2.0/src/labctl/sdwire/__init__.py +10 -0
  71. embedded_lab_control-0.2.0/src/labctl/sdwire/controller.py +1116 -0
  72. embedded_lab_control-0.2.0/src/labctl/sdwire/power_guard.py +190 -0
  73. embedded_lab_control-0.2.0/src/labctl/serial/__init__.py +18 -0
  74. embedded_lab_control-0.2.0/src/labctl/serial/boot_test.py +342 -0
  75. embedded_lab_control-0.2.0/src/labctl/serial/capture.py +430 -0
  76. embedded_lab_control-0.2.0/src/labctl/serial/keys.py +89 -0
  77. embedded_lab_control-0.2.0/src/labctl/serial/proxy.py +802 -0
  78. embedded_lab_control-0.2.0/src/labctl/serial/ser2net.py +338 -0
  79. embedded_lab_control-0.2.0/src/labctl/serial/udev.py +273 -0
  80. embedded_lab_control-0.2.0/src/labctl/services.py +210 -0
  81. embedded_lab_control-0.2.0/src/labctl/web/__init__.py +9 -0
  82. embedded_lab_control-0.2.0/src/labctl/web/activity_broadcaster.py +204 -0
  83. embedded_lab_control-0.2.0/src/labctl/web/api.py +1003 -0
  84. embedded_lab_control-0.2.0/src/labctl/web/app.py +202 -0
  85. embedded_lab_control-0.2.0/src/labctl/web/auth.py +97 -0
  86. embedded_lab_control-0.2.0/src/labctl/web/claims.py +232 -0
  87. embedded_lab_control-0.2.0/src/labctl/web/static/css/style.css +477 -0
  88. embedded_lab_control-0.2.0/src/labctl/web/static/js/app.js +76 -0
  89. embedded_lab_control-0.2.0/src/labctl/web/templates/activity.html +202 -0
  90. embedded_lab_control-0.2.0/src/labctl/web/templates/base.html +49 -0
  91. embedded_lab_control-0.2.0/src/labctl/web/templates/claim_refused.html +38 -0
  92. embedded_lab_control-0.2.0/src/labctl/web/templates/console.html +237 -0
  93. embedded_lab_control-0.2.0/src/labctl/web/templates/dashboard.html +100 -0
  94. embedded_lab_control-0.2.0/src/labctl/web/templates/login.html +25 -0
  95. embedded_lab_control-0.2.0/src/labctl/web/templates/sbc_detail.html +306 -0
  96. embedded_lab_control-0.2.0/src/labctl/web/templates/settings.html +78 -0
  97. embedded_lab_control-0.2.0/src/labctl/web/templates/status_history.html +65 -0
  98. embedded_lab_control-0.2.0/src/labctl/web/views.py +496 -0
  99. embedded_lab_control-0.2.0/src/labctl/web/websocket.py +208 -0
  100. embedded_lab_control-0.2.0/tests/__init__.py +0 -0
  101. embedded_lab_control-0.2.0/tests/conftest.py +142 -0
  102. embedded_lab_control-0.2.0/tests/fixtures/kasa_hs300_fw112.json +937 -0
  103. embedded_lab_control-0.2.0/tests/integration/__init__.py +0 -0
  104. embedded_lab_control-0.2.0/tests/integration/test_actuator_cli.py +1097 -0
  105. embedded_lab_control-0.2.0/tests/integration/test_auth.py +401 -0
  106. embedded_lab_control-0.2.0/tests/integration/test_cli.py +2606 -0
  107. embedded_lab_control-0.2.0/tests/integration/test_cli_boot_test_claims.py +207 -0
  108. embedded_lab_control-0.2.0/tests/integration/test_cli_claim_enforcement.py +1346 -0
  109. embedded_lab_control-0.2.0/tests/integration/test_cli_destructive.py +333 -0
  110. embedded_lab_control-0.2.0/tests/integration/test_pre_power_paths.py +1151 -0
  111. embedded_lab_control-0.2.0/tests/integration/test_web.py +933 -0
  112. embedded_lab_control-0.2.0/tests/integration/test_web_claim_enforcement.py +860 -0
  113. embedded_lab_control-0.2.0/tests/unit/__init__.py +0 -0
  114. embedded_lab_control-0.2.0/tests/unit/test_activity_broadcaster.py +197 -0
  115. embedded_lab_control-0.2.0/tests/unit/test_actuator_driver.py +108 -0
  116. embedded_lab_control-0.2.0/tests/unit/test_actuator_manager.py +484 -0
  117. embedded_lab_control-0.2.0/tests/unit/test_actuator_runtime.py +1492 -0
  118. embedded_lab_control-0.2.0/tests/unit/test_admin_files.py +459 -0
  119. embedded_lab_control-0.2.0/tests/unit/test_audit.py +628 -0
  120. embedded_lab_control-0.2.0/tests/unit/test_boot_test.py +533 -0
  121. embedded_lab_control-0.2.0/tests/unit/test_claim_enforcement.py +719 -0
  122. embedded_lab_control-0.2.0/tests/unit/test_claim_keepalive.py +438 -0
  123. embedded_lab_control-0.2.0/tests/unit/test_config.py +857 -0
  124. embedded_lab_control-0.2.0/tests/unit/test_database.py +1005 -0
  125. embedded_lab_control-0.2.0/tests/unit/test_extras.py +345 -0
  126. embedded_lab_control-0.2.0/tests/unit/test_health.py +706 -0
  127. embedded_lab_control-0.2.0/tests/unit/test_images.py +1497 -0
  128. embedded_lab_control-0.2.0/tests/unit/test_lcus1_driver.py +415 -0
  129. embedded_lab_control-0.2.0/tests/unit/test_manager.py +2393 -0
  130. embedded_lab_control-0.2.0/tests/unit/test_mcp_claim_keepalive.py +399 -0
  131. embedded_lab_control-0.2.0/tests/unit/test_mcp_http_auth.py +1114 -0
  132. embedded_lab_control-0.2.0/tests/unit/test_mcp_server.py +5717 -0
  133. embedded_lab_control-0.2.0/tests/unit/test_operations_doc.py +83 -0
  134. embedded_lab_control-0.2.0/tests/unit/test_power.py +936 -0
  135. embedded_lab_control-0.2.0/tests/unit/test_proxy.py +633 -0
  136. embedded_lab_control-0.2.0/tests/unit/test_safe_open.py +287 -0
  137. embedded_lab_control-0.2.0/tests/unit/test_sdwire.py +1375 -0
  138. embedded_lab_control-0.2.0/tests/unit/test_sdwire_power_guard.py +271 -0
  139. embedded_lab_control-0.2.0/tests/unit/test_ser2net.py +423 -0
  140. embedded_lab_control-0.2.0/tests/unit/test_serial_capture.py +567 -0
  141. embedded_lab_control-0.2.0/tests/unit/test_serial_capture_parts.py +62 -0
  142. embedded_lab_control-0.2.0/tests/unit/test_serial_keys.py +69 -0
  143. embedded_lab_control-0.2.0/tests/unit/test_services.py +193 -0
  144. embedded_lab_control-0.2.0/tests/unit/test_udev.py +360 -0
  145. embedded_lab_control-0.2.0/tests/unit/test_update_script.py +566 -0
@@ -0,0 +1,1514 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.0] - 2026-10-31
10
+
11
+ First release on PyPI, as `embedded-lab-control` (the import package and
12
+ the CLI are still `labctl`): `pipx install "embedded-lab-control[web,mcp]"`,
13
+ adding `kasa`, `sdwire` and `actuators` as needed (see the README for what
14
+ each extra enables). Python 3.10-3.13; the `sdwire` extra needs 3.12+, and
15
+ Kasa KLAP v2 devices need 3.11+. Everything below the upgrade notes is
16
+ the full history since development started; there was no earlier public
17
+ release.
18
+
19
+ **Highlights**
20
+
21
+ - MCP server on the mcp 2.x SDK, over stdio or HTTP (API-key auth, TLS,
22
+ image uploads), with a two-step confirmation for destructive tools and
23
+ a deny-all allowlist for host file paths.
24
+ - Hardware claims enforced on every surface (CLI, REST, web UI, MCP),
25
+ including boards that share a plug outlet, SDWire, console adapter or
26
+ relay channel; long calls keep the caller's claim alive.
27
+ - Power control for Tasmota, Kasa (incl. KLAP v2 strips) and Shelly;
28
+ SDWire SD-card multiplexers; USB relay actuators with per-board
29
+ bindings (`pre_power` straps applied on every power-on path); ser2net
30
+ serial access with udev-stable names; health monitoring; activity
31
+ stream.
32
+ - Session-based web UI and REST API with optional HTTPS.
33
+
34
+ **Upgrading from a source install (`labctl` 0.1.0)**
35
+
36
+ - The distribution was renamed `labctl` → `embedded-lab-control`.
37
+ `scripts/update.sh` and `scripts/install-services.sh` uninstall the old
38
+ distribution first; if you upgrade a venv by hand, run
39
+ `pip uninstall labctl` before installing (both own `labctl/`, and
40
+ removing the old one later would delete the new install's files).
41
+ - The database migrates itself to schema v12 on first use.
42
+ - `labctl mcp --http PORT` now really binds PORT (it always used 8000):
43
+ the shipped unit's server moves to `127.0.0.1:8080`. Clients wired to
44
+ `:8000` must move. A non-loopback `--host` requires `auth.enabled`;
45
+ HTTP clients then send `Authorization: Bearer <api_key>`.
46
+ - MCP host file access is deny-all until `mcp.allowed_read_paths` /
47
+ `mcp.allowed_write_paths` are set (`flash_image`, `sdwire_update`
48
+ copies, `boot_test`); `update.sh` creates `/var/lib/labctl/images` and
49
+ `/var/lib/labctl/output` and prints the snippet to add. The CLI is
50
+ unaffected.
51
+ - Destructive MCP tools return a plan and a `confirm_token` first
52
+ (`mcp.confirm_destructive`, `mcp.confirm_exempt`).
53
+ - Commands that operate or reconfigure a board another user has claimed
54
+ are refused unless overridden (`--override-claim`, REST
55
+ `"override_claim": true`, operators only on REST/web), and the override
56
+ is logged.
57
+ - Removed config keys (`proxy.enabled`, `proxy.log_retention_days`,
58
+ `proxy.idle_timeout`, `health.update_status_on_check`) are ignored
59
+ with a warning. Several commands that used to read a confirmation from
60
+ stdin now need `--yes` without a terminal.
61
+ - After updating a host with generated udev rules, run
62
+ `labctl serial udev --install --reload` (rules are now
63
+ `root:dialout 0660`).
64
+
65
+ **For packagers**: `mcp>=2.2,<3` pulls in `mcp-types` (pinned exactly
66
+ by mcp), `httpx2` and `opentelemetry-api`. The sdist includes the tests,
67
+ config samples and systemd units, docs and scripts; the wheel carries only
68
+ the `labctl` package.
69
+
70
+ ### Added (P2 publish)
71
+ - Release workflow (`.github/workflows/release.yml`): pushing a `vX.Y.Z`
72
+ tag runs the CI gates (`ci.yml`, now also callable as a reusable
73
+ workflow), checks the tag matches `labctl.__version__` and that this
74
+ file has the version's section, builds and `twine check`s the sdist
75
+ and wheel once, publishes them to TestPyPI and then, after approval of
76
+ the protected `pypi` environment, to PyPI with trusted publishing (no
77
+ API tokens), and creates the GitHub Release. Running it by hand only
78
+ builds and checks. `docs/RELEASING.md` has the one-time setup, the
79
+ release steps and what to do when a step fails.
80
+
81
+ ### Changed (P2 release prep)
82
+ - A command whose optional dependency is missing now says which extra to
83
+ install, in one line, instead of a traceback or `pip install <package>`:
84
+ `labctl web` (and REST) and `labctl user hash-password`/`add`/`verify`
85
+ (werkzeug) → `[web]`, `labctl mcp` → `[mcp]`, Kasa plugs → `[kasa]`,
86
+ SDWire → `[sdwire]`, USB relays → `[actuators]`. Each message gives
87
+ `pip install "embedded-lab-control[<extra>]"` and the `pipx inject`
88
+ equivalent with the extra's version floors for the running Python;
89
+ the same text reaches the CLI, REST and MCP (`labctl/extras.py`).
90
+ Below Python 3.12 the SDWire message says it needs 3.12+ (the extra
91
+ installs nothing there) and gives no install command. Only the extra's
92
+ own package being absent is reported this way: an installed but broken
93
+ or incompatible one gets its own one-line error carrying the real
94
+ ImportError and the versions the extra wants (still an error line,
95
+ not a traceback). A missing Kasa library
96
+ no longer also logs a "Failed to load Kasa credentials" warning, and
97
+ isn't remembered for the life of the process.
98
+ - README: install from PyPI with pipx or pip, a table of what each extra
99
+ enables, and Python version notes; the source install and the
100
+ systemd install from a git checkout (`scripts/install-services.sh`)
101
+ are unchanged.
102
+ - `docs/HARDWARE_MAP.md` is now a generic guide to mapping USB serial
103
+ adapters (USB paths, `labctl serial discover/add/udev`, assigning
104
+ ports, troubleshooting) with placeholder paths and names.
105
+ - `scripts/install-udev.sh` requires the rules file as its argument (it
106
+ defaulted to a repository file that no longer exists) and refuses a
107
+ file with no `SYMLINK` assignment or with a non-comment line that
108
+ isn't a udev rule, such as the message `labctl serial udev` prints
109
+ when no adapters are registered. The README and `HARDWARE_MAP.md` note
110
+ that installing a rules file by hand skips the claim check
111
+ `labctl serial udev --install` makes. `labctl ports -v` with no ports points
112
+ at `labctl serial discover` instead of `discover-usb-serial.sh`.
113
+ - The source distribution now includes the tests, `config/` (sample
114
+ config, systemd units, udev mapping example), `docs/`, `scripts/`,
115
+ `skills/`, `CHANGELOG.md` and `SECURITY.md` (`MANIFEST.in`); the wheel
116
+ is unchanged. The test suite passes from an unpacked sdist.
117
+ - CI runs on pushes to `main`, on pull requests (so a PR branch is
118
+ tested once, not twice) and by hand (`workflow_dispatch`).
119
+
120
+ ### Removed (P2 release prep)
121
+ - This lab's `config/udev/99-lab-serial.rules`,
122
+ `config/udev/port-mapping.yaml` and `config/ser2net/labctl-ser2net.yaml`.
123
+ Rules are generated with `labctl serial udev` and the ser2net config
124
+ with `labctl ser2net generate`; `config/udev/port-mapping.yaml.example`
125
+ (now with placeholder paths and names) remains for the legacy
126
+ `generate-udev-rules.py`.
127
+
128
+ ### Added (#2: long calls keep the caller's claim alive)
129
+ - A claim's deadline moved only when a call started, so one long call
130
+ (a 10-run `boot_test`, a long `serial_capture`, flashing a large
131
+ image) could outlast it, and another agent could then claim the board
132
+ and power-cycle it mid-run. MCP `boot_test`, `serial_capture`,
133
+ `sdwire_update` and `flash_image`, and CLI `boot-test`, now heartbeat a
134
+ claim the caller already holds on the board from a background thread
135
+ while they run (every quarter of the claim's current duration, 5-60 s,
136
+ so a renew to a shorter claim is followed). They never claim a board
137
+ for the caller. Each heartbeat is one conditional UPDATE
138
+ (`ResourceManager.heartbeat_held_claim`, which replaces the unused
139
+ `heartbeat_claim`) that never revives a released, expired or
140
+ taken-over claim; `touch_claim` now says whether a row changed. A
141
+ database error is a missed heartbeat (the call goes on) until the
142
+ claim's deadline passes without a successful one, or, for a caller
143
+ holding no claim, until the checks have failed for 60 s in a row; if
144
+ the claim can't be read when the call starts, it runs without
145
+ keepalive (logged). The keepalive follows the claim the call started
146
+ with (its heartbeat matches that claim's id): a claim released and
147
+ taken again, even by the same user, counts as lost.
148
+ - Boot tests check again before each run's power cycle and stop once
149
+ someone else claims the board or a board sharing its hardware, the
150
+ caller's claim is lost (released, force-released or expired), or, over
151
+ MCP with `claims.require_claim`, nobody holds it. The check runs
152
+ before the heartbeat, so a refused check extends nothing. A stopped
153
+ test says `STOPPED after N of M runs: <reason>` and reports
154
+ `Result: INCOMPLETE, ...` (or `Result: STOPPED before the first run`,
155
+ noting that the board is left off if the deploy powered it off)
156
+ instead of a pass rate; MCP results start with `Stopped: <reason>` and
157
+ the CLI exits 1. A CLI test started with `--override-claim` isn't
158
+ stopped by the claims it overrode, but is by any claim taken after it
159
+ started.
160
+ - `sdwire_update` and `flash_image` with `reboot=true` leave the board
161
+ off, and say why, when the check fails after the card is written:
162
+ `Not power cycled: <reason>` (`Not powered on:`) when the caller's own
163
+ claim was lost, `<board> is left off (not power cycled) because
164
+ '<board>' is now claimed by '<agent>'` when someone else claimed the
165
+ board or a board sharing its hardware.
166
+ - `/deploy-and-test` skill: claim after a successful build, sizing the
167
+ claim for the run (for `boot_test`, at least runs × (timeout + cycle
168
+ delay + a few seconds) plus the deploy and a margin); renew it between
169
+ long steps; release it on every path (deploy failure, boot timeout, a
170
+ confirmation not given). Resolves the claims spec's open question on
171
+ the skill.
172
+
173
+ ### Changed (#41: options and config keys that did nothing)
174
+ - `serial.base_tcp_port` (and `LABCTL_BASE_TCP_PORT`) now sets where TCP
175
+ port auto-assignment starts (`port assign` without `-t`, MCP, REST, web,
176
+ `import`): one above the highest port in use, and at least the base. It
177
+ must be 1-65535 (anything else keeps 4000, with a warning). The web
178
+ settings page showed it blank (it read `ser2net.base_tcp_port`).
179
+ - `ser2net generate` no longer gives every port without a TCP port
180
+ 4000. With `--install` each such port is assigned one and saved, as
181
+ `port assign` without `-t` would (one above the highest in use, at
182
+ least `serial.base_tcp_port`; refused past 65535), and the command
183
+ says which, with `port assign`'s claim checks (one shared helper). If
184
+ any such port is on a board another user has claimed, or one sharing
185
+ a claimed board's console device, `--install` refuses and saves and
186
+ writes nothing (leaving the port out would change that board's
187
+ console), listing them; `--override-claim` (new; recorded in the
188
+ activity log) assigns them anyway. Claimed boards whose ports already
189
+ have a TCP port are written as before. Nothing is saved unless the
190
+ config file can be written (checked first), and each save repeats
191
+ `port assign`'s check, so a claim taken meanwhile still refuses; any
192
+ failure after a save lists the TCP ports already saved. A port whose
193
+ save fails is left out and reported; the config is still written and
194
+ the command exits 1.
195
+ Without `--install` nothing is saved: those ports are left out of the
196
+ output and the number each would get is shown (stderr).
197
+ - `ser2net generate --install` writes `ser2net.config_file` (default
198
+ `/etc/ser2net.yaml`, unchanged); it always wrote `/etc/ser2net.yaml`.
199
+ The write is atomic when labctl can create files in the directory (a
200
+ temporary file there, given the old file's owner and mode, fsynced and
201
+ renamed over it), and otherwise rewritten in place (e.g. a labctl group
202
+ member, who can write `/etc/ser2net.yaml` but not `/etc`); a symlink on
203
+ the path or a hard-linked file is refused, since it may run as root. The
204
+ permission-denied message names the configured path; when the file is
205
+ really absent from a directory labctl can reach, it says to create it
206
+ with `sudo labctl ser2net generate --install` (and, for
207
+ `/etc/ser2net.yaml`, then rerun `install-services.sh` or `update.sh`
208
+ to make it group-writable); when the path can't be reached, it says so.
209
+ `install-services.sh`, and now `update.sh` (for a file created after the
210
+ install), make only `/etc/ser2net.yaml` group-writable (`root:labctl
211
+ 664`; a symlink there is left alone).
212
+ - `health.status_retention_days` now prunes status history: the monitor
213
+ deletes older entries at startup and then hourly; `0` keeps all of it.
214
+ Each board's newest entry per status, and the one in effect at the
215
+ cutoff, are always kept, so a board stable for longer than the
216
+ retention keeps its uptime. Schema v12 indexes `status_log(logged_at)`.
217
+ An hourly run is cheap lookups (newest row, rows past the cutoff, all
218
+ rows) and prunes only when one changed since the monitor's last prune.
219
+ Pruning deletes in transactions of at most 5,000 rows, so the first
220
+ prune of a long history doesn't hold the write lock for long.
221
+ - Uptime (`GET /api/sbcs/<name>/uptime`): the 24-hour figure starts from
222
+ the status in effect 24 hours ago, so a board up the whole day shows
223
+ 100% (it showed 0% with no status change in the window), and times are
224
+ compared in the database's clock (UTC), not local time.
225
+ - `claims.require_agent_name` is enforced: MCP `claim_sbc` and
226
+ `POST /api/claims/<sbc>` refuse (`agent_name_required`) a claim that
227
+ would be recorded under a placeholder name: no `agent_name` and no
228
+ authenticated user (or, over MCP stdio, no name given earlier in the
229
+ session). CLI claims are always named (`--name` or `$USER`). A REST
230
+ `agent_name` that isn't a string is a 400.
231
+ - MCP over unauthenticated HTTP no longer remembers an `agent_name`
232
+ across calls: the server can't tell its clients apart, so one client's
233
+ name named every other client's claims and releases. It is used for
234
+ the call that gives it; stdio and per-user (authenticated) names are
235
+ still remembered.
236
+ - MCP `claim_sbc` defaults `duration_minutes` to
237
+ `claims.default_duration_minutes`, like the CLI and REST (it was 30).
238
+ - `post-power` bindings can no longer be created: nothing acts on them
239
+ yet. `bind --phase` no longer offers `post-power`, and labctl
240
+ (`ResourceManager`) refuses `post_power`; MCP `bind` refuses it before
241
+ its claim checks. Bindings
242
+ stored with it before are kept and listed as inactive (`bindings list`
243
+ marks them `*`, `bindings status` says so, MCP output carries
244
+ `sample_phase_note`).
245
+ - `ports --all` lists the USB serial devices (`/dev/ttyUSB*`,
246
+ `/dev/ttyACM*`) no `/dev/lab` symlink points at; the flag did nothing.
247
+ - `remove`, `sdwire remove`, `serial remove`, `actuator remove` and
248
+ `power-all` ask only on a terminal. Without one they no longer read the
249
+ answer from stdin (a piped `y` used to confirm) and exit 1 asking for
250
+ `--yes`. Declining `actuator remove` now exits 1, like the others (it
251
+ printed "Aborted." and exited 0).
252
+ - `install-services.sh` and `update.sh` make the services' database
253
+ writable by the `labctl` group, so group members can write it from the
254
+ CLI without `sudo -u labctl`: a new config puts it in
255
+ `/var/lib/labctl/db` (setgid `2775`; the file, created empty if
256
+ missing, and any `-wal`/`-shm` are `664`; SQLite gives new `-wal`/`-shm`
257
+ files the database's mode). A database configured elsewhere is left
258
+ alone, with a note. `database_path` is compared with the directory
259
+ resolved (a symlinked `/var/lib/labctl` still matches; the database file
260
+ itself is never followed). The labctl home gets group search (`g+x`,
261
+ nothing else) when it belongs to the `labctl` group and lacks it; if
262
+ group members still can't traverse it, the scripts warn. Both scripts
263
+ run the same root-safe step,
264
+ `python -m labctl.core.admin_files shared-db` (new; also new: `create`),
265
+ and a problem there (a symlinked database file, a non-regular `-wal`, a
266
+ config that won't parse) is a warning: the scripts carry on, and
267
+ `update.sh` still restarts the services.
268
+ **Upgrading:** an existing install keeps its database where it is
269
+ (e.g. `/var/lib/labctl/labctl.db`). To share it with the group, as root
270
+ throughout (your account may not see into `/var/lib/labctl`): stop the
271
+ services, move `labctl.db` (and any `labctl.db-wal`/`-shm`) into
272
+ `/var/lib/labctl/db/`, check it arrived (`sudo ls -l
273
+ /var/lib/labctl/db/labctl.db`; if it isn't there, `update.sh` creates a
274
+ new, empty database), set `database_path: /var/lib/labctl/db/labctl.db`
275
+ in `/etc/labctl/config.yaml` and copy it to the service config, run
276
+ `update.sh` (or `install-services.sh`), and start the services. README
277
+ "Moving the database" has the commands.
278
+ - REST `POST /api/sbcs/<name>/power` returns 400, not 500, for a
279
+ non-string `action` or a JSON body that isn't an object.
280
+
281
+ ### Removed
282
+ - Config keys that were parsed but never acted on: `proxy.enabled`
283
+ (and `LABCTL_PROXY_ENABLED`), `proxy.log_retention_days`,
284
+ `proxy.idle_timeout` and `health.update_status_on_check` (use
285
+ `monitor --no-update`). A config that still sets one loads, with a
286
+ warning that it's ignored.
287
+
288
+ ### Security
289
+ - MCP `sdwire_to_host` fails closed (#41): it switches the card only when
290
+ the board's plug reports it off. It used to go ahead when the board had
291
+ no plug or its state couldn't be read; it now refuses, like
292
+ `sdwire_ls`/`cat`/`info`, unless `force=true`. CLI `labctl sdwire host`
293
+ does the same: it refuses a board with no plug or an unreadable or
294
+ unknown plug state unless `--force` (which, as before, skips the power
295
+ check), and so do CLI `sdwire ls`/`cat`/`info` (they refused only a
296
+ plug reporting ON). These now also read a card already on the host in
297
+ place, as the MCP read tools do, and take `--force` to skip the check.
298
+ One check (`labctl.sdwire.power_guard`) serves all of them. The MCP
299
+ read tools' refusal points at `sdwire_to_host(force=true)` for a board
300
+ you've made sure is off.
301
+ - A per-board cycle delay stored before `edit --cycle-delay` checked the
302
+ range (e.g. `1e9`, NaN, negative) is refused on every path before
303
+ anything is switched or written (#41): MCP/REST/web cycles, the reboot
304
+ after `sdwire flash`/`update`, `boot-test` and the recovery commands, CLI
305
+ and MCP. Only CLI `power cycle` checked it before; the others slept on
306
+ it. Clear it with `labctl edit <name> --cycle-delay -1`.
307
+ - `labctl serial remove` and `labctl serial udev --install` check claims
308
+ (#41). `serial remove` refuses an adapter that a board someone else has
309
+ claimed has a port on, by any path that reaches it (including a port
310
+ assigned by `/dev/ttyUSB0` behind the adapter's link, which isn't linked
311
+ to the registration). `serial udev --install` refuses when the new rules
312
+ would remove or re-point a `/dev/lab/<name>` link such a board uses
313
+ (every link, if the installed rules file can't be read). Both take
314
+ `--override-claim`. `serial remove` asks for confirmation only on a
315
+ terminal; without one it refuses unless `--yes` (see the no-terminal
316
+ entry above). MCP
317
+ `remove_serial_device` refuses an adapter a board someone else has
318
+ claimed has a port on, the same way.
319
+ - Writing a board's SD card fails closed when the board has no power plug
320
+ (#41): `sdwire flash`/`update` (CLI) and MCP `sdwire_update`/`flash_image`
321
+ used to switch the card silently; they now refuse unless `--no-plug-ok`
322
+ (MCP `no_plug_ok=true`), which says you've made sure the board is off.
323
+ The flag never skips the power-off of a board with a plug nor excuses a
324
+ failed one. The MCP plans show `will_do.power_off`.
325
+ - Under `sudo`, the CLI acts for the user who ran sudo (`SUDO_USER`): its
326
+ claim identity (`cli-<user>@<host>`), agent name and activity-log actor,
327
+ so e.g. `sudo labctl serial udev --install` doesn't turn your own claim
328
+ into someone else's.
329
+ - MCP confirmation tokens are bound to the plan they were issued with
330
+ (`will_do`): if it differs at confirmation (e.g. the board's stored cycle
331
+ delay changed), the call is refused and the caller asks for a new plan.
332
+ A claimed board's power-cycling tools return the claim refusal, not a
333
+ plan.
334
+ - REST and web-UI power cycles don't overlap: the guard and the 5 s rate
335
+ limit are per plug outlet, so a cycle of an outlet (by any board on it)
336
+ requested while another is running (it blocks for the whole delay) is
337
+ refused with `429`, and `on`/`off` of that outlet meanwhile with `409`.
338
+ The guard covers REST and the web UI in one server process only: power
339
+ operations from the CLI, MCP, `sdwire` reboots or `boot-test` aren't
340
+ seen by it (a lab-wide outlet lock is tracked in #38).
341
+ - `labctl edit --cycle-delay` (setting or clearing it) is claim-checked
342
+ against the boards sharing the board's hardware as well, since a stored
343
+ delay is the minimum of every board on the outlet. Other `edit` changes
344
+ still check the board alone.
345
+
346
+ ### Changed
347
+ - One cycle-delay rule on every surface (#41): the board's stored delay
348
+ (`labctl edit --cycle-delay`), or 3 s if none is set, is the default
349
+ and the minimum of every power cycle, not only CLI `power cycle` and
350
+ recovery: MCP `power_cycle` (whose `delay` now defaults to it), REST
351
+ `cycle` (likewise; the response adds `delay_seconds`), the web UI's
352
+ power-cycle button, the reboot after `sdwire flash`/`update` and every
353
+ `boot-test` run (CLI and MCP). A smaller requested delay is raised to
354
+ it, so MCP and REST no longer allow less than 3 s on a board without a
355
+ stored delay. Boards sharing a plug outlet are cycled together, so the
356
+ largest of their minimums applies (each board's stored delay, or 3 s
357
+ without one; an out-of-range one on any of them refuses), and the CLI
358
+ warning, MCP results and REST `delay_source` name the board it came
359
+ from. A negative requested delay is an error everywhere (CLI
360
+ `power cycle --delay` used to raise it to the minimum). The `--dry-run` plans of
361
+ `sdwire flash`/`update` and the MCP confirmations of `power_cycle`,
362
+ `enter_/exit_recovery`, `boot_test` and `sdwire_update` (with
363
+ `reboot=true`) show the delay and where it came from
364
+ (`will_do.cycle_delay_seconds`, `cycle_delay_source`).
365
+ - Generated udev rules (`labctl serial udev`, and the legacy
366
+ `scripts/generate-udev-rules.py`) make `/dev/lab/*` devices
367
+ `root:dialout 0660`. They were world-writable (`0666`), so any local
368
+ user could open a console or drive a relay. The labctl services are in
369
+ `dialout` and ser2net runs as root; a user who drives relays from the
370
+ CLI must now be in `dialout` (`labctl serial udev --install` says so
371
+ when the user isn't). Installed rules change on the next
372
+ `labctl serial udev --install --reload`.
373
+ - `install-services.sh` and `update.sh` make the service copy of the config
374
+ (`/var/lib/labctl/.config/labctl/config.yaml`, which holds the same API
375
+ keys and credentials as `/etc/labctl/config.yaml`) mode `640`; it was
376
+ created world-readable (`644`), so any local user could read the keys
377
+ wherever the labctl home is traversable by others (its mode comes from
378
+ `useradd`'s `HOME_MODE`, `0750` on current Ubuntu/Debian, or from an
379
+ admin: it is `2775` on the reference lab host).
380
+ - The install and update scripts (run as root) no longer follow symlinks
381
+ or hard links planted under `/var/lib/labctl` (writable by the `labctl`
382
+ service user) when they seed `/etc/labctl/config.yaml` from the service
383
+ config, create the service config, change its owner and mode, or create
384
+ and re-permission the `images`/`output` directories. These go through
385
+ `labctl.core.admin_files`: no symlink on any path component below the
386
+ (resolved) labctl home, single-link regular files only, descriptor-based
387
+ ownership changes, `O_EXCL` creation, byte-for-byte copies. Previously a
388
+ compromised service could have root hand it `/etc/labctl/config.yaml`,
389
+ or copy an arbitrary root-only file into a config its group can read.
390
+ Remaining by-path root operations there (`chown -R`, `mkdir -p`, the
391
+ drift `cmp` and the allowlist config read) are tracked in #19.
392
+ - **MCP over HTTP now authenticates with the web users' API keys (breaking
393
+ for HTTP clients when `auth.enabled` is true).** With auth enabled, every
394
+ request to `labctl mcp --http` needs `Authorization: Bearer <api_key>`
395
+ for a user in `auth.users` (401 otherwise); keys are re-read per request,
396
+ so revoking one needs no restart. Sessions are bound to the user that
397
+ opened them, destructive-tool confirm tokens to the user they were issued
398
+ to, and each user is a separate claimant (one user's claim blocks
399
+ another's mutating calls); claims and audit entries record the user. The
400
+ server refuses to start without auth on a non-loopback `--host` or with
401
+ non-loopback names in `mcp.allowed_hosts` / `allowed_origins` (a proxy
402
+ that forwards `Host: 127.0.0.1` can't be detected: enable auth before
403
+ adding one), with auth enabled but no API key configured, or when a
404
+ config file it should read can't be loaded (a fallback to another file
405
+ or to defaults could drop the authentication the intended config
406
+ enables; this includes `/etc/labctl/config.yaml` being unreadable to a
407
+ user outside the `labctl` group, who must then pass `-c`). A running
408
+ MCP server (either transport) now keeps the configuration it started
409
+ with until restarted; only API keys are re-read, from the same file, per
410
+ request (a broken file refuses every key). Users
411
+ without a string username can't authenticate by API key. New `mcp.allowed_hosts` / `mcp.allowed_origins` accept extra
412
+ `Host` / `Origin` headers (e.g. a reverse proxy's public name) on top of
413
+ the loopback names. See `docs/MCP_SERVER.md` (D012).
414
+ - API key lookups (web and MCP) no longer raise on a non-ASCII key in a
415
+ request header, and a non-string `api_key` in the config never matches.
416
+ - **Destructive MCP tools now require a two-step confirmation (breaking for
417
+ MCP clients).** The 23 tools annotated destructive (power off/cycle,
418
+ flashing, SD updates, `serial_send`, deletions, actuator/binding verbs,
419
+ recovery, force release) return a plan and a single-use `confirm_token`
420
+ on the first call and act only when called again with the same arguments
421
+ plus that token (120 s expiry, bound to tool and arguments). Configure
422
+ with `mcp.confirm_destructive` (default `true`) and `mcp.confirm_exempt`.
423
+ See `docs/MCP_SERVER.md` (D012).
424
+ - **MCP host file access is now allowlisted (breaking).** `flash_image`
425
+ (`image_path`, `post_flash_copies` sources), `sdwire_update` (`copies`
426
+ sources) and `boot_test` (`image`, `output_dir`) only accept absolute host
427
+ paths inside the new `mcp.allowed_read_paths` / `mcp.allowed_write_paths`
428
+ settings. Both default to empty, which denies all host file access over
429
+ MCP. Paths are resolved (symlinks, `..`) before the check and are refused
430
+ before any power or SD-mux change. Checked files and directories are
431
+ opened at check time without following symlinks (`labctl.core.safe_open`)
432
+ and all later I/O uses those handles, so a symlink swapped in after the
433
+ check can't redirect it; raw images are streamed into `sudo dd` instead
434
+ of being opened by root. Image format and copied file names follow the
435
+ path given. The CLI is unaffected. Fresh installs
436
+ get `/var/lib/labctl/images` (read) and `/var/lib/labctl/output` (write);
437
+ `scripts/update.sh` creates those directories on existing hosts and prints
438
+ the config to add (D012).
439
+ - Web UI: without a configured `auth.secret_key`, the session-signing key is
440
+ now random per process. It used to fall back to the constant
441
+ `labctl-dev-key` when auth was disabled; because the signed session
442
+ carries the CSRF token, anyone could forge it. Browser sessions now reset
443
+ when the web service restarts unless a key is configured.
444
+ - MCP `sdwire_ls` / `sdwire_cat` / `sdwire_info` no longer have side effects.
445
+ A card already switched to the host is read in place and left there
446
+ (host mode is confirmed with a one-sector `sudo dd` read, because the
447
+ reader's reported size lags a switch in either direction); a
448
+ card on the DUT side is switched to the host and back only when the
449
+ board's power plug reports OFF. With no power plug, an unreadable plug, or
450
+ an unknown state they now refuse (`power_unknown`) instead of switching
451
+ the card away from a possibly running board. They are annotated read-only.
452
+ Callers that relied on reading a plug-less SBC's card must switch it
453
+ explicitly with `sdwire_to_host` first.
454
+ - Read-only SD card mounts (`sdwire_ls`/`sdwire_cat` and the CLI
455
+ equivalents) no longer replay a dirty filesystem journal, which wrote to
456
+ the card despite `-o ro`: ext2/3/4 mount with `noload`, XFS/F2FS with
457
+ `norecovery`, btrfs with `rescue=nologreplay`.
458
+ - MCP `sdwire_to_host` now waits (up to 10 s) until the card is readable on
459
+ the host before returning, and warns if it isn't, so an immediate
460
+ follow-up read doesn't misjudge the mux position.
461
+
462
+ ### Changed (documentation audit)
463
+ - README, CLI help, MCP tool descriptions and docs were audited against
464
+ the code and corrected. Users will notice:
465
+ - README examples that failed as written: `labctl power on <sbc>` (not
466
+ `power <sbc> on`), `port assign` with its port type, `monitor` (no
467
+ `--foreground`), `-r` on `claim`/`force-release`/`request-release`.
468
+ The REST table lists the real routes, with the power endpoint's
469
+ `delay`, `override_claim` and status codes (400/409 `switched:
470
+ false`/429/500).
471
+ - Install instructions use the extras from a checkout (`pip install -e
472
+ ".[web,mcp,kasa,sdwire,actuators]"`): the package isn't on PyPI yet.
473
+ The README gives the Python requirements, the config search order
474
+ (a personal `~/.config/labctl/config.yaml` wins over `/etc`), and the
475
+ `labctl serial udev` flow in place of `install-udev.sh`. The SDWire
476
+ sudoers rule adds `parted` and `blkid` and gets its own file.
477
+ - New README sections on relays, bindings and recovery (`pre_power`)
478
+ and on the common flags (`--yes`, `--override-claim`, `--dry-run`).
479
+ - `docs/MCP_SERVER.md` documents the actuator, binding and recovery
480
+ tools; the prompt names are `debug_sbc` and `lab_report`; 24 tools
481
+ need confirmation; every image-upload error is listed.
482
+ - CLI help and MCP tool descriptions say what commands actually do,
483
+ e.g. `sdwire flash`/`update` power the board off and leave it off
484
+ without a reboot; `power on`/`cycle` set the `pre_power` relays of
485
+ boards sharing the outlet too; MCP/REST cycles don't use the board's
486
+ stored cycle delay; `actuator set` points at `labctl bindings
487
+ actuate`; `bind --phase post-power` is stored but not acted on yet.
488
+ - `SECURITY.md` lists the controls and states the web UI's default
489
+ exposure: the shipped unit listens on `0.0.0.0` over HTTP, auth is
490
+ off by default, and CSRF applies only with auth.
491
+ - `config/labctl.yaml.example` lists the environment variables that
492
+ actually override settings, and the `claims`, `proxy`, `health` and
493
+ `database` keys and `mcp.allow_admin_actuator_ops` with their
494
+ defaults.
495
+ - `docs/OPERATIONS.md`: `run_health_check` is `shared-resource` (it
496
+ writes nothing); claim-unchecked commands and aliases are complete.
497
+ - `docs/HARDWARE_MAP.md` is a generic example; `SPEC_actuators.md`,
498
+ `STATUS.md`, `CLAUDE.md` (schema v11) and `IMPLEMENTATION.md` match
499
+ what was built; the `deploy-and-test` skill covers confirmation,
500
+ claims and image upload.
501
+
502
+ ### Fixed
503
+ - The SD card is no longer switched to the host while the board may be
504
+ running (bus contention can corrupt the card). `boot-test` with an image
505
+ (CLI and MCP) didn't power the board off at all; `sdwire flash` and
506
+ `sdwire update` (CLI and MCP) powered it off on a best-effort basis and
507
+ carried on when that failed. All of them now refuse, writing nothing,
508
+ when the board can't be switched off. A `boot-test` deploy that fails
509
+ once the board is off hands the card back to the board and says the
510
+ board was left off (it used to be a traceback for errors other than
511
+ RuntimeError).
512
+ - `mcp.allow_admin_actuator_ops` turns on only for a YAML boolean `true`.
513
+ It was read with `bool()`, so a quoted `"false"` (or any other string)
514
+ enabled `actuator_add`/`actuator_remove`/`actuator_set` over MCP; any
515
+ non-boolean value now leaves them off, with a warning.
516
+ - `labctl edit --cycle-delay` refuses a delay over 86400 seconds, NaN or
517
+ infinity (nothing is written; `-inf` no longer counts as "clear"). The
518
+ stored delay is the default and the floor of `power cycle`, so such a
519
+ value made every later cycle of the board fail, with no `--delay` able
520
+ to get round it; `power cycle` now says how to clear one stored
521
+ earlier.
522
+
523
+ - `pre_power` relay bindings (e.g. a `recovery_mode` strap) are now set
524
+ before power-on on every path, not just CLI `power on`/`power cycle` and
525
+ the recovery composites (#36). The MCP `power_on`/`power_cycle`/
526
+ `boot_test` tools and the reboot after `sdwire_update`/`flash_image`,
527
+ the web UI and REST power actions, and CLI `power-all on`, `boot-test`
528
+ and the reboot after `sdwire flash`/`update` used to switch power and
529
+ leave the strap however it was last set, so a board could boot in or out
530
+ of recovery depending on which interface turned it on. Boards that share
531
+ the plug outlet have their straps set too, since the outlet powers them
532
+ all (this also applies to `enter-recovery`/`exit-recovery`). A relay
533
+ that can't be opened, or whose channel is busy with another operation,
534
+ now stops these before power is switched (cycles check before powering
535
+ off; a flash or file update with a reboot checks before it powers the
536
+ board off and writes the card): MCP and the CLI say so in one line, the
537
+ REST API answers 409 with `"switched": false` when nothing was switched
538
+ (a failure after power-off stays a 500), the web UI flashes it, after a
539
+ flash or file update the tools report that the power cycle failed and
540
+ why, and `boot-test` records the run as failed. A strap that fails after
541
+ power-off leaves the board off, and the error says so. A power-cycle
542
+ delay over 86400 seconds, or not a number, is now refused before
543
+ power-off: by CLI `power cycle` (also with `--dry-run`, and when the
544
+ board's stored delay is out of range; a delay below the board's minimum
545
+ is still raised to it), and, with negative delays too, by MCP
546
+ `power_cycle` and by the REST API with a 400. It used to switch the
547
+ board off, then fail.
548
+ - A relay that labctl can't open no longer crashes `power on`/`power
549
+ cycle` (and the binding verbs and recovery composites) with a
550
+ traceback; nor does an actuator whose driver isn't implemented. The
551
+ error is one `Error:` line, and the board is never powered on without
552
+ its strap. `power cycle`, `enter-recovery` and `exit-recovery` now
553
+ check every relay their pre-power bindings need before powering off,
554
+ so a relay they can't open no longer leaves the board off. A
555
+ permission error says what to do: join the group that owns the device
556
+ (only when that group grants read/write and isn't root's), then start
557
+ a new login and restart any service or MCP client running labctl. `labctl actuator set` and the MCP
558
+ `actuator_set` tool report the same error instead of raising.
559
+ - Over stdio, the MCP `list_images` tool said only that uploads need the
560
+ HTTP transport, which read as a dead end. It now lists what a stdio
561
+ session (which runs on the lab host) can do instead, with this server's
562
+ paths: pass an absolute path inside `mcp.allowed_read_paths`, copy the
563
+ image into `mcp.upload_dir` (under a unique temp name, then renamed into place;
564
+ it says whether the session can write there) and use its bare name, or
565
+ upload to the host's HTTP service. The last two are offered only when
566
+ bare names resolve (`upload_dir` inside `allowed_read_paths`). With
567
+ uploads off or `upload_dir` unreadable, a stdio session is still pointed
568
+ at host paths, and the server instructions no longer say files must be
569
+ uploaded over HTTP.
570
+ - Kasa strips that only answer KLAP discovery (e.g. HS300 firmware 1.1.2,
571
+ which closed the legacy port 9999) are controlled per outlet again.
572
+ python-kasa types such a device by its family (`IOT.SMARTPLUGSWITCH`)
573
+ as a single plug with no outlets: state reads failed with
574
+ `KeyError: 'relay_state'`, and an on/off for any outlet was sent to the
575
+ strip as a whole, not to that outlet. labctl now reads the sysinfo and
576
+ lets python-kasa pick the class from it, as python-kasa's own `connect`
577
+ does, rebuilding the device on the same connection when discovery
578
+ picked another. A Kasa device that reports no outlets now refuses an
579
+ outlet index above 1 instead of switching the whole device.
580
+ - Kasa power control works again with IOT devices whose firmware moved to
581
+ KLAP login version 2 (e.g. HS300 hardware 2.0, firmware 1.1.2): every
582
+ command failed with `AuthenticationError: Device response did not match
583
+ our challenge` even with correct credentials, because python-kasa before
584
+ 0.12 always used KLAP v1 for IOT devices. The `kasa` extra now requires
585
+ `python-kasa>=0.12` on Python 3.11+. python-kasa 0.8 and later need
586
+ Python 3.11, so on 3.10 it pins 0.7.7 (it allowed 0.5, which lacks
587
+ APIs labctl uses) and those devices still can't be controlled there.
588
+ When an authentication error persists with credentials configured and
589
+ python-kasa < 0.12, the error message now says so and names the upgrade.
590
+ - `labctl mcp --http` waits up to 15 s for a port that is in use before
591
+ starting (seen once on a deploy: the port was still in use ~1 s after
592
+ the old server had exited, cause unknown; the new server's start failed
593
+ and systemd restarted it 10 s later). It probes the addresses uvicorn
594
+ will bind, then lets uvicorn bind as before. `update.sh` also waits,
595
+ for every service, while a unit is `deactivating`, `activating` (as
596
+ while systemd restarts a failed start) or `reloading`, within
597
+ `MCP_START_TIMEOUT` (now measured in seconds), and says when systemd
598
+ had to restart one; it had reported `labctl-mcp` as failed.
599
+ - The test suite no longer loads the machine's real config. About 115
600
+ tests loaded config without naming a file, so on a lab host whose user
601
+ can read `/etc/labctl` they used the production config and database; a
602
+ run during the image-upload work migrated a production database to a
603
+ pre-release schema v10 (an `images` table without `file_id`, so uploads
604
+ failed with 500). Schema v11 adds the missing column to such a
605
+ database, so a backup taken in between can be restored safely. Every test, and every process it starts, now gets its own home
606
+ and no system config. New `LABCTL_SYSTEM_CONFIG` environment variable:
607
+ replaces `/etc/labctl/config.yaml` in the config search order.
608
+ - REST `DELETE /api/sbcs/<name>` on a claimed board answered `500`; it is
609
+ now `409 "sbc_claimed"` (#21). A non-numeric `duration_minutes` on
610
+ claim or renew answered `500`, now `400`; renew durations are now
611
+ bounded like claims. The web plug form refuses an outlet index below 1
612
+ (index 0 switches every outlet on a Tasmota device).
613
+ - REST and web claims used a session ID the next request often didn't
614
+ share (a per-request ID without auth, an API-key prefix with it), so a
615
+ claim made over REST could not always be renewed or released; they now
616
+ use `web:<user>` (`web:anonymous` without auth) and record the user as
617
+ owner. Claims made before the upgrade keep their old ID: release them
618
+ with force-release if needed.
619
+ - A command given a config file by name (`-c FILE` or `LABCTL_CONFIG`)
620
+ fails if it doesn't load (YAML error or invalid value) instead of
621
+ running on built-in defaults (commands that don't read the config,
622
+ e.g. `user generate-key` / `hash-password` / `add`, `completion` and
623
+ `--help`, still run). `labctl mcp` handles this itself: over
624
+ HTTP it refuses to start, over stdio it starts with a warning.
625
+ `labctl web` refuses to start if any config file it reads doesn't load
626
+ (e.g. `/etc/labctl/config.yaml` unreadable to a user outside the
627
+ `labctl` group: pass `-c`), since it would otherwise serve without the
628
+ login the intended config enables.
629
+ - A config value that is valid YAML but not a valid setting (e.g.
630
+ `timeout_seconds: 10s`, or a list at the top level) is a load error
631
+ (warning; the next file in the search order, or built-in defaults)
632
+ instead of a crash.
633
+ - An MCP `boot_test` `output_dir` that is an existing file is reported as
634
+ "not a directory" instead of a possible race.
635
+ - A config section header with nothing under it (e.g. `mcp:` followed only
636
+ by commented-out entries) no longer crashes every labctl command; it
637
+ counts as an empty section. A section that isn't a mapping is ignored
638
+ with a warning.
639
+ - Boot-test per-run output files (`run_NN.txt`) are written with
640
+ `O_NOFOLLOW` and refused if hard-linked, so a link planted in a shared
641
+ output directory can't redirect the (DUT-controlled) output into another
642
+ file. The default `/var/lib/labctl/output` is now also sticky (`3775`).
643
+ A run file that can't be written no longer fails the whole boot test;
644
+ the summary (also the MCP result) lists every file that wasn't saved.
645
+ - `labctl -c FILE mcp` now makes the MCP server use FILE. The server loaded
646
+ its config itself and ignored `-c`, so it read
647
+ `~/.config/labctl/config.yaml` first (for the systemd unit: the service
648
+ user's config, not the `/etc/labctl/config.yaml` the unit names). This
649
+ matters now that the MCP host-file allowlist lives in config. FILE is
650
+ exclusive, as with any explicit path: never another config. The same
651
+ exclusive mode is available as `LABCTL_CONFIG_EXCLUSIVE=1` alongside
652
+ `LABCTL_CONFIG`. (How a running server treats later changes to FILE:
653
+ see Security, MCP over HTTP.)
654
+ - MCP claim sweeper (issue #3): a crashed `mcp-stdio` session whose PID was
655
+ reused by another process is now recognised as gone (the process start
656
+ time is compared with the session's epoch), so its claim is released as
657
+ `session-lost` rather than later as `expired`.
658
+
659
+ ### Changed
660
+ - Kasa: driver errors (bad outlet, unreachable plug) inside the MCP server
661
+ are reported as such, not as an unrelated asyncio error.
662
+ - Power plugs: `labctl plug assign --index` and MCP `assign_power_plug`
663
+ refuse an outlet index below 1 (Tasmota's `Power0` switches every
664
+ relay). Rows saved with such an index (earlier, or by `labctl import`)
665
+ count as sharing every outlet on that address for claim checks.
666
+ - MCP `serial_capture` is claim-checked like `serial_send`: it holds the
667
+ board's ser2net session.
668
+ - HTTP MCP sessions are labelled `mcp-http` (claims' `session_kind` and
669
+ activity-log actors), not `mcp-stdio`; the dead-session sweep covers both.
670
+ - **Package renamed for PyPI**: the distribution is now
671
+ `embedded-lab-control` (`pip install embedded-lab-control`); the import
672
+ package and CLI remain `labctl`. The version is single-sourced from
673
+ `labctl.__version__` (also used by `/api/health` and the web footer, which
674
+ previously hard-coded `0.1.0`). License metadata is now the SPDX
675
+ expression `MIT`, and the LICENSE file ships in the wheel and sdist.
676
+ - **MCP server migrated to the mcp 2.x SDK** (`mcp>=2.2,<3`, `MCPServer`
677
+ replaces `FastMCP`). `mcp>=1.0` had started resolving to 2.2, where
678
+ `mcp.server.fastmcp` no longer exists and `labctl mcp` crashed on import.
679
+ Tools, resources and prompts are unchanged. mcp 2.x runs sync tools and
680
+ resources on worker threads; labctl keeps 1.x semantics by serializing tool
681
+ calls and the hardware-touching resources (`lab://power/{sbc}`,
682
+ `lab://health/{sbc}`, `lab://status`) under one lock (D011). The server
683
+ now reports labctl's version in `serverInfo`.
684
+ - `scripts/update.sh` and `scripts/install-services.sh` uninstall the old
685
+ `labctl` distribution before installing `embedded-lab-control`, so the
686
+ first upgrade doesn't leave two distributions owning `labctl/` (a later
687
+ `pip uninstall labctl` would otherwise delete the new install).
688
+ - The `sdwire` extra only installs on Python ≥ 3.12 (PyPI `sdwire` requires
689
+ it). On 3.10/3.11 SDWire commands report that the package is missing.
690
+ - Development: `src/` and `tests/` are now clean under `black`, `isort` and
691
+ `flake8`, which are pinned exactly in the `dev` extra
692
+ (`black==26.5.1`, `isort==9.0.1`, `flake8==7.4.1`). Formatting-only apart
693
+ from removing dead code flagged by flake8 (unused imports/variables).
694
+
695
+ ### Added
696
+ - MCP `serial_send` takes `keys` (named keys: ESC, Enter, Tab, Space,
697
+ Backspace (0x08), arrows, Home/End/Insert/Delete/PgUp/PgDn, F1-F12,
698
+ Ctrl-A..Ctrl-Z, in xterm encodings; each key is written 50 ms after
699
+ the previous one, for consoles that time out a lone ESC quickly) and
700
+ `data_hex` (raw bytes, e.g. for firmware whose terminal type wants
701
+ other function-key codes, or 0x7F to erase at a Linux prompt), so
702
+ agents can drive UEFI/U-Boot menus: a raw ESC in `data` is dropped
703
+ before it reaches the server (#33). `newline` defaults to off when
704
+ either is given. Its confirmation now shows the exact bytes it will
705
+ send (`will_do`), and an unknown key or bad hex is refused before a
706
+ token is issued. F11 has been checked on a Jetson's UEFI; the other
707
+ encodings have not been checked on real firmware yet.
708
+ - **Image uploads over MCP HTTP** (D014): `PUT /images/<name>` on the MCP
709
+ HTTP port, with the same API key, stores a file in the new
710
+ `mcp.upload_dir` (default empty: off); `flash_image`, `sdwire_update`
711
+ copies and `boot_test` take an uploaded image's bare name. New tools
712
+ `list_images` (with upload instructions) and `delete_image`. Limits:
713
+ bare names, `mcp.max_upload_mb` (8192), 1 GiB kept free, optional
714
+ `?sha256=` check, atomic replace with `?overwrite=1`. Only the uploader
715
+ or an operator may replace or delete an image; uploads and deletions are
716
+ in the activity log. Schema v10 (`images` table).
717
+ - **TLS for the MCP HTTP server**: `labctl mcp --http PORT --cert FILE
718
+ --key FILE`, or `mcp.cert_file`/`mcp.key_file`. A non-loopback bind
719
+ without TLS logs a warning. `labctl mcp --check` prints the `tls:` and
720
+ `uploads:` settings.
721
+ - Claim enforcement, part 3 (D013): the web UI and REST API refuse to
722
+ operate or reconfigure a board another user has claimed (REST: `409`
723
+ `"sbc_claimed"`; web UI: a page naming the holder), including boards
724
+ sharing its hardware and boards already wired to a port or plug being
725
+ assigned. Operators may override (`"override_claim": true`, or the
726
+ button on the refusal page), recorded as `claim_override`; agents
727
+ (`role: agent`) get `403 "not_operator"`, also for force-release (which
728
+ any REST or web user could do before). `claims.require_claim` applies
729
+ to the REST API. REST claims record the user as owner, so one user is
730
+ one claimant across REST, the web UI and MCP over HTTP. See
731
+ docs/OPERATIONS.md.
732
+ - Claim enforcement, part 2 (D013): CLI commands that operate or
733
+ reconfigure a board refuse when another user has claimed it (or a board
734
+ sharing its plug outlet, SDWire, console adapter or relay channel), naming
735
+ the holder, before any confirmation question. `--override-claim` acts
736
+ anyway and is recorded in the activity log as `claim_override`;
737
+ `power-all` skips claimed boards; `import` checks every board before
738
+ changing any; `claim` refuses a board whose shared hardware someone else
739
+ holds. `remove` releases the user's own CLI claim before deleting, and
740
+ `remove --force` is now an alias for `--override-claim` (also logged).
741
+ See docs/OPERATIONS.md for the list. Assigning a serial port a TCP port
742
+ another board's port already uses is now refused (CLI, MCP, web): the
743
+ two would share one ser2net console, reaching a claimed board's console
744
+ through another board.
745
+ - Claim enforcement, part 1 (D013): MCP tools that change a board's
746
+ configuration (`update_sbc`, `assign_*`/`remove_*` for ports, plugs and
747
+ addresses, `sdwire_assign`/`unassign`, `bind`/`unbind`) now refuse under
748
+ another user's claim, as do `actuator_set`/`actuator_remove` on channels
749
+ bound to a claimed board. A claim also covers the board's hardware:
750
+ operating a board that shares a plug outlet, SDWire, serial device or
751
+ relay channel with a board someone else claimed is refused, as are
752
+ claiming it and assigning such hardware to another board.
753
+ - `auth.users[].role`: `operator` (default) or `agent`. Only operators may
754
+ `force_release_sbc`; agents get `not_operator`.
755
+ - `claims.require_claim` (default `false`): MCP calls that change a board
756
+ refuse with `claim_required` unless the caller has claimed it.
757
+ - Claims record their authenticated owner (schema v9): the same user's
758
+ other MCP sessions, and the same user after a server restart, count as
759
+ the claimant (use, renew, release). Claiming a board you already hold
760
+ returns `already_claimed` and renews it for the requested duration
761
+ (boards sharing its hardware aren't re-checked).
762
+ - `docs/CLAUDE_CODE_AGENTS.md`: connecting Claude Code agents to the HTTP
763
+ MCP server with one labctl user and API key each, so claims keep them
764
+ apart. Covers `claude mcp add` (manual) and a shared `--mcp-config` file
765
+ with the key taken from `LABCTL_API_KEY` at launch (streamlined), agent
766
+ instructions, identity checks and key revocation.
767
+ - `SECURITY.md`: how to report vulnerabilities privately, and what's in
768
+ scope. `docs/MCP_SERVER.md` opens its security section with an overview
769
+ of every control (defaults, settings), known limits and a deployment
770
+ checklist; the README links them.
771
+ - `labctl mcp --http PORT --check` runs the HTTP startup checks (the same
772
+ code the server runs) without serving: whether clients need API keys
773
+ (`auth: required` / `none`), the config file loaded, and startup
774
+ warnings; exit 1 if the server would refuse to start or a config file it
775
+ would read is unparseable, unreadable, or (when named by
776
+ `LABCTL_CONFIG`) missing. A `-c FILE` that is missing or unreadable is
777
+ rejected by the CLI itself (exit status 2).
778
+ - `scripts/update.sh`, after restarting, shows `labctl-mcp`'s journal from
779
+ this restart (auth mode, startup warnings) and, whenever auth is
780
+ enabled, reminds that HTTP clients must send their API key; for any
781
+ service that failed to start it prints that service's log since the
782
+ restart. Before installing anything it refuses a config that doesn't
783
+ load (YAML error or an invalid value). The MCP server prints its auth
784
+ mode on every HTTP start and
785
+ logs startup warnings (short keys, unknown confirm exemptions, unchecked
786
+ Host headers; over stdio also config files it couldn't load) whatever
787
+ `log_level` says; over HTTP an unloadable config makes it refuse to
788
+ start instead (see Security).
789
+ - Destructive CLI commands (19: `power off`/`cycle`, `sdwire flash`/`update`/
790
+ `host`/`unassign`, `serial send`, `actuator set`, binding verbs, recovery,
791
+ `force-release`, `boot-test`, record removals) take `--yes/-y` and ask for
792
+ confirmation, but only when stdin is a terminal: cron, CI, systemd, pipes
793
+ and agents' tool calls are not asked. Scripts run from an interactive
794
+ shell inherit the terminal and will be asked: add `--yes` to them. `power cycle`, `sdwire flash` and `sdwire update` take
795
+ `--dry-run` (validate and print the plan; change nothing).
796
+ - `docs/OPERATIONS.md`: every CLI command, MCP tool and MCP resource
797
+ classified as read / db-write / shared-resource / system-write /
798
+ hardware / destructive (issue #7). A test keeps it in sync with the code.
799
+ - MCP tool annotations (`title`, `readOnlyHint`, `destructiveHint`,
800
+ `idempotentHint`, `openWorldHint`) on all 52 tools, defined in one table in
801
+ `mcp_server.py`. A unit test fails if a tool is added without an entry.
802
+ - `labctl mcp --host ADDR` (default `127.0.0.1`) for the HTTP transport.
803
+ - `labctl mcp` without the `mcp` extra now prints an install hint instead of
804
+ a traceback.
805
+ - GitHub Actions CI (`.github/workflows/ci.yml`) on push and pull request:
806
+ Python 3.10–3.13 matrix running flake8, black, isort and pytest with
807
+ coverage (XML uploaded as an artifact), plus a gitleaks scan of the full
808
+ git history. Reviewed gitleaks false positives are listed in
809
+ `.gitleaksignore`.
810
+ - `labctl serial send` send pacing to avoid receiver UART RX-FIFO overrun
811
+ on sustained sends (issue #8). A gapless burst can leave a receiver no
812
+ window to drain its RX FIFO, dropping a contiguous mid-payload byte run.
813
+ Two new mutually-exclusive knobs feed the payload to ser2net in delayed
814
+ slices within a single TCP connection:
815
+ - `--interbyte-delay-ms N` — one byte at a time, N ms between bytes.
816
+ - `--chunk-size B --chunk-delay-ms N` — B bytes per slice, N ms between.
817
+ Pacing applies to both send-only and `--capture` paths. Hardware/software
818
+ flow control (RTS/CTS, XON/XOFF) is tracked separately in issue #9.
819
+
820
+ ### Fixed
821
+ - `labctl mcp --http PORT` ignored PORT and always bound `127.0.0.1:8000`.
822
+ It now binds `127.0.0.1:PORT` (or `--host`). **Deployment note:** the
823
+ shipped systemd unit uses `--http 8080`, so a deployed server moves from
824
+ `:8000` to `:8080`. With a loopback bind, the SDK's DNS-rebinding guard
825
+ rejects non-localhost `Host` headers (HTTP 421).
826
+ - `labctl.sdwire` failed to import on Python 3.10 (`datetime.UTC` is
827
+ 3.11+), despite `requires-python >= 3.10`.
828
+ - Hotfix: per-connection SQLite pragmas (`busy_timeout`, `synchronous=NORMAL`,
829
+ `foreign_keys=ON`) are now best-effort. After the WAL switch, callers
830
+ without OS-level write permission on the DB file (e.g. users outside
831
+ the `labctl` group running `labctl serial send`) hit
832
+ `sqlite3.OperationalError: attempt to write a readonly database`
833
+ at connection open, because SQLite auto-downgrades the connection to
834
+ read-only and then rejects write-side pragmas. We now wrap each
835
+ pragma in `try/except sqlite3.OperationalError` and log at debug.
836
+ Writable connections still get full tuning; read-only callers keep
837
+ working. Regression test added in `tests/unit/test_database.py`.
838
+ - Transient `sqlite3.OperationalError: database is locked` errors under
839
+ multi-process load (monitor daemon + CLI + MCP + batch jobs hitting
840
+ the same SQLite file). Root cause was the combination of default
841
+ rollback-journal mode (readers and writers exclude each other across
842
+ the whole DB), the default 5s `sqlite3.connect()` busy timeout, and
843
+ `Database.initialize()` re-running its `sqlite_master` probe on every
844
+ `get_database()` call. Fix:
845
+ - `PRAGMA journal_mode=WAL` applied on first `initialize()` (persists
846
+ in the DB file header — concurrent processes inherit it). Readers
847
+ no longer block writers and vice versa; only writer-vs-writer is
848
+ serialized, and labctl writes commit sub-millisecond.
849
+ - `PRAGMA synchronous=NORMAL` (WAL-safe pairing, faster commits, a
850
+ few-second worst-case write loss on power failure — fine for a lab
851
+ DB).
852
+ - Per-connection `busy_timeout` mirrors `Config.database.timeout_seconds`
853
+ (new; default **10s**), and the same value is passed as
854
+ `sqlite3.connect(timeout=…)`. Backstops the narrow windows WAL
855
+ still serializes (schema migrations, WAL checkpoints).
856
+ - `initialize()` is now cached per-process: subsequent calls on the
857
+ same `Database` instance return immediately without re-opening a
858
+ connection. Long-lived processes (daemon, MCP, web) pay the
859
+ sqlite_master probe exactly once; short-lived CLI invocations still
860
+ pay one initialize() per run.
861
+ - New `database` config section (`database.timeout_seconds`, default
862
+ 10.0) exposed via `Config.database`. Wired through `get_database` /
863
+ `get_manager` and the four call sites (CLI, web app, MCP server).
864
+ - Note: WAL mode creates `-wal` and `-shm` sidecar files next to the
865
+ DB file. Backup scripts that previously copied just `labctl.db` now
866
+ need to include those sidecars (or quiesce the daemon and run
867
+ `PRAGMA wal_checkpoint(TRUNCATE)` before the backup).
868
+ - `LABCTL_DATABASE_TIMEOUT` env var overrides
869
+ `database.timeout_seconds` at load time, parallel to the other
870
+ `LABCTL_*` overrides. Useful for ad-hoc tuning under contention
871
+ without editing config.yaml.
872
+ - `INSERT OR IGNORE` on the `schema_version` bootstrap inserts
873
+ eliminates a latent cross-process race where two fresh-DB
874
+ initializers could both pass the `sqlite_master` probe and the
875
+ losing one would fail with a PRIMARY KEY conflict. Independent
876
+ of the lock fix but the WAL change made it slightly more likely
877
+ by shrinking the serialization window.
878
+ - `Database._init_lock` is a `threading.RLock` (not `Lock`) so a
879
+ callback into `initialize()` while the lock is held does not
880
+ self-deadlock. Defensive — nothing in the current codebase
881
+ triggers re-entry.
882
+
883
+ ### Added (actuators feature, P1–P7, PR #4)
884
+
885
+ First-class support for USB-controlled relays as provisioned lab
886
+ resources, with per-target purpose **bindings** (recovery_mode,
887
+ boot_select, power_button, …) so verbs are semantic
888
+ (`labctl enter-recovery jetson-nano-2`) rather than device-specific.
889
+ Built per `docs/SPEC_actuators.md`. Since #37, `pre_power` straps are
890
+ set on every power-on path (see Fixed above).
891
+
892
+ - **Schema v8** — `actuators`, `actuator_channels`, `bindings` tables
893
+ with CHECK constraints on enum columns, UNIQUE on
894
+ `actuator_channel_id` (one binding per channel), UNIQUE on
895
+ `(sbc_id, purpose)` (one purpose per SBC). Migration v7→v8.
896
+ - **Driver layer** (`labctl.actuators`):
897
+ - `RelayDriver` ABC with structured `WriteOutcome` /
898
+ `ProbeResult` so composites can abort safely on USB drop mid-sequence.
899
+ - `lcus1_serial` — CH340-based 4-byte ASCII protocol at 9600/8N1,
900
+ write-only, software-tracked state, side-effect-free probe.
901
+ - `MockRelayDriver` for tests with programmable failure modes.
902
+ - `get_driver()` registry; future drivers raise NotImplementedError.
903
+ - **Runtime layer** (`labctl.actuators.runtime`):
904
+ - Per-channel non-blocking locks ⇒ ChannelBusyError for concurrent
905
+ verbs; verb/shape congruence enforced (BindingShapeError).
906
+ - `actuate_binding` / `release_binding` / `press_binding` —
907
+ desired_state persisted only on WriteOutcome.OK.
908
+ - `apply_pre_power_bindings` — re-applies pre_power straps before
909
+ `power_on` / `power_cycle` so recovery_mode survives power cycles.
910
+ - `enter_recovery` / `exit_recovery` — pre-flight probe + power_off
911
+ + sleep + (de)assert + power_on, with explicit abort if the
912
+ actuator is unreachable BEFORE any power transition.
913
+ - `apply_safe_drive_on_startup` — daemon-start reconciliation that
914
+ leaves `desired_state=asserted` channels alone (recovery
915
+ mid-flash survives a daemon restart) and drives everything else
916
+ to `default_state`.
917
+ - **CLI**:
918
+ - `labctl actuator add/list/probe/remove/set`
919
+ - `labctl bind`, `labctl unbind`, `labctl bindings list`
920
+ - `labctl bindings actuate / release / press / status`
921
+ - `labctl enter-recovery / exit-recovery`
922
+ - `power on` / `power cycle` now consult pre_power bindings.
923
+ - **MCP**: complete tool set — provisioning, binding management,
924
+ operation verbs (claim-gated), and recovery composites.
925
+ - **SBC claim composition**: `claim_sbc` snapshot-checks per-channel
926
+ locks; refuses with ChannelBusyError if a verb is mid-flight.
927
+ - **Wiring-polarity validation** at bind time: refuses
928
+ `default_state == active`.
929
+ - **`pyproject.toml`**: new optional `actuators` extra pulling
930
+ `pyserial>=3.5`.
931
+
932
+ Tests: **897/897 pass.** ~140 new tests across schema migration,
933
+ manager CRUD, driver ABC contract, LCUS-1 frame builder + I/O,
934
+ runtime verbs, composite power integration, daemon-start safe-drive,
935
+ claim composition, CLI surface, and MCP tools.
936
+
937
+ #### Post-review hardening (PR #4)
938
+
939
+ - Hardware writes from automatic power-flow paths now leave audit
940
+ trails alongside operator-initiated verbs: `apply_pre_power_bindings`
941
+ emits `pre_power_apply` events (one per strap driven); daemon-start
942
+ `apply_safe_drive_on_startup` emits one synthetic `safe_drive`
943
+ summary event with held/drove/failed counts and details.
944
+ - `_validate_actuator_device_path` now rejects path traversal:
945
+ refuses any input containing `..` outright, then normalizes
946
+ remaining paths with `os.path.normpath` before the allow-list
947
+ check. Previously `/dev/lab/../etc/passwd` slipped through.
948
+ - `_audit_actuator_event` / `_audit_raw_actuator_set` / new
949
+ `_audit_safe_drive` wrap their emit call in try/except so a
950
+ bookkeeping failure can't fail a verb that physically succeeded.
951
+ - `_admin_actuator_ops_allowed` now logs at debug on config-load
952
+ errors so silent gate-disables are diagnosable.
953
+ - `pre_power_apply` audit details now include `cycle_changed` so a
954
+ paired `actuate` → `pre_power_apply` row pair (the normal
955
+ `enter_recovery` shape) reads as "operator drove the strap, then
956
+ the power-flow rechecked it" rather than as a double-toggle.
957
+
958
+ ### Fixed
959
+ - Kasa multi-outlet strips no longer hit themselves with parallel KLAP
960
+ handshakes during monitor cycles. Previously each outlet of an N-outlet
961
+ strip opened its own KLAP session in parallel, so the strip
962
+ rate-limited bursts of N concurrent auth attempts as
963
+ `AuthenticationError`. Symptom in production logs: every monitor cycle
964
+ produced a wave of "challenge mismatch" errors against `192.168.4.140`
965
+ (a 6-outlet HS300) followed by an exhausted-retry ERROR per outlet,
966
+ then a fresh wave 60 s later. Retries never helped because the cause
967
+ was contention, not transience. Fix: a process-wide per-host state
968
+ cache (5 s TTL) with a per-host serialization lock. The first caller
969
+ in a cycle pays one KLAP handshake and populates is-on for every
970
+ outlet; concurrent callers on sibling outlets read from the cache.
971
+ Writes invalidate so the next read reflects the change.
972
+
973
+ ### Added
974
+ - `labctl status` now appends the last 5 audit-log events under the SBC
975
+ table for at-a-glance recent-action context (oldest first so the newest
976
+ sits closest to the prompt).
977
+ - Per-SBC `power_cycle_delay_seconds` (schema v7). Acts as both the
978
+ default when `labctl power cycle --delay` is omitted *and* the floor
979
+ when a smaller value is passed (the CLI raises it with a warning).
980
+ Set/clear with `labctl edit <name> --cycle-delay <seconds>`
981
+ (negative value clears the override). Global fallback default is 3 s.
982
+
983
+ ### Changed
984
+ - Kasa retry path now logs at WARNING for each retry attempt (was DEBUG,
985
+ invisible at default log levels), logs at INFO when a retry succeeds,
986
+ and logs at ERROR on final failure. Default retry count bumped from 1
987
+ to 2 (3 total attempts) to absorb intermittent KLAP auth flakes.
988
+ - `labctl power cycle --delay` is now optional. Default and floor are
989
+ resolved per-SBC: SBC value if set, otherwise 3 s. Help text corrected
990
+ (was "default: 2s", code default was 2.0 — now 3.0).
991
+ - Monitor daemon now runs two cadences (2026-04-30):
992
+ - **Fast track (`health.check_interval`, default 10 s)** — ping +
993
+ serial probes, parallelized across SBCs via a thread pool. Sets
994
+ `sbcs.status` more responsively.
995
+ - **Slow track (`health.power_check_interval`, default 60 s)** —
996
+ power probes, also parallelized. Stays at 60 s by default to limit
997
+ pressure on plug controllers (Kasa KLAP especially).
998
+ - **`health.min_sleep_seconds` (default 1.0)** — floor on the
999
+ between-cycle sleep so a runaway cycle (elapsed ≥ interval) can't
1000
+ pin a CPU spinning.
1001
+ - **Default `check_interval` 60 s → 10 s** for fresh installs;
1002
+ existing configs keep their explicit value. Set
1003
+ `power_check_interval: 10` in config to also tighten power
1004
+ polling once Phase 2 lands (Kasa broadcast discovery).
1005
+ - `HealthChecker.check_all` is now parallel (ThreadPoolExecutor,
1006
+ max 16 workers). Wall time is the *max* of per-SBC probe latencies
1007
+ rather than the sum. Per-SBC failures isolate; one bad probe
1008
+ returns an empty summary instead of dropping the row.
1009
+ - `labctl status --fast` staleness threshold derives from
1010
+ `2 × power_check_interval` (was hardcoded 120 s) so it adapts when
1011
+ you change the cadence.
1012
+
1013
+ ### Added
1014
+ - `labctl status --fast` (alias `-f`) renders from the daemon's cached
1015
+ power observation instead of probing every plug live. Boot times go
1016
+ from "stuck on the slowest plug" to instant: 6.6s → 0.15s on a 6-SBC
1017
+ lab. Stale observations (older than 2× the monitor interval, default
1018
+ 120s) render dimmed so it's obvious when the daemon is behind. The
1019
+ default mode still does live probing — `--fast` is opt-in. Schema v6
1020
+ adds `last_power_state` and `last_power_at` columns to `sbcs`; the
1021
+ monitor daemon UPDATEs them every cycle. 11 new tests cover the
1022
+ schema migration, manager observation API, and `--fast` rendering
1023
+ including staleness dimming and the no-observation-yet case
1024
+ (2026-04-27)
1025
+
1026
+ ### Fixed
1027
+ - `labctl connect` now puts the local TTY in raw mode for the duration
1028
+ of the session, so each keystroke is forwarded immediately. Previously
1029
+ it shelled out to `nc`/`telnet`, which left the local terminal in
1030
+ cooked/line-buffered mode — boot menus with ~3s timeouts (Jetson
1031
+ U-Boot, Raspberry Pi, etc.) silently dropped keystrokes because they
1032
+ sat in the kernel until the user hit Enter. Native loop with select()
1033
+ + tty.setraw + TCP_NODELAY; non-TTY stdin/stdout still falls back to
1034
+ `nc`. New escape sequence: Ctrl+] then 'q' (or Ctrl+\) to disconnect,
1035
+ Ctrl+] then Ctrl+] to send a literal Ctrl+]. 12 regression tests
1036
+ cover the escape state machine and TTY/non-TTY dispatch (2026-04-26)
1037
+
1038
+ - `labctl serial list` showed registered adapters as "unassigned" when
1039
+ their assignment rows had `serial_device_id = NULL`. Root cause:
1040
+ `ResourceManager.assign_serial_port()` only populated the FK when
1041
+ the caller explicitly supplied `serial_device_id`. Callers that
1042
+ passed only a `/dev/lab/<name>` path (MCP `assign_serial_port`,
1043
+ REST API, and CLI without `--serial-device`) left the FK NULL, so
1044
+ the two-tier adapter→port link was broken while the port itself
1045
+ kept working at the device layer.
1046
+ Fix: `assign_serial_port()` now auto-resolves the FK from
1047
+ `device_path` (expects `/dev/lab/<name>` or a bare name). New
1048
+ `labctl serial repair [--apply]` command backfills the FK for
1049
+ existing orphan rows (dry-run by default). 6 new regression tests
1050
+ cover the auto-resolve and repair paths (2026-04-19)
1051
+
1052
+ ### Added
1053
+ - Activity stream Phase D — polish (2026-04-20)
1054
+ - MCP resources `lab://activity/recent` and
1055
+ `lab://activity/{sbc_name}` now expose the most recent activity
1056
+ events as JSON
1057
+ - `labctl activity export --format ndjson` exports activity events
1058
+ for downstream processing
1059
+ - The existing claim-sweep worker now also prunes activity events
1060
+ older than 30 days
1061
+ - Regression coverage added for MCP resources, CLI export, and
1062
+ activity retention pruning
1063
+ - Activity stream Phase C — source fidelity (2026-04-20)
1064
+ - MCP mutating tools now run inside `activity_context()` so audit
1065
+ events record `source=mcp` and the active MCP session ID as `actor`
1066
+ instead of falling back to `internal`
1067
+ - Flask request lifecycle now sets per-request audit attribution for
1068
+ authenticated REST API and web dashboard mutations (`api:<user>` /
1069
+ `web:<user>`)
1070
+ - Web claim endpoints now derive the claimant name from the correct
1071
+ session key (`session["user"]`)
1072
+ - Regression tests added for MCP, API-key, and logged-in web
1073
+ mutation attribution
1074
+ - Activity stream Phase B — live (2026-04-19)
1075
+ - `ActivityBroadcaster` background thread polls `audit_log` every
1076
+ 500ms and fans new rows to per-subscriber queues. Cross-process:
1077
+ events from CLI/MCP/daemon reach browsers because everyone writes
1078
+ to the same DB.
1079
+ - `GET /activity` — web page with live feed, source/result filter
1080
+ chips, pause/resume, and server-rendered hydration of the last
1081
+ 200 events.
1082
+ - `GET /activity/stream` — Server-Sent Events endpoint, session-
1083
+ authed. 15s keepalive heartbeats.
1084
+ - `GET /api/activity` — JSON query endpoint (API-key auth), same
1085
+ filters as the CLI.
1086
+ - `labctl activity tail --follow` — streams live events by polling
1087
+ the DB directly (works when the web service is stopped).
1088
+ - Activity link added to the nav bar.
1089
+ - 9 broadcaster tests (no-replay semantics, fan-out, unsubscribe,
1090
+ SSE frame format, slow-consumer drain, hydration + live).
1091
+ - `labctl services status` — reports on systemd units labctl depends
1092
+ on: `labctl-monitor`, `labctl-mcp`, `labctl-web`, and `ser2net`.
1093
+ Shows ActiveState/SubState, uptime, restart counter, and recent
1094
+ error-level journal lines for any unit that is unhealthy or has
1095
+ restarted. Exits non-zero if anything is not running. Extend the
1096
+ list with `--unit`. Non-privileged (uses `systemctl show` and
1097
+ `journalctl -u`) (2026-04-19)
1098
+ - Activity stream Phase A — foundation (2026-04-19)
1099
+ - `labctl.core.audit` module: `emit()`, `activity_context()`,
1100
+ `set_context()`, redaction, millisecond timestamps
1101
+ - Schema v5: extends `audit_log` with `actor`, `source`, `result`,
1102
+ `claim_id` columns + indexes
1103
+ - CLI boundary sets actor/source context in `main()`
1104
+ - `labctl activity tail` queries the stream with
1105
+ `--sbc/--actor/--source/--result/--since/-n` filters
1106
+ - Power CLI commands emit activity events on success/failure
1107
+ - Existing manager `_audit_log` calls route through `audit.emit()`
1108
+ - 18 tests: schema migration, emit, contextvars, redaction, truncation
1109
+ - Spec: `docs/RESOLVED_SPEC_activity_stream.md`
1110
+
1111
+
1112
+ - Hardware claims polish, Phase E (2026-04-16)
1113
+ - `ClaimsConfig.validate()` clamps invalid bounds at load time
1114
+ - `prune_released_claims()` auto-deletes old released claims (wired
1115
+ into CLI expire + MCP sweep)
1116
+ - `get_claim_metrics()` aggregate stats — exposed via `labctl claims stats`
1117
+ and `lab://claims/metrics` MCP resource
1118
+ - `AGENT_RULES.md` section 11: claim workflow, naming, duration guide
1119
+ - Hardware claims operator tooling, Phase D (2026-04-16)
1120
+ - Web REST API: full claim lifecycle at `/api/claims/*`
1121
+ - Dashboard: claim badges on SBC cards (agent, time remaining, request warning)
1122
+ - SBC detail page: claim section with holder info, pending requests,
1123
+ and force-release button
1124
+ - MCP tool responses: `_claim_advisory()` appends pending release request
1125
+ notices to all 10 gated tools on success
1126
+ - Hardware claims expiry and dead-session handling, Phase C (2026-04-16)
1127
+ - MCP `atexit` handler: releases this session's claims on clean exit
1128
+ - `release_dead_sessions()`: checks `kill -0 <pid>` for mcp-stdio claims;
1129
+ dead process + expired grace → auto-release as `session-lost`
1130
+ - Background daemon thread in MCP server sweeps every 30s
1131
+ - `labctl claims expire` CLI command for cron-driven sweeps
1132
+ - Grace period respected: dead sessions within grace window are left alone
1133
+ - Hardware claims MCP integration, Phase B (2026-04-16)
1134
+ - MCP tools: `claim_sbc`, `release_sbc`, `renew_sbc_claim`, `list_claims`,
1135
+ `get_claim`, `request_sbc_release`, `force_release_sbc`
1136
+ - MCP resources: `lab://claims`, `lab://claims/{sbc_name}`,
1137
+ `lab://claims/history/{sbc_name}`
1138
+ - Claim enforcement on 10 mutating MCP tools — other-agent claims
1139
+ return structured `sbc_claimed` JSON errors with hints
1140
+ - Session identity via `mcp-stdio:<pid>-<start_epoch>` for stdio transport
1141
+ - Heartbeat fires on every claimant tool call (reads and writes)
1142
+ - Hardware claims (exclusive access coordination), Phase A (2026-04-16)
1143
+ - SQLite schema v4 adds `claims` and `claim_requests` tables
1144
+ - Partial unique index enforces at most one active claim per SBC
1145
+ - CLI: `labctl claim`, `release`, `renew`, `force-release`, `request-release`
1146
+ - CLI: `labctl claims list | show <sbc> | history <sbc>`
1147
+ - `labctl status` surfaces the current claim holder and pending release requests
1148
+ - `labctl remove <sbc>` refuses while a claim is active (use `--force` to override)
1149
+ - Manager ops: `claim_sbc`, `release_claim`, `renew_claim`, `heartbeat_claim`,
1150
+ `get_active_claim`, `list_active_claims`, `list_claim_history`,
1151
+ `force_release_claim`, `expire_stale_claims`, `record_release_request`
1152
+ - Structured exceptions: `ClaimConflict`, `ClaimNotFoundError`, `NotClaimantError`,
1153
+ `UnknownSBCError`
1154
+ - Acquisition runs expire-stale sweep inside the transaction so past-deadline
1155
+ claims don't block new acquisitions even when no sweeper has run
1156
+ - Spec: `docs/RESOLVED_SPEC_claims.md`
1157
+ - SDWire power safety interlock (2026-04-09)
1158
+ - `sdwire_to_host` rejects if SBC is powered on (prevents SD card bus contention)
1159
+ - `--force` flag (CLI) / `force` parameter (MCP) to override when SBC is halted but power on
1160
+ - `sdwire_update` and `flash_image` auto power-off SBC before switching to host
1161
+ - Best-effort: allows operation when power state is unknown
1162
+ - `flash_image` MCP tool and enhanced `labctl sdwire flash` CLI (2026-04-06)
1163
+ - Supports .img, .img.xz, .img.gz compressed formats
1164
+ - Safety checks: block device size validation (max 256 GB), mount check, image format validation
1165
+ - Post-flash file copies to boot partition (e.g., custom config.txt)
1166
+ - On flash failure, SD card left on host for inspection
1167
+ - Automatic power-off before flash, optional reboot after
1168
+ - MCP CRUD tools for full device lifecycle management (2026-04-04)
1169
+ - Remove tools: `remove_serial_port`, `remove_network_address`, `remove_power_plug`
1170
+ - Serial device tools: `add_serial_device`, `remove_serial_device`, `serial_discover`
1171
+ - SDWire tools: `sdwire_add`, `sdwire_remove`, `sdwire_assign`, `sdwire_unassign`, `sdwire_discover`
1172
+ - AI assistants can now fully manage lab resources without CLI
1173
+ - `/deploy-and-test` Claude Code skill (2026-04-02)
1174
+ - Orchestrates build, deploy, boot, capture, and analysis via MCP tools
1175
+ - Auto-discovers config from `.claude/deploy-config.json`, `CLAUDE.md`, or user prompts
1176
+ - Boot reliability testing mode with `--runs N`
1177
+ - Located at `skills/deploy-and-test.md`, opt in via project settings.json
1178
+ - Serial I/O and boot test tools (2026-04-02)
1179
+ - `labctl serial capture <port> --timeout N --until <pattern>` — capture serial output with timeout or pattern match
1180
+ - `labctl serial send <port> <data>` — send data to serial port, optional `--capture` for response
1181
+ - `labctl boot-test <sbc>` — automated boot reliability testing: deploy, reboot N times, report success rate
1182
+ - MCP tools: `serial_capture`, `serial_send`, `boot_test`
1183
+ - Port name resolution: accepts port alias or SBC name (defaults to console port)
1184
+ - 33 new tests for serial capture, send, and boot test
1185
+ - SDWire SD card multiplexer support (2026-03-31)
1186
+ - Supports SDWire, SDWireC, and SDWire3 devices via `sdwire` Python library
1187
+ - `labctl sdwire` CLI command group: discover, add, remove, list, assign, unassign, dut, host, flash, update
1188
+ - `labctl sdwire flash <sbc> <image>` — automated workflow: switch to host, dd image, switch to DUT, power cycle
1189
+ - `labctl sdwire update <sbc> -p N -c src:dest -r old:new -d file` — atomic partition operations: copy, rename, delete files, optional reboot
1190
+ - Destination paths relative to partition root (e.g., `kernel.img` not `/boot/firmware/kernel.img`)
1191
+ - Database schema v3: `sdwire_devices` and `sdwire_assignments` tables
1192
+ - SDWire shown in `labctl info` output
1193
+ - MCP resource `lab://sdwire-devices` and tools `sdwire_to_dut`, `sdwire_to_host`, `sdwire_update`
1194
+ - Export/import includes SDWire device assignments
1195
+ - Sudoers-based privilege escalation for SD card mount/umount/dd/sync operations (enables MCP server to manage SD cards without running as root)
1196
+ - 55 new tests (322 total)
1197
+ - Global `--delay` / `-d` CLI option (2026-03-29)
1198
+ - Adds a delay in seconds before any command executes
1199
+ - Useful for chaining commands after power cycles (e.g., `labctl -d 30 health-check`)
1200
+ - Suppressed with `--quiet`, prints "Waiting Xs..." otherwise
1201
+ - MCP (Model Context Protocol) server for AI integration (2026-03-28)
1202
+ - `labctl mcp` command — stdio (default) and HTTP transports
1203
+ - 7 resources: lab://sbcs, lab://sbcs/{name}, lab://power/{name},
1204
+ lab://serial-devices, lab://ports, lab://health/{name}, lab://status
1205
+ - 10 tools: power_on/off/cycle, run_health_check, add/remove/update_sbc,
1206
+ assign_serial_port, assign_power_plug, set_network_address
1207
+ - 2 prompts: debug-sbc (guided debugging), lab-report (status overview)
1208
+ - Uses official `mcp` Python SDK with FastMCP
1209
+ - 28 comprehensive tests for tools and resources
1210
+ - Two-tier serial device management (2026-03-28)
1211
+ - `serial_devices` database table for registering physical USB-serial adapters
1212
+ - `labctl serial` CLI command group: discover, add, remove, list, rename, udev
1213
+ - Port assignments now support `--alias` for human-friendly names and `--serial-device` to link to registered adapters
1214
+ - `labctl connect` resolves by alias, SBC name, or device path
1215
+ - `labctl serial udev --install --reload` generates udev rules from database (replaces manual YAML editing)
1216
+ - Pure-Python USB discovery via `/sys` and `udevadm` (no external script dependency)
1217
+ - Database schema v2 migration (adds serial_devices table, alias/serial_device_id columns to serial_ports)
1218
+ - Export/import includes serial devices and port aliases
1219
+ - Web UI updated: alias field in port assignment form, alias column in port table
1220
+ - REST API updated: serial device endpoints, alias in port endpoints
1221
+ - Udev rules file group-writable by labctl group (no sudo needed for install)
1222
+ - Sudoers entries for passwordless udevadm and ser2net restart
1223
+ - Kasa power retry logic (2026-03-28)
1224
+ - Retries up to 2 times on transient KLAP authentication failures
1225
+ - Handles intermittent HS300 firmware handshake errors transparently
1226
+ - Native HTTPS support for web server (2026-03-28)
1227
+ - `--cert` and `--key` CLI flags on `labctl web` command
1228
+ - `web:` config section for persistent SSL configuration (cert_file, key_file)
1229
+ - Documentation for self-signed certificate generation and setup
1230
+ - Systemd service updated with SSL cert read access and port 443 capability
1231
+ - Kasa Smart Power Strip support (2026-03-28)
1232
+ - Auto-detects plugs vs strips via `kasa.Discover.discover_single()`
1233
+ - Multi-outlet control using `plug_index` (1-based) for individual outlets
1234
+ - TP-Link cloud credential support via `kasa:` config section (required for KLAP devices)
1235
+ - Proper async cleanup (`device.disconnect()`) to prevent unclosed session warnings
1236
+ - Detailed logging at all levels (DEBUG/INFO/ERROR) for troubleshooting
1237
+ - SBC renaming support (2026-03-28)
1238
+ - `labctl edit <name> --rename <new-name>` CLI command
1239
+ - Rename via REST API (`PUT /api/sbcs/<name>` with `{"name": "new-name"}`)
1240
+ - Rename via web UI edit form
1241
+ - CLI logging initialization (2026-03-28)
1242
+ - `logging.basicConfig()` wired to config `log_level` in CLI entrypoint
1243
+ - `-v` flag sets DEBUG, `-q` flag sets WARNING
1244
+ - Log output goes to stderr to avoid interfering with command output
1245
+ - `scripts/kasa-debug.py` — standalone debug script for Kasa device troubleshooting
1246
+ - Device discovery, status, and power control
1247
+ - `test-auth` action with credential hash diagnostics
1248
+ - Verbose mode includes python-kasa internal debug output
1249
+
1250
+ ### Changed
1251
+ - Default log level changed from INFO to WARNING (2026-03-28)
1252
+ - Kasa controller logging lowered from INFO/ERROR to DEBUG (2026-03-28)
1253
+ - Prevents log noise in `labctl status` output
1254
+ - `labctl ser2net generate` uses port alias as connection name when available (2026-03-28)
1255
+ - `labctl ser2net reload` auto-escalates to sudo when needed (2026-03-28)
1256
+
1257
+ ### Fixed
1258
+ - Critical: `get_status_history()` and `get_uptime()` called nonexistent `execute_query()` (2026-04-03)
1259
+ - Replaced with `execute()`, fixed tuple indexing to dict-style access for `sqlite3.Row`
1260
+ - Critical: Open redirect in login — `next` parameter now rejects absolute and protocol-relative URLs (2026-04-03)
1261
+ - Path traversal protection in SDWire file operations (2026-04-03)
1262
+ - Copy, rename, and delete paths validated via `realpath` to stay under mount point
1263
+ - Null checks after `execute_one()` on 5 insert-then-retrieve sites in manager (2026-04-03)
1264
+ - Power controller error handling improvements (2026-04-03)
1265
+ - Added warning-level logging to Tasmota/Shelly bare exception handlers
1266
+ - Added timeout to Kasa `future.result()` to prevent indefinite blocking
1267
+ - Fixed missing space in Kasa error message formatting
1268
+ - SerialProxy thread safety fixes (2026-04-03)
1269
+ - `asyncio.Lock` for write lock acquisition to prevent TOCTOU race
1270
+ - Snapshot `clients` dict before broadcast iteration
1271
+ - Guard orphaned `create_task(self.stop())` with state check
1272
+ - ser2net generate: add /dev/lab/ prefix for bare device names (2026-04-09)
1273
+ - ser2net format string: revert to `<baud><parity><databits><stopbits>` (e.g. `115200n81`) — ser2net rejects the comma-separated form with "unknown parameter 8n1" (2026-04-16)
1274
+ - Monitor daemon: `AlertManager.trigger()` call on power state change used kwargs instead of a single `Alert` object, raising `TypeError` each cycle. The uncaught exception aborted `run_once()` mid-iteration, preventing DB status updates for any SBCs processed after the crashing one. Added regression tests for `MonitorDaemon` (2026-04-17)
1275
+ - Consolidated SBC.to_dict() serialization, eliminating duplication between MCP and API (2026-04-03)
1276
+ - Atomic upsert (INSERT...ON CONFLICT) for serial ports, network addresses, power plugs, SDWire assignments (2026-04-03)
1277
+ - Config load exception now logs warning instead of silently continuing (2026-04-03)
1278
+ - SessionLogger rotation handles disk errors without leaking file handles (2026-04-03)
1279
+ - ser2net format string: extracted PARITY_CHARS constant (2026-04-03)
1280
+ - Extracted `_load_serial_device()` helper, eliminating 3x code duplication in manager (2026-04-03)
1281
+ - Removed unused `_connection` field from Database class (2026-04-03)
1282
+ - Simplified redundant `is True`/`is False` checks in Shelly controller (2026-04-03)
1283
+ - SessionLogger timestamps now include milliseconds to prevent collision (2026-04-03)
1284
+ - MCP parameter validation: `power_cycle` rejects negative delay, `boot_test` requires runs >= 1 and timeout > 0 (2026-04-03)
1285
+ - Power control API rate limiting: 5s minimum between power cycles per SBC (HTTP 429) (2026-04-03)
1286
+ - Health check power probe using wrong function signature (2026-03-28)
1287
+ - `get_controller(sbc.power_plug)` → `PowerController.from_plug(sbc.power_plug)`
1288
+ - Monitor daemon ping failing under systemd sandbox (2026-03-28)
1289
+ - Removed `NoNewPrivileges=yes`, added `AmbientCapabilities=CAP_NET_RAW`
1290
+ - Kasa unclosed aiohttp sessions on authentication failure (2026-03-28)
1291
+ - `_get_device()` now disconnects device in all error paths
1292
+ - USB serial discovery returning root hub path instead of device-specific paths (2026-03-28)
1293
+ - Authentication system (2026-03-06)
1294
+ - Session-based web login with username/password
1295
+ - API key authentication via `X-API-Key` header for REST endpoints
1296
+ - CSRF protection on all web POST/PUT/DELETE forms
1297
+ - Auth disabled by default — existing deployments unaffected
1298
+ - `/api/health` remains open without authentication for monitoring tools
1299
+ - `AuthConfig` and `UserConfig` dataclasses in config module
1300
+ - Login page template with dark theme styling
1301
+ - Logout button in navigation bar when logged in
1302
+ - `labctl user` CLI command group:
1303
+ - `labctl user hash-password` — generate werkzeug password hash
1304
+ - `labctl user generate-key` — generate random API key
1305
+ - `labctl user add <username>` — interactive user creation with YAML output
1306
+ - `labctl user verify <username>` — verify password against config
1307
+ - Auth section in `config/labctl.yaml.example`
1308
+ - 14 authentication integration tests (188 total)
1309
+ - `[tool.setuptools.package-data]` in pyproject.toml for templates and static files
1310
+ - Project documentation structure
1311
+ - ARCHITECTURE.md - System design and component specifications
1312
+ - IMPLEMENTATION.md - Milestone-based TODO lists
1313
+ - AGENT_RULES.md - Development process and standards
1314
+ - STATUS.md - Current project state tracking
1315
+ - CHANGELOG.md - This file
1316
+ - M1.1 Project Setup (2025-12-31)
1317
+ - Created project directory structure (src/labctl, tests, scripts, config, docs)
1318
+ - Added pyproject.toml with dependencies (click, pyyaml, requests)
1319
+ - Created virtual environment with dev tools (pytest, black, isort, flake8)
1320
+ - Installed ser2net system package
1321
+ - M1.2 udev Rules System (2025-12-31)
1322
+ - Created `scripts/discover-usb-serial.sh` for USB device enumeration
1323
+ - Created `scripts/generate-udev-rules.py` for rule generation from YAML mapping
1324
+ - Created `scripts/install-udev.sh` for udev rules installation
1325
+ - Created `docs/HARDWARE_MAP.md` with USB topology documentation
1326
+ - Symlinks now created under `/dev/lab/` for deterministic device access
1327
+ - M1.3 ser2net Configuration (2025-12-31)
1328
+ - Created `src/labctl/serial/ser2net.py` for config generation
1329
+ - Support for baud rate, parity, local-only connections
1330
+ - Generated config for 5 ports on TCP 4000-4004
1331
+ - All ports verified accessible via TCP
1332
+ - M1.4 Basic CLI Structure (2025-12-31)
1333
+ - Created `src/labctl/cli.py` with Click framework
1334
+ - Implemented `labctl ports` - lists configured serial ports with TCP mappings
1335
+ - Implemented `labctl connect <port>` - connects via TCP or direct serial
1336
+ - Added 8 CLI integration tests
1337
+ - M1.5 Configuration File (2025-12-31)
1338
+ - Created `src/labctl/core/config.py` with Config dataclasses
1339
+ - YAML config loading from ~/.config/labctl/config.yaml
1340
+ - Environment variable overrides (LABCTL_DEV_DIR, LABCTL_BASE_TCP_PORT, etc.)
1341
+ - Added `-c/--config` CLI option
1342
+ - Added 17 unit tests for config module
1343
+ - **Milestone 1 Complete!**
1344
+ - M2.1-2.5 Data Layer (2025-12-31)
1345
+ - Created `src/labctl/core/database.py` with SQLite schema
1346
+ - Tables: sbcs, serial_ports, network_addresses, power_plugs, status_log, audit_log
1347
+ - Foreign keys with cascade delete
1348
+ - Schema versioning support
1349
+ - Created `src/labctl/core/models.py` with data models
1350
+ - SBC, SerialPort, NetworkAddress, PowerPlug dataclasses
1351
+ - Status, PortType, AddressType, PlugType enums
1352
+ - from_row() methods for database deserialization
1353
+ - Created `src/labctl/core/manager.py` with ResourceManager
1354
+ - Full CRUD operations for SBCs
1355
+ - Serial port, network address, power plug assignment
1356
+ - Audit logging for all operations
1357
+ - Added CLI SBC management commands
1358
+ - `labctl list` - tabular output with filters (--project, --status)
1359
+ - `labctl add <name>` - create SBC with options
1360
+ - `labctl remove <name>` - delete with confirmation
1361
+ - `labctl info <name>` - detailed SBC view
1362
+ - `labctl edit <name>` - update properties
1363
+ - Added CLI port assignment commands
1364
+ - `labctl port assign <sbc> <type> <device>` - with auto TCP port
1365
+ - `labctl port remove <sbc> <type>`
1366
+ - `labctl port list` - all port assignments
1367
+ - Added 27 new tests (8 database, 19 manager)
1368
+ - Total: 62 tests passing
1369
+ - M2.6-2.7 Network and ser2net Commands (2025-12-31)
1370
+ - Added `labctl network set <sbc> <type> <ip>` with --mac, --hostname options
1371
+ - Added `labctl network remove <sbc> <type>`
1372
+ - Added `labctl ser2net generate` - generates config from database
1373
+ - Supports --output file and --install flag
1374
+ - Added `labctl ser2net reload` - restarts ser2net service
1375
+ - **Milestone 2 Complete!**
1376
+ - M3 Power Control (2025-12-31)
1377
+ - Created power module `src/labctl/power/` with controller framework
1378
+ - PowerController ABC with power_on, power_off, power_cycle, get_state
1379
+ - PowerState enum (ON, OFF, UNKNOWN)
1380
+ - Factory function get_controller() for plug type dispatch
1381
+ - Implemented TasmotaController for Tasmota smart plugs
1382
+ - HTTP API client with multi-relay support
1383
+ - Implemented ShellyController for Shelly devices
1384
+ - Implemented KasaController stub for TP-Link Kasa (requires python-kasa)
1385
+ - Added CLI plug commands
1386
+ - `labctl plug assign <sbc> <type> <address>` with --index option
1387
+ - `labctl plug remove <sbc>`
1388
+ - Added CLI power commands
1389
+ - `labctl power on/off/cycle/status <sbc>`
1390
+ - `labctl power-all on|off` with --project filter
1391
+ - Added 19 power module tests (81 total)
1392
+ - **Milestone 3 Complete!**
1393
+ - M4 CLI Completion (2025-12-31)
1394
+ - Added `labctl console <sbc>` - connect to SBC's serial console by name
1395
+ - Looks up port from database, supports --type for jtag/debug
1396
+ - Added `labctl ssh <sbc>` - SSH to SBC by name
1397
+ - Looks up IP from database, uses configured ssh_user
1398
+ - Supports --user override
1399
+ - Added `labctl status` - overview with color-coded status and power state
1400
+ - Added `labctl export` - export SBCs to YAML/JSON
1401
+ - Added `labctl import <file>` - import SBCs with --update option
1402
+ - Added `labctl completion` - shell completion for bash/zsh/fish
1403
+ - **Milestone 4 Complete!**
1404
+ - M5 Web Interface (2025-12-31)
1405
+ - Created Flask web module with application factory pattern
1406
+ - Implemented REST API endpoints:
1407
+ - `GET/POST /api/sbcs` - list and create SBCs
1408
+ - `GET/PUT/DELETE /api/sbcs/<name>` - CRUD operations
1409
+ - `GET/POST /api/sbcs/<name>/power` - power status and control
1410
+ - `GET /api/ports` - list serial ports
1411
+ - `GET /api/health` and `/api/status` - system status
1412
+ - Created web dashboard with dark theme:
1413
+ - Dashboard page with SBC card grid
1414
+ - SBC detail page with all information
1415
+ - Power control buttons (on/off/cycle)
1416
+ - Status indicators and responsive layout
1417
+ - Added `labctl web` command with --host, --port, --debug options
1418
+ - **Milestone 5 Complete!**
1419
+ - M6 Multi-Client Serial Access (2026-01-01)
1420
+ - Created serial proxy module `src/labctl/serial/proxy.py`
1421
+ - SerialProxy class with asyncio-based fan-out architecture
1422
+ - First-writer-wins write lock policy
1423
+ - Multiple readers can view console output simultaneously
1424
+ - ProxyManager for managing multiple proxy instances
1425
+ - Added session logging
1426
+ - SessionLogger class for timestamped traffic logs
1427
+ - Logs stored in ~/.local/share/labctl/logs/
1428
+ - Format: [timestamp] [direction] data
1429
+ - Added ProxyConfig to config.py
1430
+ - Configurable port range (default 5000-5099)
1431
+ - Write policy selection (first, all, queue)
1432
+ - Max clients per proxy, idle timeout
1433
+ - Added CLI proxy commands
1434
+ - `labctl proxy start <sbc>` - start proxy for SBC (foreground)
1435
+ - `labctl proxy list` - list running proxies (placeholder)
1436
+ - `labctl sessions [sbc]` - list connected clients (placeholder)
1437
+ - Added web console with xterm.js
1438
+ - WebSocket bridge for browser-to-proxy communication
1439
+ - Console page at /sbc/<name>/console
1440
+ - Full terminal emulator in browser
1441
+ - Added 26 proxy tests (143 total)
1442
+ - **Milestone 6 Complete!**
1443
+ - M7 Monitoring and Health (2026-01-01)
1444
+ - Created health check module `src/labctl/health/`
1445
+ - HealthChecker class with ping, serial, and power checks
1446
+ - CheckResult and HealthCheckSummary dataclasses
1447
+ - Automatic status determination based on check results
1448
+ - Added alerting framework
1449
+ - Alert and AlertLevel for structured notifications
1450
+ - AlertHandler ABC with pluggable handlers
1451
+ - LogAlertHandler for file-based alerts
1452
+ - ConsoleAlertHandler for terminal output
1453
+ - EmailAlertHandler and SlackAlertHandler stubs for future
1454
+ - AlertManager for dispatching to multiple handlers
1455
+ - Created monitoring daemon
1456
+ - MonitorDaemon class for periodic health checks
1457
+ - Configurable check interval
1458
+ - Automatic status updates on check results
1459
+ - Alert triggering on status/power changes
1460
+ - Added status tracking
1461
+ - log_status() method writes to status_log table
1462
+ - get_status_history() for retrieving history
1463
+ - cleanup_old_status_logs() for retention management
1464
+ - Added HealthConfig to configuration
1465
+ - check_interval, ping_timeout, serial_timeout
1466
+ - status_retention_days, alert_log_path
1467
+ - alert_on_offline, alert_on_power_change options
1468
+ - Added CLI commands
1469
+ - `labctl health-check` - run health checks on SBCs
1470
+ - --type ping|serial|power|all filter
1471
+ - --sbc to check single SBC
1472
+ - --update to update status in database
1473
+ - `labctl monitor` - start monitoring daemon
1474
+ - --interval to override check frequency
1475
+ - --no-update, --no-alerts options
1476
+ - Added web endpoints
1477
+ - GET /api/sbcs/<name>/history - status history
1478
+ - GET /api/health/check - run health checks via API
1479
+ - Status history page at /sbc/<name>/history
1480
+ - Added 28 health module tests (171 total)
1481
+ - **Milestone 7 Complete!**
1482
+ - Deferred Items Implementation (2026-01-01)
1483
+ - CLI Enhancements:
1484
+ - `labctl log <sbc>` - capture serial output to timestamped file
1485
+ - `--follow/-f` for continuous output
1486
+ - `--lines/-n` to capture N lines then exit
1487
+ - `--timestamp/--no-timestamp` for timestamp prefixes
1488
+ - `labctl status --watch` - continuous status update display
1489
+ - `labctl port list --unassigned` - show unassigned /dev/lab/* devices
1490
+ - `-q/--quiet` global flag for suppressed output
1491
+ - Command aliases: ls=list, rm=remove, delete=remove, show=info
1492
+ - Web Interface:
1493
+ - SBC edit form on detail page
1494
+ - Port/network/plug assignment forms
1495
+ - Settings page with configuration display
1496
+ - Settings link in navigation
1497
+ - GET /api/sbcs/<name>/console/info endpoint
1498
+ - GET /api/sbcs/<name>/uptime endpoint
1499
+ - Infrastructure:
1500
+ - Systemd service files (labctl-monitor.service, labctl-web.service)
1501
+ - Service installation script (scripts/install-services.sh)
1502
+ - Session log rotation with size-based rotation and compression
1503
+ - Old log cleanup based on retention period
1504
+ - Health Monitoring:
1505
+ - Uptime tracking with current uptime and 24h percentage
1506
+ - Serial probe with string/response verification
1507
+ - `serial_probe()` method for active serial testing
1508
+
1509
+ ### Changed
1510
+ - Moved documentation files to docs/ folder (AGENT_RULES.md, IMPLEMENTATION.md, DECISIONS.md)
1511
+ - Fixed README.md and CLAUDE.md paths to reference docs/ folder
1512
+
1513
+ [Unreleased]: https://github.com/johnjezl/Embedded-Lab-Control/compare/v0.2.0...HEAD
1514
+ [0.2.0]: https://github.com/johnjezl/Embedded-Lab-Control/releases/tag/v0.2.0