Add Typed Display Settings Administration

Implements admin-only Display settings with generation-checked
Apply, Save, Load, Defaults, and Reset operations across the web UI,
CLI, SSH dispatcher, and local UI owner. Adds bounded HTTP handling,
session-isolated operation results, browser lifecycle support, and
comprehensive host tests and documentation.
This commit is contained in:
2026-09-09 10:15:23 +02:00
parent 60d9c54bb4
commit d9ec3c08de
26 changed files with 1164 additions and 81 deletions
+8
View File
@@ -229,6 +229,14 @@ Persistent namespaces/blobs include:
Configuration modules generally choose RAM defaults without erasing incompatible storage. Security-material modules fail closed on malformed existing material and require explicit reset. OTA slots, coredump space, an NVS-key partition, and storage are reserved in `partitions.csv`; OTA, NVS encryption, coredump handling, and filesystem mounting are not implemented.
## Typed Display settings (8D.14)
`web_display_settings` adds admin-only bodyless GET snapshot and GET/POST operation routes. HTTPD validates bounded typed JSON and queues only an ID on the existing administration dispatcher. One login-bound slot and non-reused operation IDs follow Serial's30-second dequeue admission deadline, without a new timer; already-admitted operations may finish after logout. Results are replaceable, not durable or idempotent. The UI preserves both terminals/lease, shows Serial-style labels/values, validates timeout limits, explicitly distinguishes RAM/drafts/NVS and uses at most10 one-second result GETs/15seconds overall, then manual recovery without mutation replay.
`local_status_ui` provides a zero-wait RAM/generation projection and conditional public update API. A short critical-section reservation serializes every config writer, including legacy Apply and CLI, across NVS IO performed outside the critical section. Stale nonzero generation rejects before mutation; successful RAM publication advances a nonwrapping generation and activity. Save stabilizes selected working bytes, not browser drafts. Load keeps canonical missing/incompatible-storage defaults without rewriting NVS. Reset now commits defaults before publishing RAM (CLI too); failure leaves RAM unchanged without rollback. Buttons/diagnostic holds affect independent activity, not configuration generation; renderer reads its previous consistent RAM copy during storage work. Absent panel does not prevent config when UI task is available; no panel/I2C IO is called by settings.
Bounds:256-byte/four-receive request,128-byte snapshot,96-byte result, one slot;30 URI handlers/six sockets, no new task/timer/stack-size/queue/schema expansion. Optional staged allocation failures retain other routes. Host tests/build pass; target and memory/stack margins pending. Full contract, resource evidence and checklist: `docs/phase8d14_implementation.md`.
## Local UI and hardware boundaries
`board_pins.h` centralizes project-assigned RS-232, diagnostic, RGB LED, and local-UI hardware resources; UART0 GPIOs remain local to `main.c`, and native USB uses platform wiring. `local_display` solely owns I2C0, the SSD1315-compatible OLED, its static framebuffer, and display mutex. Display frames belong to the initiating task. Dirty-page commits and I2C transactions are bounded.
+4 -2
View File
@@ -59,6 +59,8 @@ This is a semantic map, not a complete file inventory. Start here, then read the
## Web and WebSocket serial
- **8D.14 Display (2026-09-09):** `web_display_settings.{c,h}` owns optional admin-only GET `/api/settings/display`, GET/POST `/api/settings/display-operation`; `web_ui.c` supplies Serial-style dim/off settings and bounded completion checks. `local_status_ui` owns generation-safe config/storage reservation shared with CLI; buttons do not edit timeouts. No I2C changes. 256-byte/four-receive request,128-byte snapshot,96-byte result, one slot/no timer;30 handlers/six sockets, unchanged tasks/stacks/queue/schema. Tests: cookie `--display` (7+shared), UI111, lifecycle23, dispatcher and broad regressions. Actual baseline100,100/1,748,513 B → final100,196 RAM/1,765,233 flash at160MHz. Target pending; exact API, reset ordering, deadlines, resource/validation limits: `docs/phase8d14_implementation.md`.
- **Current 8D.12/8D.13 — user functional sign-off 2026-09-08, including Settings presentation:** `web_network_settings.{c,h}` owns optional admin-only GET `/api/settings/network` and GET/POST `/api/settings/network-operation`; `web_ui.c` supplies Network, UTF-8/hex SSID editing and explicit transient-secret/connection controls. `wifi_manager` owns generation-checked secret-free snapshots/patch/save/stored-only load and radio transitions; `mdns_service` owns independent conditional hostname persistence, with manager reannouncement. Existing dispatcher receives IDs only. 768-byte request/2,048-byte snapshot/128-byte result, one slot/one-second timer with 30-second queued expiry plus scheduling latency; no hard cancellation. 27 handlers/six sockets, no task/stack/queue/schema growth. Parent integrated tests/build PASS; latest styling UI100 + renderer/CSP/Chromium checks, 99,548 B RAM / 1,744,325 B flash. User full-mix evidence accepted; loaded internal/DMA minima2,276/156 B remain resource follow-ups, not reserve approval. Full contract/exclusions/checklist: `docs/phase8d12_13_implementation.md`. Both phases user-authorized together; no 8D.14/M3 claim. Older next-phase statements below are historical.
- **8D.11:** `web_account_settings.{c,h}` extends Accounts with fingerprint-only POST `/api/settings/accounts/keys` and key-add/key-delete/key-clear on the existing operation endpoint/dispatcher. `user_database.{c,h}` owns zero-wait target-checked snapshots and canonical conditional key mutations. `web_ui.c` handles confirmations, sparse stable indices and self-revocation uncertainty. 24 handlers, six sockets; no new task/stack/queue depth. Host-tested/build-verified, target pending. Contracts/tests/checklist: `docs/phase8d11_implementation.md`.
@@ -160,12 +162,12 @@ This is a semantic map, not a complete file inventory. Start here, then read the
**Responsibility:** own OLED I2C/framebuffer operations and present status plus constrained button actions.
- Files: `src/local_display.{h,c}`, `src/local_status_ui.{h,c}`, `src/local_boot_animation.{h,c}`, `src/local_ui_config.{h,c}`, `src/local_ui_console.{h,c}`
- Interfaces: display init/frame/draw/commit/snapshot; UI start/activity/config; versioned NVS settings
- Interfaces: display init/frame/draw/commit/snapshot; UI start/activity/config; generation-checked settings projection/update and explicit persistence reservation; versioned NVS settings
- Called by: startup, local UI task, diagnostics, display console
- Dependencies: copied snapshots/public APIs from serial, broker, USB, Wi-Fi, web, SSH
- Ownership: `local_display` solely owns I2C0 and framebuffer mutex; a frame belongs to its initiating task.
- Lifecycle: the low-priority task is firmware-lifetime only if button GPIO initialization succeeds; it still runs with an absent panel so a press can reprobe after successful I2C bus setup. Failed bus creation is not recoverable by that reprobe, and `display` configuration commands depend on the UI task.
- Constraint: collect service snapshots before I2C; local UI never joins broker or handles secrets.
- Constraint: collect service snapshots before I2C; local UI never joins broker or handles secrets. All configuration writers honor the UI owner's zero-wait reservation; NVS runs outside timing critical sections. Reset commits defaults before RAM publication, including CLI; buttons/diagnostic holds update activity, not configuration generation.
## Hardware and diagnostics
+2
View File
@@ -4,6 +4,8 @@ This file is working memory. Update it during active work and before handoff; do
## Development state
- **8D.14 Display implementation complete (2026-09-09), user-authorized; host-tested/build-verified, target pending:** Admin Settings → Display supplies typed dim/off086400s, Apply/Save/Load/Defaults/Reset, Serial-style label/value presentation and bounded result polling. `web_display_settings` owns one secret-free login-bound slot, 256-byte/four-receive requests,128-byte snapshot/96-byte results; IDs use the existing dispatcher. `local_status_ui` owns nonwrapping config generation and zero-wait reservation across NVS outside critical sections; CLI shares the gate, buttons retain independent activity/reprobe behavior. Reset now commits defaults before RAM publication (CLI too), eliminating rollback overwrite. Absent panel does not gate config if UI task is available; no I2C/renderer ownership change.30 handlers/six sockets, no new task/timer/stack size/queue/schema/assets changes.30-second dequeue admission deadline, not timed cancellation; admitted work may finish after logout. PASS Display7+shared auth, UI111+C/HTML/CSP, lifecycle23, dispatcher/accounts/policy, cookie all variants, canonical Network, parser294, transports/tickets/store/idle/diagnostics/throughput/broker/login regressions and diff check. Separate self-review fixed strict result status/action/replacement handling. Independent final review found no actionable findings and independently reran Display7+shared auth, dispatcher boundary, lifecycle23, UI111+C/HTML/CSP and diff checks PASS. Parent final pio confirmation PASS9.08s, unchanged100,196/1,765,233 B; diff check PASS. Actual pre-edit pio12.10s100,100 RAM/1,748,513 flash; final pio26.09s100,196/1,765,233 (+96/+16,720 B). CPU160 confirmed in defaults/active/generated configuration; throughput fix and user sign-off preserved. Initial worktree reported clean. Optional Chromium geometry attempt blocked by sandbox socket/crash-report restrictions; no geometry pass. Target display/save-reboot/absent-panel/buttons-concurrency/full-mix/heap/HTTPD-dispatcher margins and sign-off pending, no reserve approval or full M3 claim. Exact contracts/tests/resources/checklist: `docs/phase8d14_implementation.md`. No upload/erase/commit or later phase; older no-8D.14 authorization statements below are historical.
- **Throughput fix signed off at160MHz (2026-09-08):** User explicitly validates drop-free230400-baud operation with the full client mix, including browser admin, at160MHz. Current defaults, active sdkconfig and generated sdkconfig.h independently confirm160MHz selection. This supersedes earlier frequency uncertainty and pending functional throughput validation below. Retain combined binary header/payload TLS send and bounded failure isolation; no further tuning needed for this issue. Latest full-mix160MHz result is user-reported without additional raw counters or duration; no invented long-soak, peer-byte comparison or HTTPD-stack reserve validation. Documentation-only acceptance update; no source/config/build/upload/erase/commit action.
- **Combined binary WS send functionally validated by user (2026-09-08):** User confirms no drops with browser admin also connected and explicitly signs off. Prior detailed two-browser/SSH/USB capture (browser admin absent) had UART71,292 B, all four clients read71,292/drop0, global285,168, web430 frames/142,584 B, HWM2,499/2,563; send averages4,215/4,522us and callback5,691/6,253us, no transport errors/disconnects. Full-mix follow-up is user-reported, no additional counters/duration supplied. Supersedes target-pending functional status below; no sustained-soak/HTTPD-stack reserve approval inferred. User also reports commenting out CPU240 default and assumes160MHz; inspection shows tracked default commented but active sdkconfig and generated sdkconfig.h STILL select240MHz. Thus full-mix functional sign-off stands, but160MHz operation is not verified. Preserve user config edit; no automatic rollback/build/upload. Next optional step is explicit160MHz selection in defaults and active config, rebuild/generated-setting verification, then target retest. Documentation-only sign-off update.
+6
View File
@@ -2,6 +2,12 @@
Only constraints supported by implementation or current project documentation belong here. When original rationale is unknown, the entry describes the observable constraint without inventing intent.
## Display configuration has an owner reservation separate from button activity
**Decision (8D.14):** `local_status_ui` owns a nonwrapping RAM config generation and a zero-wait reservation shared by typed Display operations and canonical CLI/legacy Apply. Compare/reserve and publish occur in short timing critical sections; NVS occurs outside them. Save stabilizes selected bytes, Load retains canonical fallback, and Reset commits defaults before RAM publication, including CLI. This replaces Reset's apply/rollback race with failure-before-publication semantics.
**Consequence:** Browser mutations must carry the selected nonzero generation, including Save; never silently replace intervening CLI edits. Buttons and diagnostic holds retain independent activity state, not config generation or persistence ownership. Do not hold timing locks across storage or I2C, add display-presence prerequisites to configuration, or mistake a successful config API for physical panel success. The existing dispatcher carries IDs only; the secret-free slot uses a30-second dequeue admission deadline, not a new timer or hard cancellation. Preserve explicit result uncertainty and no automatic mutation replay. `docs/phase8d14_implementation.md` records the exact contracts and pending target gates.
## Typed Network edits preserve manager ownership and current secret bytes
**Decision (8D.12/8D.13):** `web_network_settings` admits bounded typed operations into one login-bound slot; the existing dispatcher carries IDs only and calls canonical generation-checked Wi-Fi/mDNS APIs. HTTPD reads only zero-wait secret-free projections. Wi-Fi mutex-local compare/merge/validation preserves omitted PSKs and prevents stale edits undoing CLI/local changes; queue admission precedes RAM publication. Save stabilizes selected bytes under the mutex; browser Wi-Fi Load reads stored configuration only, never generates fallback AP secrets. mDNS uses its own generation and reports RAM-applied/reannouncement-not-queued separately.
+3 -1
View File
@@ -44,10 +44,12 @@ Missing `user_db/database` storage is committed empty. On UART0 run `user add <u
| `display set dim-seconds <0..86400>` | Set the RAM inactivity delay before contrast drops to `1`; `0` disables dimming. |
| `display set off-seconds <0..86400>` | Set the RAM inactivity delay before the OLED switches off; `0` disables automatic off. |
| `display save` / `display load` | Save the working aging settings to NVS or load them. |
| `display defaults` / `display reset` | Apply 300/600-second defaults in RAM, or apply and persist them. |
| `display defaults` / `display reset` | Apply 300/600-second defaults in RAM, or save defaults first and then apply them. |
When both transitions are enabled, `off-seconds` must be greater than `dim-seconds`. Applying settings counts as local UI activity. At normal boot, an initialized OLED shows a bounded five-second identity animation before the status UI begins; it scrolls the device name in yellow and draws the compact upright-terminal logo in blue. A missing OLED remains nonfatal; after reconnecting it safely, one new button press requests a bounded reprobe and is consumed without navigating.
Display settings require an available local UI task, not an attached panel. CLI and browser **Settings → Display** share the public configuration owner; concurrent mutations can report busy, and browser operations reject an intervening configuration edit rather than overwrite it. Save persists working RAM, not browser drafts. Load selects defaults when saved storage is absent/incompatible without rewriting NVS. Reset storage failure leaves RAM unchanged (commit-before-publication, no RAM rollback). Browser timeout/navigation does not cancel already-admitted work; Check Result and Refresh before retrying. [Display settings contract and target checklist](phase8d14_implementation.md).
## Serial service
| Command | Description |
+138
View File
@@ -0,0 +1,138 @@
# Phase 8D.14 — Typed Display settings
## Status and scope (2026-09-09)
User-authorized implementation complete; host-tested and production-build-verified. **Target validation and phase sign-off remain pending.** No 8D.15/later phase or full M3 claim.
Adds an admin-only **Settings → Display** view with Serial-style label/value presentation, typed dim/off inactivity edits, and explicit Apply/Save/Load/Defaults/Reset. The production starting worktree reported clean, despite the request's warning about uncommitted work; the existing combined binary WebSocket throughput fix and CPU160MHz configuration were preserved without source/configuration edits. Prior user full-mix230400 throughput acceptance stands and is not a validation claim for these Display changes.
No I2C/framebuffer/renderer ownership changes, electrical-diagnostic UI, secret fields/logging, generic command runner/job framework, new task/timer/mutex allocation, stack-size increase, queue-depth/item expansion, socket increase, NVS schema migration, generated-asset change/regeneration, upload, erase, branch or commit. Browser-shell authorization restrictions are unchanged.
## Production files and ownership
- `src/web_display_settings.{c,h}`: bounded HTTP authorization/admission, one login-bound operation/result slot, dispatcher execution adapter and RAM snapshot encoding.
- `src/local_status_ui.{c,h}`: authoritative configuration generation and zero-wait settings projection; conditional public configuration/persistence operations.
- `src/local_ui_console.c`: canonical CLI Set uses compare-generation Apply; Save/Load/Reset use the same reserved owner API. Defaults and existing public Apply also pass through the owner gate.
- `src/admin_ssh_console.{c,h}`: Display ID in the existing typed request union and existing sole dispatcher, without a command string.
- `src/web_server.c`, `src/CMakeLists.txt`: optional route registration and source composition.
- `src/web_ui.c`: authored Display HTML/JS; no vendored/generated asset edits or loader/CSP changes.
- `local_ui_config.c` remains the unchanged canonical version-1, 12-byte NVS validator/loader/saver.
### Configuration/currentness contract
`local_status_ui_get_settings(config, generation)` copies RAM plus a nonzero generation in one short timing critical section. It returns unavailable if UI startup did not publish configuration, or busy while a reserved configuration/storage operation is running. It never accesses the panel or NVS. The preexisting renderer/get-config path can still read the last committed RAM configuration during storage work.
`local_status_ui_update_settings(action, expected_generation, config, loaded_defaults)` compares generation and reserves configuration mutation under that same critical section. Storage IO runs **outside** the critical section. Every configuration writer, including legacy Apply and CLI, honors the reservation; competing mutations return busy rather than block. Browser requests require a nonzero expected generation; zero is reserved for unconditional canonical CLI operations. Successful RAM publication advances the generation even when bytes are identical, and signals external activity. Save does not advance generation or signal activity. Generation exhaustion fails closed rather than wrapping.
Buttons currently do **not** edit these timeouts: their activity, wake, navigation and diagnostic-hold sequences remain distinct from the configuration generation. They can proceed during NVS work. No timing lock is retained across I2C. The UI task is still optional and firmware-lifetime: configuration can work with an absent panel if the task starts, while unavailable task/button initialization yields an unavailable settings snapshot. No response claims actual brightness, successful I2C, physical presence or successful reprobe. Failed bus creation remains unrecoverable through the existing button reprobe.
### Persistence semantics
| Action | Required fields besides `action` | Effect |
|---|---|---|
| `apply` | `generation`, `dim_seconds`, `off_seconds` | Replace both RAM timeout values, not NVS; signal activity. |
| `save` | `generation` | Persist exactly the selected working generation; browser drafts are ignored. Reservation prevents changes during Save. |
| `load` | `generation` | Canonical load into RAM. Missing/incompatible/invalid storage selects defaults and returns `loaded_defaults`; NVS is not overwritten. IO errors leave RAM unchanged. |
| `defaults` | `generation` | Apply 300/600-second defaults to RAM only; signal activity. |
| `reset` | `generation` | Save defaults first, then publish RAM and signal activity. |
**Intentional CLI behavior refinement:** Reset previously applied RAM and attempted rollback on storage failure. Both typed and CLI Reset now commit defaults before RAM publication. Failure leaves RAM unchanged, so no rollback can overwrite concurrent activity/configuration and no `rollback_failed` result is needed. Successful resulting RAM/NVS values are unchanged. A storage error is not a power-loss guarantee about physical flash; inspect/reload after uncertain delivery or storage failure.
Both timeout values are unsigned integers in **086,400 seconds**. Zero disables that transition. If both are nonzero, Off must be strictly later than Dim. The fixed NVS version is supplied internally, not browser-editable. Set rejects stale read/modify/write configuration rather than overwriting a concurrent edit. No persisted generation, schema expansion or migration is needed.
## HTTP and operation contract
All three routes reuse current cookie/principal authentication, admin role enforcement, strict header/framing validation, no-store/nosniff/no-referrer response policy and request scratch cleanup. Normal users receive 403; invalid/expired sessions receive 401. GET is bodyless/queryless with the existing GET Origin policy. POST additionally requires canonical same-origin Origin and CSRF, a supported JSON Content-Type, no query or transfer encoding, and a nonempty body.
| Route | Method | Payload / bound |
|---|---|---|
| `/api/settings/display` | GET | Exactly `generation`, `dim_seconds`, `off_seconds`; 128-byte response buffer. 503 if UI unavailable/busy. No storage/panel reads. |
| `/api/settings/display-operation` | POST | Maximum 256 bytes and four receive calls. Exactly the fields in the action table. 202 returns admission, not completion. |
| `/api/settings/display-operation` | GET | Latest result for this originating login only, exactly `id`, `action`, `state`; 96-byte response buffer. |
Strict flat parser rejects unknown/duplicate/missing fields, escapes, strings in numeric fields, negative/fractional/exponent/overflow/leading-zero numbers, embedded NUL, trailing garbage, nested values and unsupported actions. Partial/error/excessively fragmented bodies are rejected, with unread bodies closing rather than unbounded drain/retry.
One static operation slot is shared by all administrators, but result retrieval is isolated by non-reused originating web-session ID. Other logins see `{id:0,action:"none",state:"idle"}`. Pending/full dispatcher admission returns 503 with Retry-After 1. Slot IDs never wrap; completed results are replaceable, not durable history or idempotent retry records.
Only an ID enters the existing four-entry dispatcher queue. On dequeue, the adapter validates the original session/principal/admin and **30-second admission deadline**, then calls the owner conditional API. This is the Serial-style dequeue deadline: **no new timer**, no hard cancellation, and a blocked dispatcher can retain this small secret-free pending record beyond 30 seconds until dequeue. Restart/revocation does not authorize stale queued work: session IDs are not reused. Already-admitted storage work can complete after logout, absolute expiry or server stop; acknowledgement/result delivery is not guaranteed.
States:
- `idle`: no result belonging to this login; not proof earlier work was cancelled.
- `pending`: queued or executing, not completed.
- `ok`: owner operation completed; only Save/Reset imply successful explicit persistence.
- `loaded_defaults`: Load selected defaults without overwriting missing/incompatible storage.
- `conflict`: selected generation stale, owner unavailable or generation exhausted; this operation made no change.
- `failed`: owner busy, validation/storage failure; this operation did not publish RAM. Inspect storage after uncertainty.
- `cancelled`: original session/currentness/dequeue deadline rejected before owner admission.
Optional registrations are staged through the existing adapter: snapshot first, then operation GET, then POST. A POST allocation failure unregisters the operation GET; failed unregister can leave a read-only result route, never a mutation-only domain. Failure preserves other settings/auth/transports. HTTPD handler budget **27 → 30**, six sockets/no LRU unchanged.
## Browser lifecycle
Display navigation preserves both sockets, hidden output draining, broker identity and writer lease; only terminal keyboard routing/view selection changes. It reads fresh working state on entry and allows explicit Refresh. Invalid/unavailable snapshots disable mutation until a valid refresh. Refresh discards drafts; there is no saved-value read or automatic mutation on selection.
All mutation bodies carry the last loaded generation, so a CLI edit between refresh and dequeue becomes a conflict, including Save. Routine Apply/Save/Load/Defaults use inline consequences; only Reset asks for confirmation of saved-NVS overwrite. Pending values remain visible but marked stale; repeated submission is disabled.
After a validated 202 acknowledgement, automatic completion checks run at one-second intervals, **at most ten GETs and fifteen seconds overall**, including session checks. Known terminal results refresh the RAM snapshot once while retaining the outcome message. No automatic POST retry. Result replacement stops automatic following; same-ID action mismatch, invalid HTTP status/schema or impossible loaded-defaults action fails validation. Lost acknowledgement, idle/replaced results and failures retain an explicit uncertainty warning with manual Check Result/Refresh recovery.
Domain/view navigation, pagehide, logout, expiry, identity replacement and late request/body completion are fenced by existing work generation plus Display request ownership/abort state. Drafts clear on teardown. Returning does not resume polling or replay a mutation. Backend work is not cancelled by browser navigation or timeout. Session replacement still requires a clean document rather than exposing retained terminal state to a new identity.
## Validation executed
All commands below ran successfully unless explicitly marked unavailable. Host storage/RTOS/HTTPD/renderer doubles are not target timing, power-loss or real-device evidence.
| Command | Result / scope |
|---|---|
| `python3 tests/web_cookie_auth/run.py --display` | PASS: **7 Display groups**, plus shared cookie/store suite. Compiles production web handler, public owner function bodies, unchanged full NVS config implementation, canonical CLI handlers, cookie/store/parser and installed-SDK boundary doubles. Schema/bounds, admin/auth, absent UI/busy projection, generation conflicts, CLI parity, real loader reboot projection, activity/diagnostic-hold interleaving, init/open/read/set/commit fault injection, replay isolation, session expiry/revocation/restart and lost ACK. |
| `python3 tests/admin_console_boundary/run.py` | PASS, including added Display zero-ID/not-ready/full-queue/FIFO routing with Serial/Accounts/Network in the unchanged four-entry queue. Existing certificate and SSH owner adapter suites pass. |
| `python3 tests/admin_console_boundary/lifecycle.py` | PASS canonical lifecycle/force/deferred owner isolation. |
| `python3 tests/admin_console_boundary/accounts.py` | PASS canonical accounts/keys/storage/currentness regressions. |
| `python3 tests/admin_ssh_policy/run.py` | PASS parsed policy, startup gates and completion regressions. |
| `python3 tests/web_admin_transport/server_lifecycle.py` | PASS **23 groups**, including all six Display staged descriptor/name failure positions, failed unregister, stop failure/restart, and other-domain failure isolation. |
| `python3 tests/web_ui_session/run.py` | PASS **111 browser groups**: prior 100 plus 11 Display groups; production C renderer, all resource/header failures, HTML structure/style and exact loader/CSP checks. All four views covered by structural fixtures. |
| `python3 tests/web_cookie_auth/run.py --settings` | PASS Settings/status/optional adapter and shared auth. |
| `python3 tests/web_cookie_auth/run.py --serial-settings` | PASS Serial mutation/CLI/failure/currentness regressions and shared auth. |
| `python3 tests/web_cookie_auth/run.py --accounts` | PASS account/key/credential policies and shared auth. |
| `python3 tests/web_cookie_auth/run.py --network` | PASS Network bounds/secret handling/owner admission and shared auth. |
| `python3 tests/web_cookie_auth/run.py --admin` | PASS real cookie/ticket/admin admission integration and shared auth. |
| `python3 tests/web_network_settings/run.py` | PASS 5 canonical Wi-Fi/mDNS owner groups. |
| `python3 tests/web_auth_parse/run.py` | PASS **294 cases, 0 failures**. |
| `python3 tests/web_admin_transport/run.py --tickets` | PASS transport and ticket suites. |
| `python3 tests/web_httpd_idle/run.py` | PASS idle lifecycle and SDK contract guards. |
| `python3 tests/web_diagnostics/run.py` | PASS diagnostics and SDK guards. |
| `python3 tests/web_session_store/run.py --serial` | PASS store and serial integration. |
| `python3 tests/web_serial_performance/run.py` | PASS preserved combined-send/failure isolation, installed-SDK wire contract and performance/epoch regressions. |
| `python3 tests/session_broker_diagnostics/run.py` | PASS **7 groups**, including bounded fanout/counter/ownership regressions. |
| `python3 tests/web_login_ui/run.py` | PASS renderer/CSP and **8 browser groups**. |
| `git --no-pager diff --check` | PASS. |
Optional real layout attempt: `WEB_UI_CHROMIUM=/usr/bin/chromium-browser python3 tests/web_ui_session/run.py` could not execute Chromium in the terminal sandbox: read-only crash-report storage and forbidden process-singleton `socket()`/ptrace. **No Chromium geometry/visual pass claimed.** Structural four-view fixtures pass; optional geometry fixtures were updated to all four views at 320/600/1200px (12 cases), but await an environment that permits Chromium. No dependency installation or broad sandbox escape was used. No sanitizer validation claimed.
Separate self-review checked owner reservation/public call sites, generation/publication/failure ordering, ID/session/deadline fences, HTTP route wiring, browser lifecycle/result validation and fixture coverage. Fixed result HTTP-status/action/replacement checking and expanded storage failures during that pass. This initial self-review was followed by an independent review of the final implementation: no actionable findings. Reviewer independently reran Display7+shared auth, dispatcher boundary, lifecycle23, UI111+C/HTML/CSP and diff checks, all PASS. Parent final `pio run` confirmation PASS9.08s at100,196 B RAM/1,765,233 B flash; diff check PASS. No target validation inferred.
## Build and resources
Actual pre-edit `pio run`: **PASS 12.10 s**, **100,100 B RAM / 1,748,513 B flash**. This matches the recorded combined-send size but was measured afresh in this worktree at CPU160MHz.
Final production `pio run`: **PASS 26.09 s**, **100,196 B RAM / 1,765,233 B flash**. Delta **+96 B RAM / +16,720 B flash** versus actual pre-edit baseline. Intermediate full configuration build passed in 71.95 s and emitted the existing nonfatal SDK `FATFS_PRINT_FLOAT` bool-default warning; final incremental build passed without that warning. No configuration was changed to suppress it.
Tracked defaults, active sdkconfig and generated `sdkconfig.h` explicitly select **160MHz**. PlatformIO's generic board banner prints 240MHz; it is not the compiled CPU selection.
Target toolchain object inspection (not host `sizeof`): Display slot **88 B**, operation ID **4 B**, portMUX **8 B**; UI config busy flag **1 B**, generation **4 B**. These are individual symbols before placement and do not by themselves explain the whole-image RAM delta. Three URI descriptors are **24 B each**. Successful startup requests **170 B additional route/table/name heap before allocator overhead**: 72 descriptor + 12 table-pointer + 86 NUL-terminated URI-name bytes. No new timer/task/mutex allocation.
Local Xtensa entry frames, excluding callees: operation HTTP handler **656 B**, snapshot HTTP handler **320 B**, dispatcher execute **128 B**, owner update **48 B**, owner snapshot **32 B**. These are not whole-call-chain requirements or runtime high-water margins. Rendered authored HTML/app/loader sizes: **26,973 / 97,829 / 1,173 bytes**. Browser allocations, runtime route heap overhead, HTTPD/dispatcher stack floors and loaded internal/DMA/PSRAM reserves remain unmeasured. Previously low lifetime memory minima remain follow-ups, not approved reserves or reopened prior functional acceptance.
## Pending target checklist — no execution/sign-off claimed
- [ ] Compare browser values with UART0/admin SSH `display status`, including 0/0, disabled Dim, disabled Off, 86,400 boundary and enabled ordering; reject malformed/direct API values without state changes.
- [ ] Verify actual dim/off/wake behavior and fresh activity after Apply/Load/Defaults/Reset; Save must not count as a wake/activity change.
- [ ] Change RAM without Save, reboot through an authorized independent control and confirm old saved values; explicitly Save, reboot and confirm new values. Confirm Defaults RAM-only and Reset persistence. No factory erase required.
- [ ] Missing/incompatible storage selects defaults on Load without silently replacing NVS; storage faults leave RAM unchanged. Test faults only through an approved safe test setup.
- [ ] With panel absent at boot but working buttons/UI task, read/apply/save/load configuration and observe continued UART0/native USB recovery. Reconnect panel safely and verify existing consumed-press reprobe/wake. Distinguish absent panel from failed I2C bus or unavailable UI task.
- [ ] While browser snapshot/operation is pending, edit through UART0/admin SSH and exercise buttons. Verify stale generation conflict, explicit Refresh/review, no overwritten CLI edit, no stuck reservation, no accidental button action or I2C ownership change.
- [ ] Two admin tabs/logins: global pending capacity, same-login result replacement warning, other-login result isolation, expired/revoked cookie denial, lost acknowledgement recovery, blocked dispatcher/dequeue expiry and HTTPS stop/restart fencing. Never interpret timeout as cancellation.
- [ ] Desktop/mobile narrow layout, keyboard focus, Refresh draft replacement, Reset confirmation, automatic completion bounds, manual uncertain-result recovery, logout/expiry/pagehide and same-session restoration.
- [ ] Repeat settled full mix at CPU160MHz/230400 baud with two browser serial clients, SSH/USB and both admins while using Display. Capture broker drops/HWM, service errors, free/minimum/largest internal/DMA/PSRAM and HTTPD/dispatcher stack high-water; distinguish acceptance from numeric reserve approval.
- [x] Independent diff/test review complete; no actionable findings.
- [ ] Explicit user target acceptance before claiming 8D.14 sign-off. No later-phase work is implied.
+3 -1
View File
@@ -1,5 +1,7 @@
# Phase 8D — Incremental web administration plan
**8D.14 current implementation (2026-09-09):** Separately user-authorized Display settings implemented and host/build verified; target sign-off pending. Typed dim/off edits and explicit Apply/Save/Load/Defaults/Reset use generation-checked public UI ownership shared with CLI, with NVS outside critical sections and Reset commit-before-RAM publication. Serial-style Display view, preserved terminal/lease lifecycle, bounded login-bound operation results/no automatic replay.30 handlers/six sockets; no task/timer/stack-size/queue/schema/I2C/assets changes. Display7+shared HTTP, UI111+C/HTML/CSP, lifecycle23 and broad dispatcher/auth/owner/transport/throughput regressions PASS. Actual pre-edit build100,100 RAM/1,748,513 flash → final100,196/1,765,233 (+96/+16,720 B), pio26.09s PASS, CPU160 confirmed. Chromium geometry blocked by sandbox, target save/reboot/absent-panel/buttons-concurrency and heap/stack margins pending. [Exact implementation and target checklist](phase8d14_implementation.md). No target/full-M3 sign-off, reserve approval or 8D.15/later work. Supersedes historical no-8D.14-authorization statements below; prior scoped functional acceptance stands.
**Latest functional acceptance (2026-09-08):** User signs off implemented **8D.12/8D.13 and Settings presentation refinements** after boot/full-client-mix telemetry. Supersedes target-pending/no-signoff statements for this scope below, not historical build/test evidence or unreported checklist limits. [Acceptance record](phase8d12_13_implementation.md) retains all samples and counters: web sole writer + web/SSH/USB observers, both admins; no web send/queue/protocol failures or SSH handshake/auth/IO failures, but rejected input and one logout/disconnect retained without diagnosis. Loaded internal/DMA free31,512/23,756 B, minima2,276/156 B, largest20,480 B. Low conservative lifetime minima remain a resource follow-up, not approved reserves or proof of allocation failure. No full M3 claim or next-phase implementation authorization; 8D.14 awaits a separate request.
**Current implementation (2026-09-08):** User authorized **8D.12 and 8D.13 together**, backend and Network UI delivered. 8D.12 covers nonsecret STA/AP/profile/mDNS edits and persistence; 8D.13 adds explicit secret replacement/disabled-STA clear and connection controls. Profile selection means selecting a configuration to edit; connection control is canonical **Next profile**, not explicit-index selection. 27 handlers/six sockets, one bounded slot/timer, no task/stack/queue/schema growth. Backend/cookie Network PASS, UI agent97+renderer/CSP/review PASS, lifecycle agent21 PASS; backend P3 queue-drop-counter finding fixed. **Parent integrated suites and build PASS:** 24.99 s, 99,548 B RAM / 1,742,437 B flash (+288/+36,656 vs legacy-cleanup baseline). Parent UI97/CSP, lifecycle21, Network/HTTP policy, canonical console/accounts, transport/tickets, idle/store/diagnostics checks passed; exact attribution below. **Target behavior, timer heap/memory floors and HTTPD/dispatcher stack margins remain pending.** [8D.12/8D.13 implementation](phase8d12_13_implementation.md) is the exact API/SSID/secret/uncertainty contract and checklist. No Wi-Fi reset/default-secret/export, browser-shell policy widening, 8D.14 work, M3 completion or target sign-off. Supersedes historical next-request restrictions below; previous scoped acceptance stands.
@@ -184,7 +186,7 @@ Typed operations must preserve subsystem owner/lock/persistence contracts and co
| **8D.11 — SSH authorized keys** | List fingerprints and add/delete/clear supported public keys through bounded user APIs. | Ed25519/P-256 import, maximum supported length, malformed input, duplicates, targeted revocation, and unchanged SSH authentication behavior. No private-key upload/export or host-identity management. |
| **8D.12 — Network settings without secret mutation** | Secret-free STA/AP/profile and mDNS settings, non-secret edits, and explicit persistence through `wifi_manager`, `wifi_config`, `mdns_service`/`mdns_config`. | Responses never serialize saved PSKs; validate working/persisted semantics, live hostname changes, and behavior after connection loss. No new manager/task or Wi-Fi blob migration. Split mDNS into a follow-up if needed. |
| **8D.13 — Wi-Fi secrets and connection controls** | Explicit password replacement/clear semantics, bounded transient input, profile selection/reconnect and AP policy actions using manager-owned operations. | Preserve existing secrets when fields are omitted; never prefill saved secrets; document apply/save and likely connection loss; reconnect via STA/AP and verify UART0/USB recovery. No background secret fetch or general credential export. |
| **8D.14 — Display settings** | Typed local display configuration and explicit persistence via `local_ui_config`/public UI APIs. | Validate limits, save/reboot, absent-display behavior, and concurrent buttons/CLI edits. No I2C ownership changes or electrical diagnostics UI. |
| **8D.14 — Display settings** | **Implemented, host/build verified; target pending.** Typed local display configuration and explicit persistence via `local_ui_config`/generation-checked public UI APIs; [contract/evidence](phase8d14_implementation.md). | Host limits/storage/CLI-generation/activity/lifecycle regressions pass; actual save/reboot, absent-display and concurrent buttons/CLI target checklist remains pending. No I2C ownership changes or electrical diagnostics UI. |
| **8D.15 — Bounded network diagnostics** | Secret-free network status and a narrowly bounded diagnostic workflow through existing network facilities. | Diagnostic start/result/cancel/expiry behavior and concurrent CLI use are bounded; callbacks do not format/send HTTP directly. No unbounded result/history buffer or new generic jobs framework. Split asynchronous ping from read-only status if necessary. |
| **8D.16 — Broker client visibility and writer transfer** | Admin-only detailed client snapshot plus explicit confirmed writer assignment using existing broker APIs; smallest broker change only if authoritative generation-safe validation is missing. | Stale/disconnected/reused target fails without changing the current lease; exactly one writer; normal users cannot obtain management details or transfer. Test concurrent USB/SSH/browser requests. No transfer on page open or selection alone. |
| **8D.17 — Serial/Wi-Fi quick popovers** | UI-only reuse of completed typed endpoints, with full-page links and shared validation; start in `web_ui`. | Hover, focus, click/tap parity, Escape/outside-click dismissal, no mutation on opening, explicit apply/save, no secret exposure. No duplicate backend or new settings scope. |
+1
View File
@@ -34,6 +34,7 @@ idf_component_register(
"web_serial_settings.c"
"web_account_settings.c"
"web_network_settings.c"
"web_display_settings.c"
"web_admin_tickets.c"
"web_admin_transport.c"
"web_assets_data.c"
+16 -2
View File
@@ -19,6 +19,7 @@
#include "web_serial_settings.h"
#include "web_account_settings.h"
#include "web_network_settings.h"
#include "web_display_settings.h"
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
#define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U
@@ -87,6 +88,7 @@ typedef enum {
ADMIN_REQUEST_SERIAL_SETTINGS,
ADMIN_REQUEST_ACCOUNT_SETTINGS,
ADMIN_REQUEST_NETWORK_SETTINGS,
ADMIN_REQUEST_DISPLAY_SETTINGS,
} admin_request_origin_t;
typedef struct {
@@ -100,6 +102,7 @@ typedef struct {
uint32_t serial_settings_id;
uint32_t account_settings_id;
uint32_t network_settings_id;
uint32_t display_settings_id;
};
} admin_request_t;
@@ -685,6 +688,16 @@ esp_err_t admin_ssh_console_submit_network_settings(uint32_t id)
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
esp_err_t admin_ssh_console_submit_display_settings(uint32_t id)
{
taskENTER_CRITICAL(&s_lock);
bool ready = s_dispatch_ready;
taskEXIT_CRITICAL(&s_lock);
if (!ready || !id) return ESP_ERR_INVALID_STATE;
admin_request_t request = {.origin = ADMIN_REQUEST_DISPLAY_SETTINGS, .display_settings_id = id};
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
static void worker_task(void *context)
{
(void)context;
@@ -694,10 +707,11 @@ static void worker_task(void *context)
continue;
}
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS ||
request.origin == ADMIN_REQUEST_NETWORK_SETTINGS) {
request.origin == ADMIN_REQUEST_NETWORK_SETTINGS || request.origin == ADMIN_REQUEST_DISPLAY_SETTINGS) {
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) web_serial_settings_execute(request.serial_settings_id);
else if (request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) web_account_settings_execute(request.account_settings_id);
else web_network_settings_execute(request.network_settings_id);
else if (request.origin == ADMIN_REQUEST_NETWORK_SETTINGS) web_network_settings_execute(request.network_settings_id);
else web_display_settings_execute(request.display_settings_id);
secure_wipe(&request, sizeof(request));
continue;
}
+1
View File
@@ -18,6 +18,7 @@ extern "C" {
esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id);
esp_err_t admin_ssh_console_submit_account_settings(uint32_t id);
esp_err_t admin_ssh_console_submit_network_settings(uint32_t id);
esp_err_t admin_ssh_console_submit_display_settings(uint32_t id);
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
+57 -9
View File
@@ -203,6 +203,8 @@ static TickType_t s_diagnostic_hold_started;
static uint32_t s_external_activity_sequence;
static local_ui_config_t s_config;
static bool s_config_available;
static bool s_config_busy;
static uint32_t s_config_generation;
static const gpio_num_t s_button_gpios[LOCAL_STATUS_BUTTON_COUNT] = {
LOCAL_UI_BUTTON_PREVIOUS_GPIO,
@@ -1368,21 +1370,62 @@ esp_err_t local_status_ui_get_config(local_ui_config_t *config)
return available ? ESP_OK : ESP_ERR_INVALID_STATE;
}
esp_err_t local_status_ui_apply_config(const local_ui_config_t *config)
esp_err_t local_status_ui_get_settings(local_ui_config_t *config, uint32_t *generation)
{
esp_err_t error = local_ui_config_validate(config);
if (error != ESP_OK) {
if (config == NULL || generation == NULL) return ESP_ERR_INVALID_ARG;
portENTER_CRITICAL(&s_timing_mux);
esp_err_t error = !s_config_available ? ESP_ERR_INVALID_STATE :
s_config_busy ? ESP_ERR_TIMEOUT : ESP_OK;
if (error == ESP_OK) {
*config = s_config;
*generation = s_config_generation;
}
portEXIT_CRITICAL(&s_timing_mux);
return error;
}
esp_err_t local_status_ui_update_settings(local_ui_settings_action_t action,
uint32_t expected_generation, const local_ui_config_t *config, bool *loaded_defaults)
{
if (loaded_defaults != NULL) *loaded_defaults = false;
if (action < LOCAL_UI_SETTINGS_APPLY || action > LOCAL_UI_SETTINGS_RESET ||
(action == LOCAL_UI_SETTINGS_APPLY && local_ui_config_validate(config) != ESP_OK))
return ESP_ERR_INVALID_ARG;
local_ui_config_t candidate;
portENTER_CRITICAL(&s_timing_mux);
if (!s_config_available) {
portEXIT_CRITICAL(&s_timing_mux);
return ESP_ERR_INVALID_STATE;
esp_err_t error = !s_config_available ||
(expected_generation && expected_generation != s_config_generation) ||
s_config_generation == UINT32_MAX ? ESP_ERR_INVALID_STATE :
s_config_busy ? ESP_ERR_TIMEOUT : ESP_OK;
if (error == ESP_OK) {
candidate = action == LOCAL_UI_SETTINGS_APPLY ? *config : s_config;
s_config_busy = true;
}
s_config = *config;
++s_external_activity_sequence;
portEXIT_CRITICAL(&s_timing_mux);
return ESP_OK;
if (error != ESP_OK) return error;
bool stored = true;
if (action == LOCAL_UI_SETTINGS_LOAD) error = local_ui_config_load(&candidate, &stored);
if (action == LOCAL_UI_SETTINGS_DEFAULTS || action == LOCAL_UI_SETTINGS_RESET)
local_ui_config_defaults(&candidate);
if (action == LOCAL_UI_SETTINGS_SAVE || action == LOCAL_UI_SETTINGS_RESET)
error = local_ui_config_save(&candidate);
portENTER_CRITICAL(&s_timing_mux);
if (error == ESP_OK && action != LOCAL_UI_SETTINGS_SAVE) {
s_config = candidate;
++s_config_generation;
++s_external_activity_sequence;
}
s_config_busy = false;
portEXIT_CRITICAL(&s_timing_mux);
if (error == ESP_OK && loaded_defaults != NULL) *loaded_defaults = !stored;
return error;
}
esp_err_t local_status_ui_apply_config(const local_ui_config_t *config)
{
return local_status_ui_update_settings(LOCAL_UI_SETTINGS_APPLY, 0, config, NULL);
}
esp_err_t local_status_ui_start(const local_ui_config_t *config)
@@ -1397,6 +1440,11 @@ esp_err_t local_status_ui_start(const local_ui_config_t *config)
portENTER_CRITICAL(&s_timing_mux);
s_config = *config;
if (s_config_generation == UINT32_MAX) {
portEXIT_CRITICAL(&s_timing_mux);
return ESP_ERR_INVALID_STATE;
}
++s_config_generation;
s_config_available = true;
portEXIT_CRITICAL(&s_timing_mux);
+16
View File
@@ -21,6 +21,22 @@ esp_err_t local_status_ui_start(const local_ui_config_t *config);
esp_err_t local_status_ui_get_config(local_ui_config_t *config);
esp_err_t local_status_ui_apply_config(const local_ui_config_t *config);
typedef enum {
LOCAL_UI_SETTINGS_APPLY, LOCAL_UI_SETTINGS_SAVE, LOCAL_UI_SETTINGS_LOAD,
LOCAL_UI_SETTINGS_DEFAULTS, LOCAL_UI_SETTINGS_RESET
} local_ui_settings_action_t;
/* Zero-wait RAM projection. Generation is nonzero and never wraps. */
esp_err_t local_status_ui_get_settings(local_ui_config_t *config, uint32_t *generation);
/* Reserve configuration across storage IO, without holding a critical section.
* Zero expected_generation is for canonical unconditional CLI operations only.
* Nonzero stale generations return ESP_ERR_INVALID_STATE; contention returns
* ESP_ERR_TIMEOUT. Load retains the canonical default fallback. Reset commits
* defaults before publishing RAM, so a storage failure needs no RAM rollback.
* No display IO occurs here; successful RAM changes signal renderer activity. */
esp_err_t local_status_ui_update_settings(local_ui_settings_action_t action,
uint32_t expected_generation, const local_ui_config_t *config, bool *loaded_defaults);
/* Preserve a manually selected display diagnostic for a bounded interval. */
void local_status_ui_hold_for_diagnostics(void);
+9 -24
View File
@@ -87,7 +87,8 @@ static int apply_parameter(const char *parameter, const char *text)
}
local_ui_config_t config;
esp_err_t error = local_status_ui_get_config(&config);
uint32_t generation;
esp_err_t error = local_status_ui_get_settings(&config, &generation);
if (error != ESP_OK) {
printf("Could not read local UI configuration: %s\n", esp_err_to_name(error));
return 1;
@@ -102,7 +103,7 @@ static int apply_parameter(const char *parameter, const char *text)
return 1;
}
error = local_status_ui_apply_config(&config);
error = local_status_ui_update_settings(LOCAL_UI_SETTINGS_APPLY, generation, &config, NULL);
if (error != ESP_OK) {
printf("Invalid display configuration: %s. When both timeouts are enabled, off must be later than dim.\n",
esp_err_to_name(error));
@@ -122,11 +123,7 @@ static int command_display(int argc, char **argv)
return apply_parameter(argv[2], argv[3]);
}
if (argc == 2 && strcmp(argv[1], "save") == 0) {
local_ui_config_t config;
esp_err_t error = local_status_ui_get_config(&config);
if (error == ESP_OK) {
error = local_ui_config_save(&config);
}
esp_err_t error = local_status_ui_update_settings(LOCAL_UI_SETTINGS_SAVE, 0, NULL, NULL);
if (error != ESP_OK) {
printf("Could not save display configuration: %s\n", esp_err_to_name(error));
return 1;
@@ -136,17 +133,15 @@ static int command_display(int argc, char **argv)
}
if (argc == 2 && strcmp(argv[1], "load") == 0) {
local_ui_config_t config;
bool used_stored_config;
esp_err_t error = local_ui_config_load(&config, &used_stored_config);
if (error == ESP_OK) {
error = local_status_ui_apply_config(&config);
}
bool loaded_defaults = false;
esp_err_t error = local_status_ui_update_settings(LOCAL_UI_SETTINGS_LOAD, 0, NULL, &loaded_defaults);
if (error == ESP_OK) error = local_status_ui_get_config(&config);
if (error != ESP_OK) {
printf("Could not load display configuration: %s\n", esp_err_to_name(error));
return 1;
}
printf("Loaded %s display configuration.\n",
used_stored_config ? "stored" : "default");
loaded_defaults ? "default" : "stored");
print_config(&config);
return 0;
}
@@ -163,19 +158,9 @@ static int command_display(int argc, char **argv)
return 0;
}
if (argc == 2 && strcmp(argv[1], "reset") == 0) {
local_ui_config_t previous;
local_ui_config_t defaults;
local_ui_config_defaults(&defaults);
esp_err_t error = local_status_ui_get_config(&previous);
if (error == ESP_OK) {
error = local_status_ui_apply_config(&defaults);
}
if (error == ESP_OK) {
error = local_ui_config_reset_storage();
if (error != ESP_OK) {
(void)local_status_ui_apply_config(&previous);
}
}
esp_err_t error = local_status_ui_update_settings(LOCAL_UI_SETTINGS_RESET, 0, NULL, NULL);
if (error != ESP_OK) {
printf("Could not reset display configuration: %s\n", esp_err_to_name(error));
return 1;
+236
View File
@@ -0,0 +1,236 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_display_settings.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "secure_random.h"
#include "local_status_ui.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
enum { APPLY, SAVE, LOAD, DEFAULTS, RESET, ACTION_COUNT };
static const char *const s_actions[] = {"apply", "save", "load", "defaults", "reset"};
enum { IDLE, PENDING, OK, FAILED, CANCELLED, LOADED_DEFAULTS, CONFLICT };
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "loaded_defaults", "conflict"};
typedef struct {
uint32_t id;
web_session_id_t session;
user_principal_t principal;
int64_t deadline;
local_ui_config_t config;
uint32_t generation;
unsigned action, state;
} display_operation_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static display_operation_t s_operation;
static uint32_t s_next_id;
/* Deliberately narrow flat JSON: ASCII names/enums, unsigned decimal integers,
* no escapes, nesting, duplicate/unknown fields, exponent or fractional values. */
static bool parse(const char *body, size_t length, display_operation_t *operation)
{
const char *keys[] = {"action", "generation", "dim_seconds", "off_seconds"};
unsigned seen = 0;
size_t pos = 0;
local_ui_config_defaults(&operation->config);
operation->action = ACTION_COUNT;
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
TAKE('{');
for (unsigned field = 0; field < 4; ++field) {
if (field) { TAKE(','); }
TAKE('"');
size_t start = pos;
while (pos < length && body[pos] != '"') ++pos;
if (pos == length) return false;
unsigned key = 0;
for (; key < 4; ++key)
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
if (key == 4 || (seen & (1U << key))) return false;
++pos; TAKE(':'); SPACE();
if (key == 0) {
TAKE('"'); start = pos;
while (pos < length && body[pos] != '"') ++pos;
if (pos == length) return false;
for (unsigned i = 0; i < ACTION_COUNT; ++i)
if (strlen(s_actions[i]) == pos - start && !memcmp(body + start, s_actions[i], pos - start)) operation->action = i;
if (operation->action == ACTION_COUNT) return false;
++pos;
} else {
uint32_t number = 0;
start = pos;
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
unsigned digit = (unsigned)(body[pos++] - '0');
if (number > (UINT32_MAX - digit) / 10U) return false;
number = number * 10U + digit;
}
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
if (key == 1) operation->generation = number;
if (key == 2) operation->config.dim_timeout_seconds = number;
if (key == 3) operation->config.off_timeout_seconds = number;
}
seen |= 1U << key;
SPACE();
if (pos < length && body[pos] == '}') break;
}
TAKE('}'); SPACE();
#undef TAKE
#undef SPACE
return pos == length && seen == (operation->action == APPLY ? 15U : 3U) &&
operation->generation != 0 && local_ui_config_validate(&operation->config) == ESP_OK;
}
void web_display_settings_execute(uint32_t id)
{
display_operation_t operation;
taskENTER_CRITICAL(&s_lock);
operation = s_operation;
taskEXIT_CRITICAL(&s_lock);
if (!id || operation.id != id || operation.state != PENDING) {
secure_wipe(&operation, sizeof(operation));
return;
}
bool current = false;
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, &current);
unsigned state = CANCELLED;
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
esp_timer_get_time() < operation.deadline) {
/* The owner checks the selected generation and reserves all config
* mutations, including CLI callers, across storage IO. Buttons only
* signal activity: they never replace configuration or own this gate. */
bool defaults = false;
static const local_ui_settings_action_t actions[] = {
LOCAL_UI_SETTINGS_APPLY, LOCAL_UI_SETTINGS_SAVE, LOCAL_UI_SETTINGS_LOAD,
LOCAL_UI_SETTINGS_DEFAULTS, LOCAL_UI_SETTINGS_RESET
};
error = local_status_ui_update_settings(actions[operation.action], operation.generation,
&operation.config, &defaults);
state = error == ESP_OK ? (defaults ? LOADED_DEFAULTS : OK) :
error == ESP_ERR_INVALID_STATE ? CONFLICT : FAILED;
}
taskENTER_CRITICAL(&s_lock);
if (s_operation.id == id && s_operation.state == PENDING) {
s_operation.state = state;
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
secure_wipe(&s_operation.config, sizeof(s_operation.config));
}
taskEXIT_CRITICAL(&s_lock);
secure_wipe(&operation, sizeof(operation));
}
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(request, status);
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
return web_httpd_unread_body(request) ? ESP_FAIL : error;
}
esp_err_t web_display_operation_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
bool mutation = request->method == HTTP_POST;
esp_err_t error = mutation
? web_cookie_auth_require_json(request, 256, &view, &allowed)
: web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
display_operation_t operation = {0};
if (mutation) {
char type[40] = {0}, body[256];
size_t received = 0;
bool valid = request->content_len &&
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
/* Finite bytes and receive calls; timeout/error closes, never retry/drain. */
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
int count = httpd_req_recv(request, body + received, request->content_len - received);
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
else received += (size_t)count;
}
valid = valid && received == request->content_len && parse(body, received, &operation);
secure_wipe(body, sizeof(body));
if (!valid) {
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_display_request\"}");
goto done;
}
operation.session = view.id;
operation.principal = view.principal;
operation.deadline = esp_timer_get_time() + 30000000LL;
operation.state = PENDING;
taskENTER_CRITICAL(&s_lock);
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
if (!busy) {
operation.id = ++s_next_id;
s_operation = operation;
}
taskEXIT_CRITICAL(&s_lock);
if (busy || admin_ssh_console_submit_display_settings(operation.id) != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
taskEXIT_CRITICAL(&s_lock);
error = httpd_resp_set_hdr(request, "Retry-After", "1");
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
secure_wipe(&operation, sizeof(operation));
goto done;
}
} else {
taskENTER_CRITICAL(&s_lock);
if (s_operation.session == view.id) {
operation.id = s_operation.id;
operation.action = s_operation.action;
operation.state = s_operation.state;
}
taskEXIT_CRITICAL(&s_lock);
}
char response[96];
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]);
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
respond(request, mutation ? "202 Accepted" : "200 OK", response);
secure_wipe(&operation, sizeof(operation));
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
esp_err_t web_display_settings_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
esp_err_t error = web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
local_ui_config_t config;
uint32_t generation;
error = local_status_ui_get_settings(&config, &generation);
if (error != ESP_OK) {
error = respond(request, "503 Service Unavailable", "{\"error\":\"display_unavailable\"}");
goto done;
}
char response[128];
int written = snprintf(response, sizeof(response),
"{\"generation\":%" PRIu32 ",\"dim_seconds\":%" PRIu32 ",\"off_seconds\":%" PRIu32 "}",
generation, config.dim_timeout_seconds, config.off_timeout_seconds);
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
respond(request, "200 OK", response);
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
+9
View File
@@ -0,0 +1,9 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdint.h>
#include "esp_http_server.h"
/* Optional admin-only RAM snapshot and typed dispatcher admission/results. */
esp_err_t web_display_settings_handler(httpd_req_t *request);
esp_err_t web_display_operation_handler(httpd_req_t *request);
void web_display_settings_execute(uint32_t id);
+15 -1
View File
@@ -26,6 +26,7 @@
#include "web_serial_settings.h"
#include "web_account_settings.h"
#include "web_network_settings.h"
#include "web_display_settings.h"
#include "web_admin_transport.h"
#include "web_session_store.h"
#include "web_cookie_auth.h"
@@ -413,6 +414,15 @@ static const httpd_uri_t s_account_generate_password_uri = {
static const httpd_uri_t s_network_uri = {
.uri = "/api/settings/network", .method = HTTP_GET, .handler = web_network_snapshot_handler,
};
static const httpd_uri_t s_display_uri = {
.uri = "/api/settings/display", .method = HTTP_GET, .handler = web_display_settings_handler,
};
static const httpd_uri_t s_display_operation_get_uri = {
.uri = "/api/settings/display-operation", .method = HTTP_GET, .handler = web_display_operation_handler,
};
static const httpd_uri_t s_display_operation_post_uri = {
.uri = "/api/settings/display-operation", .method = HTTP_POST, .handler = web_display_operation_handler,
};
static const httpd_uri_t s_network_operation_get_uri = {
.uri = "/api/settings/network-operation", .method = HTTP_GET, .handler = web_network_operation_handler,
};
@@ -635,7 +645,7 @@ esp_err_t web_server_start(void)
config.httpd.max_open_sockets = 6;
config.httpd.max_uri_handlers =
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) +
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 13U;
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 16U;
/* Exhaustion rejects new sockets, never evicts an existing serial writer. */
config.httpd.lru_purge_enable = false;
config.httpd.recv_wait_timeout = 1;
@@ -699,6 +709,10 @@ esp_err_t web_server_start(void)
web_httpd_register_optional_get(server, &s_network_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_network_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_network_operation_get_uri.uri, HTTP_GET);
if (web_httpd_register_optional_get(server, &s_display_uri) == ESP_OK &&
web_httpd_register_optional_get(server, &s_display_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_display_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_display_operation_get_uri.uri, HTTP_GET);
}
if (error != ESP_OK) {
web_cookie_auth_stop();
+158 -2
View File
@@ -185,7 +185,23 @@ static const char s_index_html[] =
"<section id=\"serial-settings\" class=\"settings-page\" aria-label=\"Serial settings\" hidden>"
"<div class=\"serial-actions\"><button id=\"settings-serial\" class=\"button\" type=\"button\" aria-pressed=\"true\">Serial settings</button>"
"<button id=\"settings-accounts\" class=\"button\" type=\"button\" aria-pressed=\"false\">Accounts</button>"
"<button id=\"settings-network\" class=\"button\" type=\"button\" aria-pressed=\"false\">Network</button></div>"
"<button id=\"settings-network\" class=\"button\" type=\"button\" aria-pressed=\"false\">Network</button>"
"<button id=\"settings-display\" class=\"button\" type=\"button\" aria-pressed=\"false\">Display</button></div>"
"<div id=\"display-settings\" hidden><h2>Display</h2>\n"
"<p class=\"connection-detail\">Working OLED inactivity settings, not saved NVS values. Zero disables a transition. Each timeout is 086400 seconds; when both are enabled, Off must be later than Dim.</p>\n"
"<p class=\"connection-detail\">Apply and Defaults change RAM only. Save persists the working snapshot, not browser drafts. Load discards drafts and uses stored settings, or defaults if storage is absent/incompatible; it does not change NVS. Reset saves defaults and applies them. Refresh discards drafts. Intervening configuration edits reject stale operations: Refresh and review before retrying.</p>\n"
"<p class=\"connection-detail\">Configuration works with an absent panel if the local UI task is available; success does not prove the panel changed. Buttons keep their normal wake/reprobe behavior. Navigation and these settings leave both terminals and the writer lease unchanged.</p>\n"
"<button id=\"display-refresh\" class=\"button\" type=\"button\">Refresh</button>\n"
"<p id=\"display-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"display-values\" class=\"settings-values\" hidden><dt>Dim after (seconds)</dt><dd id=\"display-dim_seconds\"></dd><dt>Off after (seconds)</dt><dd id=\"display-off_seconds\"></dd></dl>\n"
"<div id=\"display-edit\" class=\"settings-edit\" hidden><label>Dim after (seconds)<input id=\"display-edit-dim_seconds\" type=\"number\" min=\"0\" max=\"86400\" step=\"1\"></label><label>Off after (seconds)<input id=\"display-edit-off_seconds\" type=\"number\" min=\"0\" max=\"86400\" step=\"1\"></label></div>\n"
"<div class=\"serial-actions\">\n"
"<button id=\"display-apply\" class=\"button\" type=\"button\">Apply to RAM</button>\n"
"<button id=\"display-save\" class=\"button\" type=\"button\">Save working config</button>\n"
"<button id=\"display-load\" class=\"button\" type=\"button\">Load saved config</button>\n"
"<button id=\"display-defaults\" class=\"button\" type=\"button\">Defaults in RAM</button>\n"
"<button id=\"display-reset\" class=\"button\" type=\"button\">Reset and save defaults</button>\n"
"<button id=\"display-result\" class=\"button\" type=\"button\">Check Operation Result</button>\n"
"</div><p id=\"display-operation-detail\" class=\"connection-detail\" role=\"status\">Check Result after uncertainty; navigation, expiry or timeout does not cancel admitted work. No automatic mutation retry.</p></div>\n"
"<div id=\"network-settings\" hidden><h2>Network</h2>"
"<p class=\"connection-detail\">Edits apply to RAM only. Save persists the device working configuration, NOT browser drafts. Refresh discards drafts. "
"Wi-Fi Load uses stored configuration only; missing or invalid storage leaves RAM unchanged. No Wi-Fi defaults/reset. "
@@ -377,7 +393,7 @@ static const char s_app_js[] =
" element('serial-result').disabled = busy;\n"
"}\n"
"function clearSettings() {\n"
" clearAccounts(); clearNetwork();\n"
" clearAccounts(); clearNetwork(); clearDisplay();\n"
" if (!serialAuto && serialOperationPending) element('serial-operation-detail').textContent = serialOutcomeWarning + 'Operation outcome pending or unknown. Select Check Result on return; navigation does not cancel backend work.';\n"
" stopSerialAuto(true);\n"
" if (settingsAbort) settingsAbort.abort();\n"
@@ -391,6 +407,7 @@ static const char s_app_js[] =
"async function refreshSettings() {\n"
" if (settingsDomain === 'accounts') return refreshAccounts();\n"
" if (settingsDomain === 'network') return refreshNetwork();\n"
" if (settingsDomain === 'display') return refreshDisplay();\n"
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort || serialAuto) return;\n"
" settingsHost.hidden = false;\n"
" const controller = new AbortController(), generation = workGeneration; settingsAbort = controller;\n"
@@ -483,6 +500,144 @@ static const char s_app_js[] =
" }\n"
" }\n"
"}\n"
"const displayDetail = element('display-detail'), displayFields = ['dim_seconds', 'off_seconds'];\n"
"let displayAbort = null, displayGeneration = 0, displayOperationAction = '';\n"
"function displayValid(v) { return displayFields.every(k => Number.isInteger(v[k]) && v[k] >= 0 && v[k] <= 86400) && (!v.dim_seconds || !v.off_seconds || v.off_seconds > v.dim_seconds); }\n"
"const displayActions = ['apply', 'save', 'load', 'defaults', 'reset'];\n"
"let displayOperationId = 0, displayOperationPending = false, displayAwaitingAck = false;\n"
"let displayOutcomeWarning = '', displayAuto = null;\n"
"function stopDisplayAuto(recovery = false) {\n"
" if (!displayAuto) return;\n"
" window.clearTimeout(displayAuto.timer); window.clearTimeout(displayAuto.deadline); displayAuto = null;\n"
" if (recovery) element('display-operation-detail').textContent += ' Automatic checking stopped; outcome still uncertain. Select Check Result; do not resubmit.';\n"
"}\n"
"function expireDisplayAuto(auto) {\n"
" if (displayAuto !== auto) return;\n"
" stopDisplayAuto(true);\n"
" if (displayAbort) displayAbort.abort();\n"
" displayAbort = null; displayButtons();\n"
"}\n"
"function scheduleDisplayCheck() {\n"
" const auto = displayAuto;\n"
" if (!auto) return;\n"
" if (auto.attempts >= 10) { stopDisplayAuto(true); displayButtons(); return; }\n"
" auto.timer = window.setTimeout(() => {\n"
" if (displayAuto !== auto) return;\n"
" if (performance.now() >= auto.until) { expireDisplayAuto(auto); return; }\n"
" ++auto.attempts; displayOperation(null, true);\n"
" }, 1000);\n"
"}\n"
"function startDisplayAuto() {\n"
" const auto = {attempts: 0, timer: 0, deadline: 0, until: performance.now() + 15000}; displayAuto = auto;\n"
" auto.deadline = window.setTimeout(() => expireDisplayAuto(auto), 15000);\n"
" scheduleDisplayCheck();\n"
"}\n"
"function displayButtons() {\n"
" const busy = !!displayAbort || !!displayAuto;\n"
" for (const action of displayActions) element('display-' + action).disabled = busy || displayOperationPending || !displayGeneration;\n"
" for (const key of displayFields) element('display-edit-' + key).disabled = busy || displayOperationPending || !displayGeneration;\n"
" element('display-refresh').disabled = busy;\n"
" element('display-result').disabled = busy;\n"
"}\n"
"function clearDisplay() {\n"
" if (!displayAuto && displayOperationPending) element('display-operation-detail').textContent = displayOutcomeWarning + 'Outcome pending or unknown. Check Result on return; navigation does not cancel work.';\n"
" stopDisplayAuto(true);\n"
" if (displayAbort) displayAbort.abort(); displayAbort = null; displayGeneration = 0;\n"
" element('display-values').hidden = true; element('display-edit').hidden = true;\n"
" for (const key of displayFields) { element('display-' + key).textContent = ''; element('display-edit-' + key).value = ''; }\n"
" displayDetail.textContent = 'Select Refresh to read current values.'; displayButtons();\n"
"}\n"
"async function refreshDisplay() {\n"
" if (settingsDomain !== 'display' || selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || displayAbort || displayAuto) return;\n"
" const controller = new AbortController(), generation = workGeneration; displayAbort = controller; displayButtons();\n"
" const current = () => displayAbort === controller && selected === 'settings' && settingsDomain === 'display';\n"
" displayDetail.textContent = 'Reading display configuration... Previous snapshot is stale until refreshed.';\n"
" try {\n"
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
" const {status, payload: v} = await api('/api/settings/display', generation, {signal: controller.signal, limit: 128, current});\n"
" if (status !== 200 || !v || Object.keys(v).length !== 3 || !Number.isInteger(v.generation) || v.generation < 1 || v.generation > 4294967295 || !displayValid(v)) throw new Error('Invalid snapshot');\n"
" displayGeneration = v.generation;\n"
" for (const key of displayFields) { element('display-' + key).textContent = String(v[key]); element('display-edit-' + key).value = String(v[key]); }\n"
" element('display-values').hidden = false; element('display-edit').hidden = false;\n"
" displayDetail.textContent = (displayOperationPending ? 'Snapshot may be stale: operation outcome pending or unknown. ' : 'Working snapshot loaded. ') + 'Apply changes RAM; Save explicitly persists this working generation. Refresh replaces drafts.';\n"
" } catch (error) {\n"
" if (live(generation) && current()) { displayGeneration = 0; displayDetail.textContent = (error.status ? error.message : 'Display snapshot unavailable or invalid.') + ' Select Refresh to retry.'; }\n"
" } finally { if (current()) { displayAbort = null; displayButtons(); } }\n"
"}\n"
"async function displayOperation(action, automatic = false) {\n"
" if (settingsDomain !== 'display') return;\n"
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || displayAbort || (action && displayOperationPending)) return;\n"
" if (!automatic && displayAuto) return;\n"
" const detail = element('display-operation-detail');\n"
" let refresh = false, poll = false;\n"
" let body;\n"
" if (action) {\n"
" if (element('display-edit').hidden || !displayGeneration) return;\n"
" const value = {action, generation: displayGeneration};\n"
" if (action === 'apply') {\n"
" for (const key of displayFields) {\n"
" const text = element('display-edit-' + key).value;\n"
" if (!/^(0|[1-9][0-9]{0,4})$/.test(text)) { detail.textContent = 'Enter whole-number seconds, 086400.'; return; }\n"
" value[key] = Number(text);\n"
" }\n"
" if (!displayValid(value)) { detail.textContent = 'Timeouts must be 086400; Off must be later than Dim when both are enabled.'; return; }\n"
" }\n"
" if (action === 'reset' && !window.confirm('Reset applies defaults and overwrites saved NVS configuration. Continue?')) return;\n"
" body = JSON.stringify(value);\n"
" if (new TextEncoder().encode(body).length > 256) return;\n"
" }\n"
" const controller = new AbortController(), generation = workGeneration; displayAbort = controller;\n"
" const auto = automatic ? displayAuto : null;\n"
" const current = () => {\n"
" if (auto && displayAuto === auto && performance.now() >= auto.until) expireDisplayAuto(auto);\n"
" return displayAbort === controller && selected === 'settings' && settingsDomain === 'display';\n"
" };\n"
" element('display-refresh').disabled = true; displayButtons();\n"
" detail.textContent = displayOutcomeWarning + (action ? (action === 'apply' ? 'Applying' : action) + '... Submitting once; completion will be checked automatically.' : 'Reading latest result for this login...');\n"
" displayDetail.textContent = 'Snapshot stale: operation pending or outcome not yet checked.';\n"
" try {\n"
" if (!await loadSession(generation, controller.signal, false)) throw new Error('Session check cancelled');\n"
" if (!current()) return;\n"
" if (action) { displayOperationPending = true; displayAwaitingAck = true; }\n"
" const {status, payload: result} = await api('/api/settings/display-operation', generation, {method: action ? 'POST' : 'GET', body, signal: controller.signal, limit: 96, current});\n"
" if (status !== (action ? 202 : 200) || !result || Object.keys(result).length !== 3 || !Number.isInteger(result.id) || result.id < 0 || result.id > 4294967295 ||\n"
" (result.state === 'loaded_defaults' && result.action !== 'load') ||\n"
" !['none', ...displayActions].includes(result.action) || !['idle','pending','ok','failed','cancelled','loaded_defaults','conflict'].includes(result.state) ||\n"
" ((result.id === 0) !== (result.state === 'idle')) || ((result.id === 0) !== (result.action === 'none')) ||\n"
" (action && (!result.id || result.action !== action || result.state !== 'pending'))) throw new Error('Invalid operation response');\n"
" if (!action && displayOperationId && displayOperationId === result.id && displayOperationAction && displayOperationAction !== result.action) throw new Error('Operation action changed for the same ID');\n"
" const uncertain = !action && displayAwaitingAck;\n"
" const replaced = !action && displayOperationId && displayOperationId !== result.id;\n"
" if (action) displayOutcomeWarning = '';\n"
" else if (uncertain) displayOutcomeWarning = 'Submission acknowledgement was lost; this latest result may belong to an earlier operation or another tab. Inspect before retrying. ';\n"
" else if (replaced) displayOutcomeWarning = 'Previous result was replaced or unavailable; its outcome is unknown. ';\n"
" displayOperationId = result.id; displayOperationAction = result.action; displayOperationPending = result.state === 'pending'; displayAwaitingAck = false;\n"
" const messages = {idle: 'No retained result. Outcome may be unknown; inspect working settings and CLI storage before retrying.',\n"
" pending: 'Pending: queued or executing; do not resubmit. Automatic checks are bounded; Check Result is available for recovery.',\n"
" ok: 'Operation completed. Apply/Defaults change RAM only; Save/Reset persist NVS.',\n"
" loaded_defaults: 'Load applied defaults because saved storage was absent or incompatible. NVS was not changed.',\n"
" failed: 'Operation failed or configuration was busy. RAM was not changed by this operation; inspect working settings and storage before retrying.',\n"
" conflict: 'Working configuration changed or the UI is unavailable. Refresh and review before retrying; this operation made no change.',\n"
" cancelled: 'Operation rejected before execution because the login or queue deadline was no longer current.'};\n"
" detail.textContent = displayOutcomeWarning + result.action + ': ' + messages[result.state];\n"
" poll = !replaced && result.state === 'pending' && (!!action || automatic);\n"
" refresh = !action && result.state !== 'pending' && result.state !== 'idle';\n"
" } catch (error) {\n"
" if (live(generation) && current()) detail.textContent = displayOutcomeWarning + (error.status ? error.message : 'Operation outcome unknown.') + ' Check Result and Refresh before any explicit retry. No automatic retry.';\n"
" } finally {\n"
" if (current()) {\n"
" displayAbort = null;\n"
" if (poll) { if (action) startDisplayAuto(); else scheduleDisplayCheck(); }\n"
" else stopDisplayAuto();\n"
" displayButtons();\n"
" if (refresh) await refreshDisplay();\n"
" }\n"
" }\n"
"}\n"
"element('settings-display').addEventListener('click', () => selectSettingsDomain('display'));\n"
"element('display-refresh').addEventListener('click', refreshDisplay);\n"
"element('display-result').addEventListener('click', () => displayOperation(null));\n"
"for (const action of displayActions) element('display-' + action).addEventListener('click', () => displayOperation(action));\n"
"let settingsDomain = 'serial', accounts = [], accountsAbort = null, accountId = 0, accountPending = false, accountAwaitingAck = false, accountWarning = '';\n"
"let keysAbort = null, accountKeys = [], keysIdentity = '';\n"
"function keyIdentity() { const t = accounts[Number(element('account-target').value)]; return t ? JSON.stringify([t.username,t.user_id,t.auth_generation]) : ''; }\n"
@@ -561,6 +716,7 @@ static const char s_app_js[] =
" if (!sessionVerified || accountRole !== 'admin' || selected !== 'settings' || domain === settingsDomain) return;\n"
" clearSettings(); settingsDomain = domain; settingsHost.hidden = false;\n"
" element('serial-settings-content').hidden = domain !== 'serial'; element('account-settings').hidden = domain !== 'accounts'; element('network-settings').hidden = domain !== 'network';\n"
" element('display-settings').hidden = domain !== 'display'; element('settings-display').setAttribute('aria-pressed', String(domain === 'display'));\n"
" element('settings-network').setAttribute('aria-pressed', String(domain === 'network'));\n"
" element('settings-serial').setAttribute('aria-pressed', String(domain === 'serial')); element('settings-accounts').setAttribute('aria-pressed', String(domain === 'accounts'));\n"
" refreshSettings();\n"
+2 -1
View File
@@ -31,7 +31,8 @@ typedef int *SemaphoreHandle_t;
#define pdMS_TO_TICKS(x) (x)
#define CONSOLE_COMPLETION_OUTPUT_CAPACITY 1024U
static unsigned lock_depth, ticks, runs, actions;
static uint32_t serial_settings_executed, account_settings_executed, network_settings_executed;
static uint32_t serial_settings_executed, account_settings_executed, network_settings_executed, display_settings_executed;
static void web_display_settings_execute(uint32_t id) { assert(!lock_depth); display_settings_executed = id; }
static void web_network_settings_execute(uint32_t id) { assert(!lock_depth); network_settings_executed = id; }
static void web_account_settings_execute(uint32_t id) { assert(!lock_depth); account_settings_executed = id; }
static unsigned serial_settings_preceding_runs, queue_send_wait;
+15
View File
@@ -364,5 +364,20 @@ int main(void)
assert(serial_settings_executed == 31 && network_settings_executed == 32 && account_settings_executed == 33);
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
puts("PASS: typed Network queues only an ID, shares unchanged queue, executes outside lock without command runner");
assert(admin_ssh_console_submit_display_settings(0) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = false;
assert(admin_ssh_console_submit_display_settings(1) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = true; queue_full = true;
assert(admin_ssh_console_submit_display_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
queue_full = false;
assert(admin_ssh_console_submit_serial_settings(41) == ESP_OK);
assert(admin_ssh_console_submit_network_settings(42) == ESP_OK);
assert(admin_ssh_console_submit_account_settings(43) == ESP_OK);
assert(admin_ssh_console_submit_display_settings(44) == ESP_OK && queue_send_wait == 0);
assert(admin_ssh_console_submit_display_settings(45) == ESP_ERR_TIMEOUT && s_request_queue->count == 4);
pump(worker_task);
assert(serial_settings_executed == 41 && network_settings_executed == 42 && account_settings_executed == 43 && display_settings_executed == 44);
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
puts("PASS: typed Display IDs share all four unchanged queue slots; full/not-ready admission fails, routing never invokes command runner");
puts("PASS: admission/identity, two owners, completion contention/reopen, history, queued stale/revoked work, UART dispatch, hidden/disconnected prompts, exit-to-SELF_CLOSE, deferred rejection/drain/close, 5s output backpressure");
}
+88 -24
View File
@@ -37,8 +37,8 @@ def define(path, name):
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
source, re.M | re.S)
# Non-array declarations have no brackets; explicit shape avoids silent omission.
if len(uri_tables) != 24:
raise RuntimeError('Review URI extraction: expected 22 descriptors and two tables')
if len(uri_tables) != 27:
raise RuntimeError('Review URI extraction: expected 25 descriptors and two tables')
state = source[source.index('static SemaphoreHandle_t s_server_mutex;'):
source.index('static esp_err_t ensure_mutex(void)')]
header = (ROOT / 'src/web_server.h').read_text()
@@ -134,6 +134,26 @@ HANDLER(serial_settings_handler)
HANDLER(web_serial_settings_handler) HANDLER(web_account_settings_handler)
HANDLER(web_account_generate_password_handler) HANDLER(web_account_keys_handler)
HANDLER(web_network_snapshot_handler) HANDLER(web_network_operation_handler)
HANDLER(web_display_settings_handler) HANDLER(web_display_operation_handler)
static unsigned display_calls, display_allocations, display_fail_at;
static esp_err_t display_register(httpd_handle_t s, const httpd_uri_t *uri) {
assert(s == SERVER && auth_live && ssl_live && !locked);
assert(!uri->is_websocket && !uri->handle_ws_control_frames && !uri->user_ctx);
++display_calls;
if (display_calls == 1) {
assert(!strcmp(uri->uri, "/api/settings/display") && uri->method == HTTP_GET);
assert(uri->handler == web_display_settings_handler);
} else {
assert(!strcmp(uri->uri, "/api/settings/display-operation"));
assert(uri->method == (display_calls == 2 ? HTTP_GET : HTTP_POST));
assert(uri->handler == web_display_operation_handler && display_calls <= 3);
}
/* Model the adapter's staged descriptor/name allocations, before publication. */
for (unsigned allocation = 0; allocation < 2; ++allocation)
if (++display_allocations == display_fail_at) return ESP_ERR_NO_MEM;
registered[registered_count++] = uri;
return ESP_OK;
}
static unsigned network_calls, network_allocations, network_fail_at;
static esp_err_t network_register(httpd_handle_t s, const httpd_uri_t *uri) {
assert(s == SERVER && auth_live && ssl_live && !locked);
@@ -169,7 +189,7 @@ static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t
static esp_err_t httpd_ssl_start(httpd_handle_t *server, const httpd_ssl_config_t *config) {
assert(!locked && auth_live && !ssl_live); ++ssl_starts;
assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable);
assert(config->httpd.max_uri_handlers == 27 && config->port_secure == 443);
assert(config->httpd.max_uri_handlers == 30 && config->port_secure == 443);
assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1);
assert(config->tls_handshake_timeout_ms == 5000);
assert(config->user_cb == tls_session_callback);
@@ -207,6 +227,7 @@ static esp_err_t account_register(httpd_handle_t s, const httpd_uri_t *uri) {
}
static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_uri_t *uri) {
assert(uri->method == HTTP_GET);
if (uri->handler == web_display_settings_handler || uri->handler == web_display_operation_handler) return display_register(s, uri);
if (uri->handler == web_network_snapshot_handler || uri->handler == web_network_operation_handler)
return network_register(s, uri);
if (uri->handler == web_account_settings_handler) return account_register(s, uri);
@@ -214,6 +235,7 @@ static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_u
return httpd_register_uri_handler(s, uri);
}
static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t *uri) {
if (uri->handler == web_display_operation_handler) return display_register(s, uri);
if (uri->handler == web_network_operation_handler) return network_register(s, uri);
if (uri->handler == web_account_keys_handler) {
assert(s == SERVER && auth_live && ssl_live && !locked);
@@ -240,7 +262,7 @@ static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t
static esp_err_t httpd_unregister_uri_handler(httpd_handle_t s, const char *uri, int method) {
assert(!locked && s == SERVER && ssl_live && auth_live && serial_live);
assert((registration_calls == 18 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST) ||
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation") || !strcmp(uri, "/api/settings/network-operation")) && method == HTTP_GET));
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation") || !strcmp(uri, "/api/settings/network-operation") || !strcmp(uri, "/api/settings/display-operation")) && method == HTTP_GET));
++unregister_calls;
for (unsigned i = 0; i < registered_count; ++i) {
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
@@ -309,12 +331,14 @@ static void reset(void) {
unregister_fail = settings_fail = false; settings_calls = 0; clear_events();
operation_calls = operation_fail_at = 0;
network_calls = network_allocations = network_fail_at = 0;
display_calls = display_allocations = display_fail_at = 0;
account_calls = account_fail_at = generation_calls = keys_calls = 0;
generation_fail = keys_fail = false;
}
static void fresh_registration(void) {
registration_calls = registered_count = 0;
network_calls = network_allocations = 0;
display_calls = display_allocations = 0;
}
static void start(void) {
assert(web_server_start() == ESP_OK);
@@ -336,6 +360,14 @@ static const httpd_uri_t *method_route(const char *uri, int method) {
}
return found;
}
static void display_complete(void) {
assert(display_calls == 3 && display_allocations == 6);
assert(route("/api/settings/display")->handler == web_display_settings_handler);
for (int method = HTTP_GET; method <= HTTP_POST; ++method) {
const httpd_uri_t *r = method_route("/api/settings/display-operation", method);
assert(r && r->handler == web_display_operation_handler);
}
}
static void network_complete(void) {
assert(network_calls == 3 && network_allocations == 6);
assert(route("/api/settings/network")->handler == web_network_snapshot_handler);
@@ -378,7 +410,7 @@ int main(void) {
}
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
reset(); start(); assert(registered_count == 27 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
reset(); start(); assert(registered_count == 30 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(route("/api/settings/serial")->handler == serial_settings_handler);
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
@@ -432,7 +464,7 @@ int main(void) {
assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned);
assert(!admin_inits && !admin_attaches && !auth_stops && !ssl_stops);
assert(!s_transitioning && s_last_error == ESP_OK && s_counters.starts == 1 && !s_counters.start_failures);
assert(registered_count == 25 && unregister_calls == failure - 17);
assert(registered_count == 28 && unregister_calls == failure - 17);
for (unsigned i = 0; i < registered_count; ++i)
assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin"));
assert(route("/ws/serial")->handler == traced_websocket_handler);
@@ -441,13 +473,13 @@ int main(void) {
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
assert(!admin_detaches && !admin_stoppeds);
registration_fail_at = 0; fresh_registration(); start();
assert(registered_count == 27 && admin_attaches == 1 && s_counters.starts == 2);
assert(registered_count == 30 && admin_attaches == 1 && s_counters.starts == 2);
assert(web_server_stop() == ESP_OK && admin_stoppeds == 1);
}
puts("PASS optional positions 17..18 preserve M1, roll back ticket when needed and recover after stop/restart");
reset(); registration_fail_at = 18; unregister_fail = true;
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 26);
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 29);
assert(auth_live && ssl_live && serial_live && s_serial_transport_attached);
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
ticket = route("/api/admin/ws-ticket");
@@ -459,7 +491,7 @@ int main(void) {
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
assert(!admin_detaches && !admin_stoppeds);
unregister_fail = false; registration_fail_at = 0; fresh_registration(); start();
assert(registered_count == 27 && admin_attaches == 1 && web_server_stop() == ESP_OK);
assert(registered_count == 30 && admin_attaches == 1 && web_server_stop() == ESP_OK);
puts("PASS failed unregister retains only original ticket handler, no admin attachment, and permits restart");
reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL;
@@ -481,7 +513,7 @@ int main(void) {
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
reset(); settings_fail = true; start();
assert(settings_calls == 1 && registered_count == 26);
assert(settings_calls == 1 && registered_count == 29);
assert(auth_live && serial_live && admin_owned && web_server_stop() == ESP_OK);
settings_fail = false; fresh_registration(); start();
assert(route("/api/settings/serial")->handler == serial_settings_handler);
@@ -489,7 +521,7 @@ int main(void) {
puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
for (unsigned failure = 1; failure <= 2; ++failure) {
reset(); operation_fail_at = failure; start();
assert(registered_count == 25 && operation_calls == failure && unregister_calls == failure - 1);
assert(registered_count == 28 && operation_calls == failure && unregister_calls == failure - 1);
assert(auth_live && serial_live && admin_owned);
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/serial-operation"));
assert(web_server_stop() == ESP_OK);
@@ -497,7 +529,7 @@ int main(void) {
puts("PASS optional Serial operation GET/POST failure never publishes a mutation-only route or disables transports");
for (unsigned failure = 1; failure <= 3; ++failure) {
reset(); account_calls = 0; account_fail_at = failure; start();
assert(account_calls == failure && registered_count == (failure == 1 ? 24 : 25));
assert(account_calls == failure && registered_count == (failure == 1 ? 27 : 28));
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(auth_live && serial_live && admin_owned);
@@ -505,17 +537,17 @@ int main(void) {
assert(strcmp(registered[i]->uri, "/api/settings/account-operation"));
assert(web_server_stop() == ESP_OK);
account_fail_at = 0; account_calls = 0; fresh_registration(); start();
assert(registered_count == 27 && account_calls == 3);
assert(registered_count == 30 && account_calls == 3);
assert(web_server_stop() == ESP_OK);
}
reset(); account_calls = 0; account_fail_at = 3; unregister_fail = true; start();
assert(registered_count == 26 && auth_live && serial_live && admin_owned);
assert(registered_count == 29 && auth_live && serial_live && admin_owned);
for (unsigned i = 0; i < registered_count; ++i)
assert(strcmp(registered[i]->uri, "/api/settings/account-operation") || registered[i]->method == HTTP_GET);
assert(web_server_stop() == ESP_OK); account_fail_at = 0;
puts("PASS optional Accounts list/result/mutation allocation failures preserve transports and never expose mutation without reads (including failed unregister)");
reset(); generation_fail = true; start();
assert(generation_calls == 1 && registered_count == 26 && account_calls == 3);
assert(generation_calls == 1 && registered_count == 29 && account_calls == 3);
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
@@ -527,12 +559,12 @@ int main(void) {
}
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
generation_fail = false; fresh_registration(); start();
assert(generation_calls == 2 && registered_count == 27);
assert(generation_calls == 2 && registered_count == 30);
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(web_server_stop() == ESP_OK);
puts("PASS optional password generation allocation failure preserves account routes/auth/transports; restart recovers");
reset(); keys_fail = true; start();
assert(keys_calls == 1 && registered_count == 26 && account_calls == 3 && generation_calls == 1);
assert(keys_calls == 1 && registered_count == 29 && account_calls == 3 && generation_calls == 1);
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
@@ -547,7 +579,7 @@ int main(void) {
}
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
keys_fail = false; fresh_registration(); start();
assert(keys_calls == 2 && registered_count == 27);
assert(keys_calls == 2 && registered_count == 30);
assert(route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
assert(web_server_stop() == ESP_OK);
puts("PASS optional account keys allocation failure preserves account/generation/auth/transports; restart recovers");
@@ -572,7 +604,7 @@ int main(void) {
reset(); network_fail_at = failure; start();
unsigned failed_route = (failure + 1) / 2;
assert(network_calls == failed_route && network_allocations == failure);
assert(registered_count == (failed_route == 1 ? 24 : 25));
assert(registered_count == (failed_route == 1 ? 27 : 28));
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
assert(!method_route("/api/settings/network-operation", HTTP_GET));
assert(!method_route("/api/settings/network-operation", HTTP_POST));
@@ -580,13 +612,13 @@ int main(void) {
other_domains_complete();
assert(web_server_stop() == ESP_OK);
network_fail_at = 0; fresh_registration(); start();
assert(registered_count == 27); network_complete();
assert(registered_count == 30); network_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS all six Network descriptor/name allocation positions isolate failures and recover after restart");
for (unsigned failure = 5; failure <= 6; ++failure) {
reset(); network_fail_at = failure; unregister_fail = true; start();
assert(registered_count == 26 && unregister_calls == 1);
assert(registered_count == 29 && unregister_calls == 1);
assert(route("/api/settings/network")->handler == web_network_snapshot_handler);
assert(method_route("/api/settings/network-operation", HTTP_GET)->handler == web_network_operation_handler);
assert(!method_route("/api/settings/network-operation", HTTP_POST));
@@ -596,10 +628,42 @@ int main(void) {
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
unregister_fail = false; network_fail_at = 0; fresh_registration(); start();
assert(registered_count == 27); network_complete();
assert(registered_count == 30); network_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS failed Network result unregister leaves reads only and preserves stop-failure ownership/restart");
for (unsigned failure = 1; failure <= 6; ++failure) {
reset(); display_fail_at = failure; start();
unsigned failed_route = (failure + 1) / 2;
assert(display_calls == failed_route && display_allocations == failure);
assert(registered_count == (failed_route == 1 ? 27 : 28));
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
assert(!method_route("/api/settings/display-operation", HTTP_GET));
assert(!method_route("/api/settings/display-operation", HTTP_POST));
assert(!!method_route("/api/settings/display", HTTP_GET) == (failed_route != 1));
other_domains_complete(); network_complete();
assert(web_server_stop() == ESP_OK);
display_fail_at = 0; fresh_registration(); start();
assert(registered_count == 30); display_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS all six Display descriptor/name allocation positions isolate failures and recover after restart");
for (unsigned failure = 5; failure <= 6; ++failure) {
reset(); display_fail_at = failure; unregister_fail = true; start();
assert(registered_count == 29 && unregister_calls == 1);
assert(route("/api/settings/display")->handler == web_display_settings_handler);
assert(method_route("/api/settings/display-operation", HTTP_GET)->handler == web_display_operation_handler);
assert(!method_route("/api/settings/display-operation", HTTP_POST));
other_domains_complete(); network_complete();
ssl_stop_error = ESP_FAIL;
assert(web_server_stop() == ESP_FAIL && s_server == SERVER);
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
unregister_fail = false; display_fail_at = 0; fresh_registration(); start();
assert(registered_count == 30); display_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS failed Display result unregister leaves reads only and preserves stop-failure ownership/restart");
for (unsigned failure = 0; failure < 8; ++failure) {
reset();
if (failure == 0) settings_fail = true;
@@ -607,10 +671,10 @@ int main(void) {
else if (failure <= 5) account_fail_at = failure - 2;
else if (failure == 6) generation_fail = true;
else keys_fail = true;
start(); network_complete(); assert(web_server_stop() == ESP_OK);
start(); network_complete(); display_complete(); assert(web_server_stop() == ESP_OK);
}
puts("PASS every other settings route failure leaves the complete Network domain available");
puts("21 lifecycle groups passed (16 required fatal positions, 13 optional routes, all six Network allocation positions, plus failed unregister)");
puts("23 lifecycle groups passed (16 required fatal positions, 16 optional routes, Network/Display allocation positions and failed unregister)");
return 0;
}
'''
@@ -0,0 +1,210 @@
/* Production HTTP/store/owner/config/CLI; deterministic storage and scheduling. */
#include <errno.h>
#include <stdlib.h>
#include "local_status_ui.h"
#include "freertos/FreeRTOS.h"
#define ESP_ERR_TIMEOUT 0x107
#include "../../src/local_ui_config.c"
static portMUX_TYPE s_timing_mux;
static local_ui_config_t s_config;
static bool s_config_available, s_config_busy, s_diagnostic_hold_active;
static uint32_t s_config_generation, s_external_activity_sequence, s_diagnostic_hold_started;
static void *s_diagnostic_gate;
#define portENTER_CRITICAL taskENTER_CRITICAL
#define portEXIT_CRITICAL taskEXIT_CRITICAL
#define xTaskGetTickCount() 42U
#define pdMS_TO_TICKS(v) (v)
#define xSemaphoreTake(a,b) ((void)(a), (void)(b), 1)
#define xSemaphoreGive(a) ((void)(a), 1)
#define pdTRUE 1
#include "display_owner_production.h"
#include "../../src/web_display_settings.c"
static int show_status(void) { return 0; }
const char *esp_err_to_name(esp_err_t e) { (void)e; return "test error"; }
#include "display_console_production.h"
static bool on_dispatcher, have_stored, queue_fail;
static local_ui_config_t persisted, staged;
static esp_err_t storage_error;
static unsigned storage_calls, storage_fail_at, storage_step;
static esp_err_t storage_result(void) { return ++storage_step == storage_fail_at ? ESP_FAIL : ESP_OK; }
static uint32_t queued_id;
static void (*storage_hook)(void);
esp_err_t nvs_flash_init(void) {
assert(on_dispatcher && !host_lock_depth); ++storage_calls;
if (storage_hook) { void (*h)(void) = storage_hook; storage_hook = NULL; h(); }
storage_step = 0;
return storage_error == ESP_OK ? storage_result() : storage_error;
}
esp_err_t nvs_open(const char *ns, int mode, nvs_handle_t *h) {
assert(!strcmp(ns, LOCAL_UI_CONFIG_NVS_NAMESPACE)); *h = 1;
if (storage_result() != ESP_OK) return ESP_FAIL;
return !have_stored && mode == NVS_READONLY ? ESP_ERR_NVS_NOT_FOUND : ESP_OK;
}
esp_err_t nvs_get_blob(nvs_handle_t h, const char *key, void *out, size_t *size) {
assert(h == 1 && !strcmp(key, LOCAL_UI_CONFIG_NVS_BLOB_KEY));
if (storage_result() != ESP_OK) return ESP_FAIL;
if (out) memcpy(out, &persisted, sizeof(persisted));
*size = sizeof(persisted); return ESP_OK;
}
esp_err_t nvs_set_blob(nvs_handle_t h, const char *key, const void *in, size_t size) {
assert(h == 1 && !strcmp(key, LOCAL_UI_CONFIG_NVS_BLOB_KEY) && size == sizeof(staged));
if (storage_result() != ESP_OK) return ESP_FAIL;
memcpy(&staged, in, size); return ESP_OK;
}
esp_err_t nvs_commit(nvs_handle_t h) { assert(h == 1); if (storage_result() != ESP_OK) return ESP_FAIL; persisted = staged; have_stored = true; return ESP_OK; }
void nvs_close(nvs_handle_t h) { assert(h == 1); }
esp_err_t admin_ssh_console_submit_display_settings(uint32_t id) {
assert(id && !on_dispatcher && !host_lock_depth);
if (queue_fail) return ESP_ERR_TIMEOUT;
queued_id = id; return ESP_OK;
}
static void operation_begin(const issued_t *identity, const char *body) {
begin("/api/settings/display-operation", body ? HTTP_POST : HTTP_GET, body);
same_origin(); if (body) add("Content-Type", "application/json");
if (identity) {
char cookie[100]; snprintf(cookie, sizeof(cookie), "__Host-sak-session=%s", identity->token);
add("Cookie", cookie); if (body) add("X-CSRF-Token", identity->view.csrf);
}
}
static void display_expect(const char *status, bool snapshot) {
unsigned before = storage_calls;
esp_err_t e = snapshot ? web_display_settings_handler(&req) : web_display_operation_handler(&req);
assert(e == (send_fail || aux.remaining_len ? ESP_FAIL : ESP_OK));
if (strcmp(response_status, status)) fprintf(stderr, "expected %s got %s: %s\n", status, response_status, output);
assert(!strcmp(response_status, status) && storage_calls == before);
assert(strlen(output) < 128); zero(scratch, sizeof(scratch));
}
static void execute(void) { on_dispatcher = true; web_display_settings_execute(queued_id); on_dispatcher = false; }
static void submit(const issued_t *who, const char *body) {
operation_begin(who, body); display_expect("202 Accepted", false); assert(s_operation.state == PENDING);
}
static void action(const issued_t *who, const char *name) {
char body[96]; snprintf(body, sizeof(body), "{\"action\":\"%s\",\"generation\":%u}", name, s_config_generation);
submit(who, body); execute();
}
static void concurrent(void) {
local_ui_config_t config; uint32_t generation;
assert(s_config_busy && local_status_ui_get_settings(&config, &generation) == ESP_ERR_TIMEOUT);
assert(local_status_ui_apply_config(&s_config) == ESP_ERR_TIMEOUT);
assert(local_status_ui_update_settings(LOCAL_UI_SETTINGS_RESET, 0, NULL, NULL) == ESP_ERR_TIMEOUT);
uint32_t activity = s_external_activity_sequence, original = s_config_generation;
local_status_ui_hold_for_diagnostics();
assert(s_external_activity_sequence == activity + 1 && s_config_generation == original);
}
static void revoke(void) { web_session_store_invalidate(s_operation.session); }
static void display_settings_tests(void) {
auth_reset(); issued_t admin = mint(&alice), user = mint(&bob), other = mint(&alice);
local_ui_config_defaults(&s_config); s_config_available = true; s_config_generation = 1; receive_fragment = 64;
const char *apply = "{\"action\":\"apply\",\"generation\":1,\"dim_seconds\":0,\"off_seconds\":86400}";
operation_begin(NULL, apply); display_expect("401 Unauthorized", false);
operation_begin(&user, apply); display_expect("403 Forbidden", false);
operation_begin(&user, NULL); display_expect("403 Forbidden", false);
for (unsigned mode = 0; mode < 8; ++mode) {
operation_begin(&admin, apply);
if (mode == 0) req.content_len = aux.remaining_len = 257;
if (mode == 1) req.uri = "/api/settings/display-operation?x=1";
if (mode == 2) req.method = HTTP_GET;
if (mode == 3) add("X-CSRF-Token", "duplicate");
if (mode == 4) add("Origin", "https://evil.example");
if (mode == 5) add("Transfer-Encoding", "chunked");
if (mode == 6) add("Content-Type", "text/plain");
if (mode == 7) add("Sec-Fetch-Site", "cross-site");
(void)web_display_operation_handler(&req);
assert(response_status[0] == '4' && !s_next_id && !storage_calls);
}
puts("PASS Display HTTP security: admin/cookie/Origin/CSRF, duplicates, query/body/framing bounds");
const char *invalid[] = {"{}", "[]", "{\"action\":\"save\"}", "{\"action\":\"save\",\"generation\":0}",
"{\"action\":\"save\",\"generation\":1,\"dim_seconds\":2}", "{\"action\":\"save\",\"generation\":01}",
"{\"action\":\"save\",\"generation\":1e2}", "{\"action\":\"save\",\"generation\":1.0}",
"{\"action\":\"save\",\"generation\":-1}", "{\"action\":\"save\",\"generation\":4294967296}",
"{\"action\":\"save\",\"generation\":1,\"generation\":1}", "{\"action\":\"sa\\u0076e\",\"generation\":1}",
"{\"action\":\"apply\",\"generation\":1,\"dim_seconds\":10,\"off_seconds\":10}",
"{\"action\":\"apply\",\"generation\":1,\"dim_seconds\":86401,\"off_seconds\":0}"};
for (unsigned i = 0; i < sizeof(invalid)/sizeof(*invalid); ++i) {
operation_begin(&admin, invalid[i]); display_expect("400 Bad Request", false);
}
display_operation_t parsed;
for (size_t n = 0; n < strlen(apply); ++n) assert(!parse(apply, n, &parsed));
assert(parse(apply, strlen(apply), &parsed)); assert(!parse(apply, strlen(apply)+1, &parsed));
for (unsigned dim = 0; dim < 3; ++dim) for (unsigned off = 0; off < 3; ++off) {
unsigned values[] = {0, 1, 86400}; char body[160];
snprintf(body, sizeof(body), "{\"off_seconds\":%u,\"dim_seconds\":%u,\"generation\":4294967295,\"action\":\"apply\"}", values[off], values[dim]);
assert(parse(body, strlen(body), &parsed) == (!values[off] || !values[dim] || values[off] > values[dim]));
}
receive_fragment = 1; operation_begin(&admin, apply); display_expect("400 Bad Request", false); assert(body_offset == 4); receive_fragment = 64;
char full[257]; memset(full, ' ', 256); memcpy(full, apply, strlen(apply)); full[256] = 0;
queue_fail = true; operation_begin(&admin, full); display_expect("503 Service Unavailable", false); queue_fail = false;
assert(body_offset == 256 && s_operation.state == IDLE);
puts("PASS Display parser: exact schema, integer limits/order/zero, truncations, four receives and exact 256-byte admission");
for (unsigned mode = 0; mode < 5; ++mode) {
operation_begin(mode == 0 ? NULL : mode == 1 ? &user : &admin, NULL); req.uri = "/api/settings/display";
s_config_available = mode != 3; s_config_busy = mode == 4;
display_expect(mode == 0 ? "401 Unauthorized" : mode == 1 ? "403 Forbidden" : mode >= 3 ? "503 Service Unavailable" : "200 OK", true);
if (mode == 2) assert(!strcmp(output, "{\"generation\":1,\"dim_seconds\":300,\"off_seconds\":600}"));
}
s_config_available = true; s_config_busy = false;
puts("PASS Display snapshot: bounded RAM-only, unavailable UI/contention; no panel or storage dependency");
submit(&admin, apply); uint32_t first = queued_id;
operation_begin(&other, apply); display_expect("503 Service Unavailable", false);
operation_begin(&other, NULL); display_expect("200 OK", false); assert(strstr(output, "idle"));
execute(); assert(s_operation.state == OK && s_config_generation == 2 && s_config.dim_timeout_seconds == 0 && !have_stored);
zero(&s_operation.principal, sizeof(s_operation.principal)); zero(&s_operation.config, sizeof(s_operation.config));
execute(); assert(s_config_generation == 2);
submit(&admin, apply); execute(); assert(s_operation.state == CONFLICT && s_config_generation == 2);
action(&admin, "save"); assert(have_stored && persisted.off_timeout_seconds == 86400 && s_config_generation == 2);
action(&admin, "defaults"); assert(s_config.off_timeout_seconds == 600 && persisted.off_timeout_seconds == 86400);
action(&admin, "load"); assert(s_config.off_timeout_seconds == 86400);
have_stored = false; action(&admin, "load"); assert(s_operation.state == LOADED_DEFAULTS && s_config.off_timeout_seconds == 600 && !have_stored);
have_stored = true; persisted.version = 99; action(&admin, "load"); assert(s_operation.state == LOADED_DEFAULTS && persisted.version == 99);
action(&admin, "reset"); assert(s_operation.state == OK && persisted.version == 1);
/* A simulated reboot invokes the real boot loader, not browser drafts. */
local_ui_config_t boot; bool stored; on_dispatcher = true;
assert(local_ui_config_load(&boot, &stored) == ESP_OK && stored && !memcmp(&boot, &persisted, sizeof(boot))); on_dispatcher = false;
puts("PASS Display persistence: Apply/Save/Defaults/Load/fallback/Reset, real config loader reboot projection and stale generation/replay isolation");
on_dispatcher = true; char *set[] = {"display", "set", "dim-seconds", "10"}; assert(command_display(4, set) == 0); on_dispatcher = false;
uint32_t selected = s_config_generation;
char body[96]; snprintf(body, sizeof(body), "{\"action\":\"save\",\"generation\":%u}", selected); submit(&admin, body);
on_dispatcher = true; set[3] = "20"; assert(command_display(4, set) == 0); on_dispatcher = false;
execute(); assert(s_operation.state == CONFLICT && s_config.dim_timeout_seconds == 20 && persisted.dim_timeout_seconds == 300);
storage_hook = concurrent; action(&admin, "save"); assert(s_operation.state == OK && persisted.dim_timeout_seconds == 20);
storage_error = ESP_FAIL; selected = s_config_generation; action(&admin, "reset");
assert(s_operation.state == FAILED && s_config_generation == selected && s_config.dim_timeout_seconds == 20);
action(&admin, "load"); assert(s_operation.state == FAILED && s_config_generation == selected); storage_error = ESP_OK;
for (unsigned failure = 1; failure <= 4; ++failure) {
storage_fail_at = failure;
local_ui_config_t old_working = s_config, old_saved = persisted;
for (unsigned i = 0; i < 3; ++i) {
action(&admin, i == 0 ? "save" : i == 1 ? "reset" : "load");
assert(s_operation.state == FAILED && !s_config_busy && s_config_generation == selected);
assert(!memcmp(&old_working, &s_config, sizeof(s_config)) && !memcmp(&old_saved, &persisted, sizeof(persisted)));
}
}
storage_fail_at = 0;
for (unsigned i = SAVE; i < ACTION_COUNT; ++i) {
on_dispatcher = true; char *args[] = {"display", (char *)s_actions[i]}; assert(command_display(2, args) == 0); on_dispatcher = false;
}
s_config_generation = UINT32_MAX;
assert(local_status_ui_apply_config(&s_config) == ESP_ERR_INVALID_STATE); s_config_generation = selected + 10;
puts("PASS Display canonical concurrency: CLI generation conflicts, storage reservation, concurrent activity/diagnostic hold, failed persistence leaves RAM unchanged, no wrap");
snprintf(body, sizeof(body), "{\"action\":\"save\",\"generation\":%u}", s_config_generation);
submit(&admin, body); unsigned before = storage_calls;
web_display_settings_execute(0); web_display_settings_execute(first); assert(storage_calls == before && s_operation.state == PENDING);
now += 30000000; execute(); assert(s_operation.state == CANCELLED && storage_calls == before);
submit(&admin, body); web_session_store_invalidate(admin.view.id); execute(); assert(s_operation.state == CANCELLED);
admin = mint(&alice); submit(&admin, body); db_fail = true; execute(); db_fail = false; assert(s_operation.state == CANCELLED); admin = mint(&alice);
submit(&admin, body); stale_user = alice.user_id; execute(); stale_user = 0; assert(s_operation.state == CANCELLED);
admin = mint(&alice); now = admin.view.expires_at_us - 1; submit(&admin, body); now = admin.view.expires_at_us; execute(); assert(s_operation.state == CANCELLED);
admin = mint(&alice); submit(&admin, body); storage_hook = revoke; execute(); assert(s_operation.state == OK);
operation_begin(&admin, NULL); display_expect("401 Unauthorized", false);
admin = mint(&alice); submit(&admin, body); web_cookie_auth_stop(); assert(web_cookie_auth_start() == ESP_OK); execute(); assert(s_operation.state == CANCELLED);
admin = mint(&alice); operation_begin(&admin, NULL); display_expect("200 OK", false); assert(strstr(output, "idle"));
puts("PASS Display session lifecycle: deadline, expiry/revocation/missed notification/database failure, admitted completion, stop/restart fencing");
send_fail = true; submit(&admin, body); send_fail = false; execute(); assert(s_operation.state == OK);
operation_begin(&admin, NULL); display_expect("200 OK", false); assert(strstr(output, "ok"));
s_next_id = UINT32_MAX; operation_begin(&admin, body); display_expect("503 Service Unavailable", false);
puts("PASS Display operation results: lost acknowledgement retains result, no automatic replay, nonwrapping operation IDs");
}
+26
View File
@@ -59,6 +59,24 @@ admin = "--admin" in sys.argv
settings = "--settings" in sys.argv
serial_settings = "--serial-settings" in sys.argv
accounts = "--accounts" in sys.argv
display = "--display" in sys.argv
if display:
HEADERS["nvs_flash.h"] = '#pragma once\n#include "esp_err.h"\nesp_err_t nvs_flash_init(void);\n'
HEADERS["nvs.h"] = '''#pragma once
#include <stddef.h>
#include "esp_err.h"
typedef int nvs_handle_t;
#define NVS_READONLY 0
#define NVS_READWRITE 1
#define ESP_ERR_NVS_NOT_FOUND 0x1102
#define ESP_ERR_NVS_TYPE_MISMATCH 0x1103
#define ESP_ERR_NVS_INVALID_LENGTH 0x110c
esp_err_t nvs_open(const char *, int, nvs_handle_t *);
esp_err_t nvs_get_blob(nvs_handle_t, const char *, void *, size_t *);
esp_err_t nvs_set_blob(nvs_handle_t, const char *, const void *, size_t);
esp_err_t nvs_commit(nvs_handle_t);
void nvs_close(nvs_handle_t);
'''
network = "--network" in sys.argv
if network:
HEADERS["esp_wifi_types.h"] = "#pragma once\ntypedef int wifi_auth_mode_t;\n"
@@ -186,6 +204,13 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
settings_source += ' serial_service_counters_t serial_counters = {0};\n' + acquisition
settings_source += ' return snprintf(response, capacity,\n' + serial_format + ',\n' + serial_arguments + ');\n}\n'
(tmp / 'settings_production.h').write_text(settings_source)
if display:
ui_source = (ROOT / 'src/local_status_ui.c').read_text()
names = ('local_status_ui_get_config', 'local_status_ui_get_settings', 'local_status_ui_update_settings', 'local_status_ui_apply_config', 'local_status_ui_hold_for_diagnostics')
(tmp / 'display_owner_production.h').write_text('\n'.join(function(ui_source, name) for name in names))
console_source = (ROOT / 'src/local_ui_console.c').read_text()
names = ('print_usage', 'print_config', 'parse_timeout', 'apply_parameter', 'command_display')
(tmp / 'display_console_production.h').write_text('\n'.join(function(console_source, name) for name in names))
if network:
wifi_source = (ROOT / 'src/wifi_config.c').read_text()
mdns_source = (ROOT / 'src/mdns_config.c').read_text()
@@ -221,6 +246,7 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
*(["-DHOST_SERIAL_SETTINGS"] if serial_settings else []),
*(["-DHOST_ACCOUNTS"] if accounts else []),
*(["-DHOST_NETWORK"] if network else []),
*(["-DHOST_DISPLAY"] if display else []),
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto",
"-o", str(tmp / "test")], check=True, timeout=30)
subprocess.run([str(tmp / "test")], check=True, timeout=20)
+6
View File
@@ -143,6 +143,9 @@ static void auth_reset(void) {
#ifdef HOST_NETWORK
#include "network_settings_test.c"
#endif
#ifdef HOST_DISPLAY
#include "display_settings_test.c"
#endif
int main(void) {
assert(store_tests() == 0); auth_reset();
@@ -302,6 +305,9 @@ int main(void) {
#endif
#ifdef HOST_NETWORK
network_settings_tests();
#endif
#ifdef HOST_DISPLAY
display_settings_tests();
#endif
return 0;
}
+2 -1
View File
@@ -13,7 +13,7 @@ const deferred = () => { let resolve; const promise = new Promise(r => { resolve
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': []};
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': [], '/api/settings/display': [], '/api/settings/display-operation': []};
const fits = [];
let serial = 0, now = Date.now();
class Clock extends Date { static now() { return now; } }
@@ -1251,5 +1251,6 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
assert.match(b.nodes['accounts-list'].textContent,/alice/); assert.doesNotMatch(b.nodes['accounts-list'].textContent,/replaced/); assert.equal(b.nodes['account-generated'].value,secret);
});
await require('./network.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
await require('./display.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
})().catch(error => { console.error(error); process.exitCode = 1; });
+120
View File
@@ -0,0 +1,120 @@
'use strict';
const assert = require('node:assert/strict');
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html}) => {
const path = '/api/settings/display', op = path + '-operation';
const fixture = (extra = {}) => ({generation: 7, dim_seconds: 300, off_seconds: 600, ...extra});
const reply = (action = 'apply', state = 'pending', id = 42, status = 200) => new Response(JSON.stringify({id, action, state}), {status});
const ack = action => reply(action, 'pending', 42, 202);
const n = (b, id) => b.nodes['display-' + id];
const posts = b => b.calls.filter(c => c.url === op && c.method === 'POST');
const gets = b => b.calls.filter(c => c.url === op && c.method === 'GET');
const reads = b => b.calls.filter(c => c.url === path);
async function open(value = fixture()) {
const b = await adminBrowser(); b.click('select-settings'); await tick();
b.queues[path].push(json(value)); b.click('settings-display'); await tick(); return b;
}
async function complete(b, action, state = 'ok') {
b.queues[op].push(reply(action, state)); b.queues[path].push(json(fixture({generation: 8})));
b.fire(1000); await tick();
}
await test('Display admin-only entry, actual label/value controls, absent-panel policy and navigation preserves terminals/lease', async () => {
for (const id of ['settings-display','display-values','display-edit-dim_seconds','display-edit-off_seconds','display-refresh','display-result','display-apply','display-save','display-load','display-defaults','display-reset']) assert.ok(html.includes('id="' + id + '"'));
assert.match(html, /absent panel/); assert.match(html, /086400/);
const u = browser(); u.start(); await tick(); u.click('settings-display'); await tick(); assert.equal(reads(u).length, 0);
const b = await open(); assert.equal(n(b,'dim_seconds').textContent, '300'); assert.equal(n(b,'edit-off_seconds').value, '600');
assert.equal(n(b,'settings').hidden, false); assert.equal(b.nodes['network-settings'].hidden, true);
const count = b.calls.length; b.click('settings-display'); b.click('select-settings'); await tick(); assert.equal(b.calls.length, count);
for (let i = 0; i < 2; ++i) {
b.sockets[i].emit('message', {data: Uint8Array.of(0,255,i).buffer}); assert.deepEqual(b.terminals[i].writes.at(-1), [0,255,i]);
b.terminals[i].input('blocked'); assert.equal(b.sockets[i].sent.length, 0);
}
b.click('settings-serial'); await tick(); assert.ok(b.sockets.every(s => !s.closed)); assert.equal(b.sockets.length, 2);
});
await test('Display strict snapshots reject extra/missing/type/range/order/status/oversized data and disable stale edits', async () => {
for (const value of [null, {}, fixture({generation:0}), fixture({generation:4294967296}), fixture({dim_seconds:'0'}), fixture({off_seconds:86401}), fixture({dim_seconds:600}), fixture({extra:1})]) {
const b = await open(value); assert.ok(n(b,'apply').disabled); assert.match(n(b,'detail').textContent, /unavailable|invalid/);
}
const b = await open(); b.queues[path].push(new Response('x'.repeat(129))); b.click('display-refresh'); await tick(); assert.ok(n(b,'apply').disabled);
b.queues[path].push(new Response(JSON.stringify(fixture()), {status:202})); b.click('display-refresh'); await tick(); assert.ok(n(b,'apply').disabled);
});
await test('Display typed Apply validates integer/zero/timeout order; bounded POST carries selected generation and CSRF', async () => {
const b = await open();
for (const [dim,off] of [['-1','600'],['1.5','600'],['1e2','600'],['01','600'],['86401','0'],['600','600'],['601','600'],['','600']]) {
n(b,'edit-dim_seconds').value = dim; n(b,'edit-off_seconds').value = off; b.click('display-apply'); await tick(); assert.equal(posts(b).length,0);
}
n(b,'edit-dim_seconds').value = '0'; n(b,'edit-off_seconds').value = '86400'; b.queues[op].push(ack('apply')); b.click('display-apply'); await tick();
const post = posts(b)[0]; assert.deepEqual(JSON.parse(post.body), {action:'apply',generation:7,dim_seconds:0,off_seconds:86400});
assert.equal(post.headers['X-CSRF-Token'],token); assert.equal(post.mode,'cors'); assert.ok(post.body.length <= 256);
assert.ok(n(b,'apply').disabled); assert.equal(n(b,'values').hidden,false);
b.click('display-apply'); await tick(); assert.equal(posts(b).length,1);
await complete(b,'apply'); assert.equal(reads(b).length,2); assert.equal(n(b,'apply').disabled,false); assert.match(n(b,'operation-detail').textContent,/completed/);
});
await test('Display explicit Save/Load/Defaults/Reset use working generation not drafts; only Reset confirms', async () => {
for (const action of ['save','load','defaults','reset']) {
const b = await open(); n(b,'edit-dim_seconds').value='123'; let confirmations=0;
b.window.confirm=()=>{++confirmations; return false;};
if (action==='reset') {b.click('display-reset'); await tick(); assert.equal(posts(b).length,0); assert.equal(confirmations,1);}
b.window.confirm=()=>{++confirmations; return true;}; b.queues[op].push(ack(action)); b.click('display-'+action); await tick();
assert.deepEqual(JSON.parse(posts(b)[0].body),{action,generation:7}); assert.equal(confirmations,action==='reset'?2:0);
await complete(b,action,action==='load'?'loaded_defaults':'ok'); assert.equal(reads(b).length,2);
}
});
await test('Display terminal failure/conflict/cancellation refreshes once without replay or success claims', async () => {
for (const state of ['failed','conflict','cancelled']) {
const b=await open(); b.queues[op].push(ack('save')); b.click('display-save'); await tick(); await complete(b,'save',state);
assert.equal(posts(b).length,1); assert.equal(reads(b).length,2); assert.doesNotMatch(n(b,'operation-detail').textContent,/Operation completed/);
}
});
await test('Display ten-poll and fifteen-second deadline bounds include delayed session work', async () => {
const b=await open(); b.queues[op].push(ack('save')); b.click('display-save'); await tick();
for(let i=0;i<10;i++){b.queues[op].push(reply('save')); b.fire(1000); await tick();}
assert.equal(gets(b).length,10); assert.equal(posts(b).length,1); assert.ok(![...b.timers.values()].some(t=>t.ms===1000||t.ms===15000));
assert.match(n(b,'operation-detail').textContent,/Automatic checking stopped/);
const c=await open(); c.queues[op].push(ack('save')); c.click('display-save'); await tick();
const d=deferred(); c.queues['/api/session'].push(d.promise); c.fire(1000); await tick(); c.elapse(15000); c.fire(15000); await tick();
d.resolve(session({role:'admin',username:'alice'})); await tick(); assert.equal(gets(c).length,0); assert.equal(posts(c).length,1);
});
await test('Display lost ACK/result replacement/same-ID action mismatch preserve uncertainty and stop automatic following', async () => {
const b=await open(); b.queues[op].push(()=>{throw Error('lost');}); b.click('display-save'); await tick();
assert.equal(posts(b).length,1); assert.match(n(b,'operation-detail').textContent,/unknown/);
b.queues[op].push(reply('save','ok')); b.queues[path].push(json(fixture())); b.click('display-result'); await tick(); assert.match(n(b,'operation-detail').textContent,/acknowledgement was lost/);
for(const changed of [reply('save','pending',43), reply('reset','ok',42)]) {
const c=await open(); c.queues[op].push(ack('save')); c.click('display-save'); await tick(); c.queues[op].push(changed); c.fire(1000); await tick();
assert.ok(![...c.timers.values()].some(t=>t.ms===1000||t.ms===15000)); assert.match(n(c,'operation-detail').textContent,/unknown/); assert.equal(posts(c).length,1);
}
});
await test('Display operation rejects malformed status/schema/ID/action/state and impossible loaded-defaults replies', async () => {
for(const r of [reply('save','pending',42,200),reply('save','ok',42,202),reply('reset','pending',42,202),new Response(JSON.stringify({id:42,action:'save',state:'pending',extra:1}),{status:202})]) {
const b=await open(); b.queues[op].push(r); b.click('display-save'); await tick(); assert.match(n(b,'operation-detail').textContent,/unknown/); assert.equal(gets(b).length,0);
}
const b=await open(); b.queues[op].push(reply('save','loaded_defaults')); b.click('display-result'); await tick(); assert.match(n(b,'operation-detail').textContent,/unknown/);
});
await test('Display navigation fences pending read/POST/result and never resumes/replays on return', async () => {
for(const stage of ['snapshot','post','result']) {
const b=await open(); const d=deferred();
if(stage==='snapshot'){b.queues[path].push(d.promise); b.click('display-refresh');}
else if(stage==='post'){b.queues[op].push(d.promise); b.click('display-save');}
else {b.queues[op].push(ack('save')); b.click('display-save'); await tick(); b.queues[op].push(d.promise); b.fire(1000);}
await tick(); b.click('settings-serial'); await tick();
const count=posts(b).length; d.resolve(stage==='snapshot'?json(fixture({generation:99})):stage==='post'?ack('save'):reply('save','ok')); await tick();
assert.equal(n(b,'edit-dim_seconds').value,''); assert.ok(![...b.timers.values()].some(t=>t.ms===1000||t.ms===15000));
b.queues[path].push(json(fixture())); b.click('settings-display'); await tick(); assert.equal(posts(b).length,count); assert.ok(b.sockets.every(s=>!s.closed));
}
});
await test('Display endpoint401 and identity replacement close both routes; stale401 after navigation cannot expire current view', async () => {
for(const route of [path,op]) {
const b=await open(); b.queues[route].push(failure(401)); b.click(route===path?'display-refresh':'display-result'); await tick();
assert.deepEqual(b.redirects,['/login']); assert.ok(b.sockets.every(s=>s.closed)); assert.equal(n(b,'edit-dim_seconds').value,'');
}
const b=await open(); const d=deferred(); b.queues[path].push(d.promise); b.click('display-refresh'); await tick(); b.click('settings-serial'); await tick(); d.resolve(failure(401)); await tick(); assert.equal(b.redirects.length,0);
const c=await open(); c.queues['/api/session'].push(session({role:'admin',username:'replacement'})); c.click('display-save'); await tick(); assert.deepEqual(c.redirects,['/']); assert.equal(posts(c).length,0);
});
await test('Display pagehide/expiry/logout fence drafts and in-flight work without backend cancellation claims', async () => {
for(const event of ['pagehide','expiry','logout']) {
const b=await open(); const d=deferred(); b.queues[op].push(d.promise); b.click('display-save'); await tick();
if(event==='pagehide') b.emit('pagehide'); else if(event==='expiry') b.window.sakSessionExpired(); else {b.queues['/api/logout'].push(new Response(null,{status:204})); b.click('sign-out');}
await tick(); d.resolve(ack('save')); await tick(); assert.ok(b.sockets.every(s=>s.closed)); assert.equal(n(b,'edit-dim_seconds').value,''); assert.equal(posts(b).length,1);
assert.ok(![...b.timers.values()].some(t=>t.ms===1000||t.ms===15000));
}
});
};
+9 -9
View File
@@ -45,10 +45,10 @@ def check_layout(html):
if cls in classes(node):
return node
raise AssertionError(cls)
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary'):
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values'):
assert ids[ident]['tag'] == 'dl'
assert 'settings-values' in classes(ids[ident])
for ident in ('serial-settings-content', 'account-settings', 'network-settings'):
for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-settings'):
nodes = list(descendants(ids[ident]))
assert not any(n['tag'] == 'pre' for n in nodes)
assert all('connection-detail' in classes(n) for n in nodes if n['tag'] == 'p')
@@ -59,9 +59,9 @@ def check_layout(html):
ancestor(n, 'settings-edit')
except AssertionError:
ancestor(n, 'serial-edit')
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh'):
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh', 'display-refresh'):
assert ids[ident]['text'] == 'Refresh'
for ident in ('serial-result', 'account-result', 'network-result'):
for ident in ('serial-result', 'account-result', 'network-result', 'display-result'):
assert ids[ident]['text'] == 'Check Operation Result'
for ident in ('network-boot', 'network-enabled', 'account-password-saved'):
assert 'settings-check' in classes(ids[ident]['parent'])
@@ -90,7 +90,7 @@ def check_layout(html):
):
assert rule in css, rule
assert '.settings-edit textarea{font:inherit;width:100%;min-width:0;' in css
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all three settings views')
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all four settings views')
def check_browser_layout(html, tmp, executable):
@@ -103,13 +103,13 @@ def check_browser_layout(html, tmp, executable):
fixture = re.sub(r'<link\b[^>]*>|<img\b[^>]*>', '', fixture)
probe = r'''
const cases = [];
for (const width of [320, 600, 1200]) for (const view of ['serial-settings-content', 'account-settings', 'network-settings']) {
for (const width of [320, 600, 1200]) for (const view of ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings']) {
const frame = document.createElement('iframe'); frame.style.width = width + 'px'; frame.style.height = '900px';
cases.push(new Promise(resolve => {
frame.onload = () => {
const d = frame.contentDocument, win = frame.contentWindow;
d.getElementById('serial-settings').hidden = false;
for (const id of ['serial-settings-content', 'account-settings', 'network-settings']) d.getElementById(id).hidden = id !== view;
for (const id of ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings']) d.getElementById(id).hidden = id !== view;
const section = d.getElementById(view);
section.querySelectorAll('[hidden]').forEach(n => n.hidden = false);
section.querySelectorAll('dl').forEach(dl => {
@@ -166,6 +166,6 @@ def check_browser_layout(html, tmp, executable):
assert result.returncode == 0, result.stderr
parsed = Document(result.stdout)
results = json.loads(parsed.ids['layout-results']['text'])
assert len(results) == 9
assert len(results) == 12
assert all(not case['errors'] for case in results), results
print('PASS Chromium layout: all three settings views at 320/600/1200px; bounded controls, summaries, inline checkboxes and rendered consecutive-space distinction (fixture data, not live app)')
print('PASS Chromium layout: all four settings views at 320/600/1200px; bounded controls, summaries, inline checkboxes and rendered consecutive-space distinction (fixture data, not live app)')