diff --git a/DESIGN.md b/DESIGN.md index f0fc464..36babab 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -140,9 +140,9 @@ The words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** are no | D-CAM-20 | Group capture | `sb/capture-group` fans one request out as independent per-camera capture jobs sharing an adapter-generated `captureGroupId`; the single deferred reply aggregates every member's terminal result | One operator action often needs an evidence set from several cameras. Aggregation fits the shipped single-reply command model plus the D-CAM-10 deferred reply; a core scatter-gather exchange (one request, multiple replies) is not required for v1 and is raised as a core question (§27). | | D-CAM-21 | Capture thumbnail | Opt-in per capture profile (`thumbnail.size` = `small` 160px / `medium` 320px / `large` 640px, longest edge, aspect preserved, never upscaled); JPEG; carried in the ANNOUNCEMENT only, as native protobuf bytes; never in the durable record | A consumer on the bus can see the picture without fetching the file. It is bounded by the longest edge because cameras are 4:3 and 16:9 and a fixed W×H would distort or letterbox. It is announcement-only because the terminal body IS the committed document — the catalog's `terminal_result`, the metadata sidecar verbatim, and the body group replies embed — and a lossy, derived, disposable preview must not be durably stored per capture (D-CAM-13). It carries NO digest: a thumbnail is a lossy re-encode and a `sha256` beside the artifact's would invite a consumer to believe it is verifiable against it. A thumbnail that cannot be rendered, or will not fit the byte ceiling, is dropped and counted — it never fails a capture. The ceiling is the TRANSPORT's, resolved at startup: the Greengrass IPC client encodes a whole message into a static 10,000-byte buffer, so IPC carries `small` only (6 KiB budget) and a larger configured size is clamped down rather than rejected — the same config ships to Greengrass and to Kubernetes; MQTT carries all three (60 KiB budget, bounded by the library's 64 KiB binary-value cap, not by the broker). And a preview NEVER outranks the result: if an announcement carrying one cannot be published, the result is announced again without it. | | D-CAM-23 | Standardized southbound error codes | Rename the routing/availability codes to the SOUTHBOUND.md §2.2 family: `INVALID_REQUEST` and `INSTANCE_REQUIRED` → `BAD_ARGS`, `UNKNOWN_INSTANCE` → `NO_SUCH_INSTANCE`, `CAMERA_UNAVAILABLE` → `DEVICE_UNAVAILABLE`. Domain codes (`CAPTURE_*`, `PTZ_*`, `STORAGE_PRESSURE`, `QUEUE_FULL`, …) are unchanged. **Hard rename, no aliases.** | The adapter's routing/availability codes were adapter-local while the rest of the ecosystem standardized on `BAD_ARGS`/`NO_SUCH_INSTANCE`/`DEVICE_UNAVAILABLE`, so a fleet-wide alarm rule keyed on the standard names could not match this adapter. This is a **wire-visible breaking change** for any consumer keying on the old strings; the component is pre-1.0/experimental and a documented breaking change is acceptable where a silent one is not. `INSTANCE_REQUIRED` and `INVALID_REQUEST` both collapse to `BAD_ARGS` because both are argument faults. Consumer grep at implementation time found no matches in `edge-console` or `bottling-company-test` for the old strings. | -| D-CAM-24 | Lifecycle verbs `sb/pause` / `sb/resume`; `repoll` N/A | Serve the standardized `sb/pause` / `sb/resume` lifecycle verbs: they suspend/admit a camera's new capture work (scheduled and commanded), leave in-flight captures to run to completion, refuse new capture work while paused with the stable `INSTANCE_PAUSED` code, are idempotent (`{ paused, changed }`), and surface in `sb/status`. Pause is process-local (a restart begins unpaused) and scoped to capture workload — `sb/reconnect`, PTZ, and read-only verbs are unaffected. **`repoll` is recorded N/A.** | Pause/resume are the standard instance-control family a real adapter serves (SOUTHBOUND.md §2.2). `repoll` triggers an immediate poll cycle for poll-loop adapters; this adapter is capture-on-demand and schedule-driven, not poll-based, so there is no poll cycle to trigger — `sb/capture` / `sb/capture-submit` are the on-demand trigger and schedules drive the periodic work. Adding a redundant `repoll` alias would only duplicate `sb/capture` while needing its own idempotency key, so it is not implemented; the decision is recorded here and in `docs/reference/messaging-interface.md` per the issue. | +| D-CAM-24 | Lifecycle verbs `sb/pause` / `sb/resume`; `repoll` N/A | Serve the standardized `sb/pause` / `sb/resume` lifecycle verbs: they suspend/admit a camera's new capture work (scheduled and commanded), leave in-flight captures to run to completion, refuse new capture work while paused with the standardized top-level `PAUSED` code (SOUTHBOUND.md §2.2), are idempotent (`{ paused, changed }`), and surface in `sb/status`. Pause is process-local (a restart begins unpaused) and scoped to capture workload — `sb/reconnect`, PTZ, and read-only verbs are unaffected. **`repoll` is recorded N/A.** | Pause/resume are the standard instance-control family a real adapter serves (SOUTHBOUND.md §2.2). `repoll` triggers an immediate poll cycle for poll-loop adapters; this adapter is capture-on-demand and schedule-driven, not poll-based, so there is no poll cycle to trigger — `sb/capture` / `sb/capture-submit` are the on-demand trigger and schedules drive the periodic work. Adding a redundant `repoll` alias would only duplicate `sb/capture` while needing its own idempotency key, so it is not implemented; the decision is recorded here and in `docs/reference/messaging-interface.md` per the issue. | | D-CAM-25 | Operational metric families | `camera_captures` becomes a `(Total, Interval)` counter family accumulated on the capture hooks and drained on the 30 s sampler tick (the interval counter preserves the every-event guarantee — the drain is a reset, not a sample). Add a `CameraCommand` family dimensioned `instance` × `verb` × `result` with `commandRequests`/`commandErrors` pairs and a `commandLatencyMs` sum, its cells defined lazily on first use. `camera_queue` stays a set of sampled gauges. **Recorded cardinality decision:** `camera_captures` / `camera_queue` stay **fleet-scoped** (no per-camera dimension); only the lower-frequency `CameraCommand` carries `instance`. | The two capture families lacked the `(Total, Interval)` pattern the rest of the ecosystem uses, so a fleet dashboard could not read them the same way as the other adapters. Per-camera dimensions on the capture families are deliberately avoided: a 256-camera fleet would mint 256 streams per measure on the highest-frequency families (the same reasoning that kept the fleet-scope originally), and pre-populating `instance × verb × result` for the command family would cold-define 256 × 16 × 2 streams — so `CameraCommand` is defined lazily and only the families whose frequency justifies it carry `instance`. Per-camera capture state remains answerable through `sb/queue-status` and the per-instance connectivity keepalive. | -| D-CAM-26 | Edge-console panel trio | Register `overview` / `signals` / `diagnostics` panels via `register_panel` (order 10/20/30, `scope: "instance"`), bound to the verbs the adapter serves: overview → `sb/status`/`sb/reconnect`/`sb/pause`/`sb/resume`; signals → `sb/list`/`sb/status`/`sb/capture`/`sb/capture-status`; diagnostics → `sb/discover`/`sb/queue-status`. | The baseline advertises a descriptor-driven panel surface to edge-console; registering the trio on the same inbox as the verbs, before the acknowledged subscription begins, advertises the panels atomically with the command surface they drive. | +| D-CAM-26 | Edge-console panel trio | Register `overview` / `signals` / `diagnostics` panels via `register_panel` (order 10/20/30, `scope: "instance"`), bound to the verbs the adapter serves: overview → `sb/status`/`sb/reconnect`/`sb/pause`/`sb/resume`; signals → `sb/list`/`sb/status`/`sb/capture`/`sb/capture-status`; diagnostics → `sb/discover`/`sb/queue-status`. Every panel meets the renderable-descriptor floor: `summary`/`keyValueList` widgets carry `rows`, `commandSummary` widgets carry `verbs`, widgets carry `id`/`title`, and no widget names a `writeVerb`. The signal-adapter widget kinds are absent by design — no `signalGrid` (no `sb/signals`/`sb/read` signal inventory; `cameraRoster`/`captureSurface` are the camera-domain equivalents) and no `treeBrowser` (no hierarchical `sb/browse`; `sb/discover` is an active scan, not a browse). | The baseline advertises a descriptor-driven panel surface to edge-console; registering the trio on the same inbox as the verbs, before the acknowledged subscription begins, advertises the panels atomically with the command surface they drive. | | D-CAM-27 | Committed `Cargo.lock`; `data-types.md` N/A | Commit `Cargo.lock` (drop the `/Cargo.lock` gitignore + rationale), regenerated with the local `.cargo` `[patch]` override inactive so it records the pinned git source and is valid on a fresh clone / in CI. The RTSP native-coverage harness (`simulators/run-rtsp-native-coverage.ps1`), which mounts the workspace read-only, keeps its **writable-overlay** lockfile — a single-file bind mount from outside the read-only source tree that masks the committed lock and is (re)generated in-container by a prep run — so cargo never has to rewrite the committed git-sourced lock on the read-only mount. The adapter's signal `data-types.md` page is **N/A**: this component publishes capture announcements (`app/image/*`), not `SouthboundSignalUpdate` envelopes, so the signal value-mapping page does not apply; the published shapes are documented in `messaging-interface.md`. | The three-way constraint (a committed lock must record the git source ⊕ a `[patch]`-ed local build rewrites it to a path source ⊕ the read-only validation mount cannot rewrite it) is resolved deliberately rather than by suppressing the `edgecommons component validate` warning: the committed lock is git-sourced and reproducible on a fresh clone/CI, and the two local frictions (a `git status` that shows lock churn while the patch is active, and the read-only harness) are handled by simply not committing that churn and by running the harness with the patch inactive. The manifest `license` field is reconciled to `BUSL-1.1` to match the `LICENSE` file (`Fixes #7`). | | D-CAM-28 | `config.schema.json` | Author a Draft 2020-12 `config.schema.json` whose **root models `component.global`** (with `$defs/camera` for one `component.instances[]` entry and `$defs` for every backend/profile/schedule/enum), derived field-for-field from `src/config.rs`. Strict (`additionalProperties:false`) everywhere the parser is (`deny_unknown_fields`); permissive only where the parser is (`featureOverrides`, `resourceGroups`). | `edgecommons component validate` could not check camera configs and warned on the missing schema. The CLI validates `component.global` against the schema root (`ec-validate/schema.rs` checks `/component/global` only), so the root is `component.global`; `$defs/camera` documents and lets external Draft 2020-12 validators check `instances[]`, matching the scaffold's `$defs/device`. Validated: the shipped `deploy/docker/simulator-config.json` and the §10.1 full example ACCEPT; a malformed global key, an ONVIF backend missing `mediaProfile`, a bad enum, an unknown backend field, and a camera missing `backend` all REJECT. `edgecommons component validate` reports **no findings** (schema valid, config clean, lockfile warning resolved). | | D-CAM-22 | Bare-RTSP backend | A distinct `rtsp` backend addresses a camera by a raw `rtsp://`/`rtsps://` URL, with no ONVIF. It is still-image only, reuses the shared RTSP engine (`RtspCaptureController`) and the network/credential/TLS primitives, and advertises `capture_modes=[rtsp-frame]` with all PTZ/snapshot/discovery capabilities off. To make it buildable without ONVIF, the protocol-neutral net/auth primitives and the credential-resolution seam are lifted from the `onvif` module into a shared `backend::net` module, and the `rtsp` cargo feature is decoupled from `onvif`. | ONVIF gives identity, capability discovery, media profiles, snapshot, PTZ, and the governed stream URI; a raw RTSP URL gives none of these, so it is a genuinely different camera kind rather than a mode of `onvif-rtsp` — a separate backend keeps the ONVIF backend's required-field invariants (`deviceServiceUrl`/`mediaProfile`) intact. `connect()` performs the RTSP `DESCRIBE`/`SETUP` + auth + SDP/codec validation so a dead URL, bad auth, or unsupported codec fails at connect (the supervisor keeps such a camera OFFLINE rather than falsely ONLINE, since reachability is inferred from a successful connect). The URL carries no credentials (userinfo is rejected); credentials are `$secret` references resolved through the same bounded EdgeCommons path as ONVIF, and the same host-allowlist / DNS-pin / RTSPS-SNI / forbidden-address policy applies to the user-supplied URL. Decoupling the feature lets an operator ship an RTSP-only binary without the ONVIF surface. | @@ -1503,7 +1503,7 @@ must round-trip unchanged. | `NO_SUCH_INSTANCE` | Target camera ID is not configured. | | `CAMERA_DISABLED` | The configured camera is disabled. | | `DEVICE_UNAVAILABLE` | Camera is offline (session down or unreachable) and the command policy does not wait. | -| `INSTANCE_PAUSED` | The camera is paused (`sb/pause`) and refuses new capture work until resumed. | +| `PAUSED` | The camera is paused (`sb/pause`) and refuses new capture work until resumed. | | `CAMERA_MOVING` | Capture/PTZ interlock rejected capture while the camera was moving. | | `UNSUPPORTED_CAPABILITY` | Camera/backend cannot perform the requested operation. | | `UNKNOWN_CAPTURE_PROFILE` | Named profile is not configured for the camera. | @@ -2000,21 +2000,28 @@ Greengrass recipes and MQTT ACLs grant only: ### 19.1 Standard southbound health -The adapter emits the standard `southbound_health` metric dimensioned by `instance` with the current -contract measures: +The adapter emits the standard `southbound_health` metric dimensioned by `instance` with exactly the +eight SOUTHBOUND §5 contract measures: - `connectionState`: 1 when the camera session is online, otherwise 0; +- `signalsSubscribed`: the signal inventory the session serves — a camera serves no + `SouthboundSignalUpdate` signals (its data points are images announced on `app/image/*`), so the + gauge reads 0 (the contract's "0 while disconnected" holds trivially); - `publishLatencyMs`: terminal application-message publication latency; - `pollLatencyMs`: the most recent capture/status acquisition round-trip where available; - `readErrors`: acquisition/status read errors in the interval; +- `writeErrors`: device-path failures of allow-listed southbound signal writes — the adapter exposes + no signal-write surface (`sb/write` is not served; PTZ failures ride `CameraPtz`), so the interval + counter reads 0; - `staleSignals`: 1 when the camera has no successful health/capture observation within the configured - stale threshold, otherwise 0. + stale threshold, otherwise 0; +- `reconnects`: sessions re-established in the interval. `publishLatencyMs` is observed on the terminal announcement that reached the transport; an announcement that could not be published contributes no latency and is counted as `announcementFailed` instead. -The optional standard `reconnects` measure is also emitted. Additional capture, queue, storage, and PTZ -measures below are camera-specific metrics; they do not redefine `southbound_health`. +Additional capture, queue, storage, and PTZ measures below are camera-specific metrics; they do not +redefine `southbound_health`. ### 19.2 Camera metrics diff --git a/docs/reference/messaging-interface.md b/docs/reference/messaging-interface.md index d2cdce9..e215ea3 100644 --- a/docs/reference/messaging-interface.md +++ b/docs/reference/messaging-interface.md @@ -437,7 +437,7 @@ session, not the camera); ledgered and settled immediately. **What it does.** Suspends a camera's new capture work: while paused, scheduled captures are held and new commanded captures (`sb/capture`, `sb/capture-submit`, `sb/capture-group`, `sb/capture-group-submit`) are -refused with `INSTANCE_PAUSED`. Captures already in flight run to completion. The verb is idempotent, and +refused with `PAUSED`. Captures already in flight run to completion. The verb is idempotent, and `changed` reports whether the state moved. Pause is in-memory only — a restart begins unpaused. It applies only to capture workload: `sb/reconnect`, `sb/ptz*`, and the read-only verbs are unaffected, and the paused state is surfaced by `sb/status`. @@ -599,7 +599,7 @@ lifecycle event. ## Stable errors The public error `code` is one of `BAD_ARGS`, `NO_SUCH_INSTANCE`, `CAMERA_DISABLED`, -`DEVICE_UNAVAILABLE`, `INSTANCE_PAUSED`, `CAMERA_MOVING`, `UNSUPPORTED_CAPABILITY`, +`DEVICE_UNAVAILABLE`, `PAUSED`, `CAMERA_MOVING`, `UNSUPPORTED_CAPABILITY`, `UNKNOWN_CAPTURE_PROFILE`, `QUEUE_FULL`, `GROUP_TOO_LARGE`, `RESOURCE_LIMIT`, `CAPTURE_TIMEOUT`, `CAPTURE_CANCELLED`, `PROCESS_INTERRUPTED`, `CAPTURE_NOT_FOUND`, `IDEMPOTENCY_CONFLICT`, `PREVIOUS_OUTCOME_UNKNOWN`, `REPLY_REQUIRED`, `UNSUPPORTED_PIXEL_FORMAT`, `STORAGE_PRESSURE`, @@ -608,7 +608,7 @@ The public error `code` is one of `BAD_ARGS`, `NO_SUCH_INSTANCE`, `CAMERA_DISABL `BAD_ARGS` covers a malformed request body, an out-of-range field, and instance routing: a missing `instance` when more than one camera is configured is `BAD_ARGS`, and an unknown `instance` is -`NO_SUCH_INSTANCE`. `INSTANCE_PAUSED` is returned when a camera has been paused with `sb/pause` and is +`NO_SUCH_INSTANCE`. `PAUSED` is returned when a camera has been paused with `sb/pause` and is asked to accept new capture work. The routing and availability codes (`BAD_ARGS`, `NO_SUCH_INSTANCE`, `DEVICE_UNAVAILABLE`) are the standardized southbound names shared across EdgeCommons adapters (`core/docs/SOUTHBOUND.md` §2.2); the domain codes (`CAPTURE_*`, `PTZ_*`, `STORAGE_PRESSURE`, …) are diff --git a/docs/reference/metrics.md b/docs/reference/metrics.md index 1ab9140..510b1bf 100644 --- a/docs/reference/metrics.md +++ b/docs/reference/metrics.md @@ -14,9 +14,11 @@ a camera connects or disconnects. | Measure | Unit | Meaning | |---|---|---| | `connectionState` | Count | 1 while the camera's session is live, 0 otherwise. | +| `signalsSubscribed` | Count | The signal inventory the session serves. A camera serves no `SouthboundSignalUpdate` signals — its data points are images announced on `app/image/*` — so the gauge reads 0. | | `publishLatencyMs` | Milliseconds | How long the camera's last terminal message took to reach the transport. Absent until one has. | | `pollLatencyMs` | Milliseconds | The last acquisition round-trip. Absent until the camera has produced a frame. | | `readErrors` | Count | Acquisition failures in the interval. A failure to encode or to write to disk is not counted: it is not the camera's fault. | +| `writeErrors` | Count | Device-path failures of southbound signal writes. The adapter serves no signal-write surface (PTZ failures are counted in `CameraPtz`), so the counter reads 0. | | `staleSignals` | Count | 1 when the camera has produced nothing within `healthThresholds.staleSignalSecs`, 0 otherwise. A camera can be connected and stale. | | `reconnects` | Count | Sessions re-established in the interval. A camera's first connection is not a reconnect. | diff --git a/src/error.rs b/src/error.rs index 76e2b43..378f8ef 100644 --- a/src/error.rs +++ b/src/error.rs @@ -23,6 +23,8 @@ pub enum ErrorCode { /// southbound availability code. DeviceUnavailable, /// The camera instance is paused (`sb/pause`) and refuses new capture work until resumed. + /// Standardized southbound paused-refusal code (SOUTHBOUND.md §2.2): a whole operation the + /// paused state prohibits answers the top-level code `PAUSED`. InstancePaused, /// The capture/PTZ interlock rejected the operation. CameraMoving, @@ -77,7 +79,7 @@ impl ErrorCode { Self::NoSuchInstance => "NO_SUCH_INSTANCE", Self::CameraDisabled => "CAMERA_DISABLED", Self::DeviceUnavailable => "DEVICE_UNAVAILABLE", - Self::InstancePaused => "INSTANCE_PAUSED", + Self::InstancePaused => "PAUSED", Self::CameraMoving => "CAMERA_MOVING", Self::UnsupportedCapability => "UNSUPPORTED_CAPABILITY", Self::UnknownCaptureProfile => "UNKNOWN_CAPTURE_PROFILE", @@ -405,7 +407,7 @@ mod tests { (ErrorCode::NoSuchInstance, "NO_SUCH_INSTANCE"), (ErrorCode::CameraDisabled, "CAMERA_DISABLED"), (ErrorCode::DeviceUnavailable, "DEVICE_UNAVAILABLE"), - (ErrorCode::InstancePaused, "INSTANCE_PAUSED"), + (ErrorCode::InstancePaused, "PAUSED"), (ErrorCode::CameraMoving, "CAMERA_MOVING"), (ErrorCode::UnsupportedCapability, "UNSUPPORTED_CAPABILITY"), (ErrorCode::UnknownCaptureProfile, "UNKNOWN_CAPTURE_PROFILE"), diff --git a/src/observability.rs b/src/observability.rs index c5f2932..981c7e0 100644 --- a/src/observability.rs +++ b/src/observability.rs @@ -508,18 +508,31 @@ impl CaptureMetrics { let metric = edgecommons::metrics::MetricBuilder::create(HEALTH_METRIC) .add_dimension("instance", instance) .add_measure("connectionState", "Count", 1) + .add_measure("signalsSubscribed", "Count", 1) .add_measure("publishLatencyMs", "Milliseconds", 1) .add_measure("pollLatencyMs", "Milliseconds", 1) .add_measure("readErrors", "Count", 60) + .add_measure("writeErrors", "Count", 60) .add_measure("staleSignals", "Count", 60) .add_measure("reconnects", "Count", 60) .build(); - let mut values = std::collections::HashMap::with_capacity(6); + let mut values = std::collections::HashMap::with_capacity(8); values.insert( "connectionState".to_owned(), f64::from(sample.connection_state), ); + // The two remaining SOUTHBOUND §5 measures are structurally constant for a camera and are + // emitted so the eight-measure family stays fleet-uniform: + // - `signalsSubscribed` counts the signal inventory a session serves. A camera serves no + // `SouthboundSignalUpdate` signals -- its data points are images announced on + // `app/image/*` -- so the gauge reads 0 whether connected or not (the contract's + // "0 while disconnected" holds trivially). + // - `writeErrors` counts device-path failures of allow-listed signal writes. The adapter + // exposes no southbound signal-write surface (`sb/write` is not served; PTZ failures are + // the `CameraPtz` family), so the interval counter reads 0. + values.insert("signalsSubscribed".to_owned(), 0.0); + values.insert("writeErrors".to_owned(), 0.0); values.insert("readErrors".to_owned(), sample.read_errors as f64); values.insert("staleSignals".to_owned(), f64::from(sample.stale_signals)); values.insert("reconnects".to_owned(), sample.reconnects as f64); @@ -963,6 +976,25 @@ mod tests { assert_eq!(values.get("connectionState"), Some(&1.0)); assert_eq!(values.get("readErrors"), Some(&2.0)); assert_eq!(values.get("reconnects"), Some(&1.0)); + // The SOUTHBOUND §5 measures with no camera-side source read 0, not absent: the family + // stays the exact eight-measure shape fleet dashboards consume. + assert_eq!(values.get("signalsSubscribed"), Some(&0.0)); + assert_eq!(values.get("writeErrors"), Some(&0.0)); + // The definition carries exactly the eight SOUTHBOUND §5 measures. + let defined = target.last_definition(); + let measures: Vec<&str> = defined.get_measures().keys().map(String::as_str).collect(); + let mut expected = vec![ + "connectionState", + "publishLatencyMs", + "pollLatencyMs", + "readErrors", + "staleSignals", + "reconnects", + "writeErrors", + "signalsSubscribed", + ]; + expected.sort_unstable(); + assert_eq!(measures, expected, "southbound_health is the exact eight-measure family"); assert_eq!( target.last_definition().get_dimensions().get("instance"), Some(&"camera-a".to_owned()), diff --git a/src/runtime.rs b/src/runtime.rs index 6c69506..9a49d87 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -2259,26 +2259,64 @@ pub fn camera_command_verbs() -> Vec<&'static str> { /// /// Core validates `id`/`title`/uniqueness; the widget kinds and bound verbs are console-interpreted, /// so they ride verbatim. `order` 10/20/30, every panel `scope: "instance"`. Each binds only verbs -/// this adapter actually serves (SOUTHBOUND.md §2.2, the panel-trio baseline). +/// this adapter actually serves (SOUTHBOUND.md §2.2, the panel-trio baseline), and every panel +/// carries at least one widget the shipped console renders today (`summary`/`keyValueList` rows, +/// `commandSummary` verbs). The signal-adapter widgets do not map onto a camera and are absent by +/// design: no `signalGrid` (the adapter serves no `sb/signals`/`sb/read` signal inventory -- the +/// `cameraRoster`/`captureSurface` kinds are the camera-domain equivalents a future console can +/// bind), no `treeBrowser` (there is no hierarchical `sb/browse`; `sb/discover` is an active +/// network scan with a closed request schema, not a browse), and no widget names a `writeVerb` +/// (there is no southbound signal-write surface). #[must_use] pub fn camera_panels() -> Vec { vec![ serde_json::json!({ "id": "overview", "title": "Overview", "order": 10, "scope": "instance", "widgets": [ - { "kind": "summary", "fields": ["state", "connected", "paused", "backend"] }, - { "kind": "commandSummary", "actions": ["sb/reconnect", "sb/pause", "sb/resume"] } + { + "kind": "summary", "id": "overview-summary", "title": "Camera overview", + "rows": [ + { "label": "Status", "value": "State, connection, pause, and backend via cmd/sb/status" }, + { "label": "Lifecycle", "value": "Pause, resume, and reconnect the camera instance" }, + { "label": "Captures", "value": "On-demand and scheduled captures announced on app/image/*" } + ] + }, + { + "kind": "commandSummary", "id": "overview-lifecycle", "title": "Lifecycle bindings", + "verbs": ["sb/status", "sb/reconnect", "sb/pause", "sb/resume"] + } ], "verbs": ["sb/status", "sb/reconnect", "sb/pause", "sb/resume"] }), serde_json::json!({ "id": "signals", "title": "Cameras", "order": 20, "scope": "instance", - "widgets": [ { "kind": "cameraRoster" }, { "kind": "captureSurface" } ], + "widgets": [ + { "kind": "cameraRoster", "id": "camera-roster", "title": "Camera roster", + "scope": "instance", "listVerb": "sb/list", "statusVerb": "sb/status" }, + { "kind": "captureSurface", "id": "capture-surface", "title": "Captures", + "scope": "instance", "captureVerb": "sb/capture", "statusVerb": "sb/capture-status" }, + { + "kind": "commandSummary", "id": "capture-commands", "title": "Capture bindings", + "verbs": ["sb/list", "sb/status", "sb/capture", "sb/capture-status"] + } + ], "verbs": ["sb/list", "sb/status", "sb/capture", "sb/capture-status"] }), serde_json::json!({ "id": "diagnostics", "title": "Diagnostics", "order": 30, "scope": "instance", - "widgets": [ { "kind": "treeBrowser" }, { "kind": "keyValueList" } ], + "widgets": [ + { + "kind": "keyValueList", "id": "diagnostic-surfaces", "title": "Diagnostics", + "rows": [ + { "label": "Discovery", "value": "Scan for cameras via cmd/sb/discover" }, + { "label": "Queue", "value": "Capture queue depth and age via cmd/sb/queue-status" } + ] + }, + { + "kind": "commandSummary", "id": "diagnostic-commands", "title": "Diagnostic commands", + "verbs": ["sb/discover", "sb/queue-status"] + } + ], "verbs": ["sb/discover", "sb/queue-status"] }), ] diff --git a/src/runtime/tests/simulator_runtime/coverage_command.rs b/src/runtime/tests/simulator_runtime/coverage_command.rs index 16ca3a5..209ba4c 100644 --- a/src/runtime/tests/simulator_runtime/coverage_command.rs +++ b/src/runtime/tests/simulator_runtime/coverage_command.rs @@ -4754,7 +4754,7 @@ async fn a_thumbnail_that_fails_or_is_dropped_is_counted_on_the_capture_metric() /// The lifecycle verbs suspend a camera's new capture work and surface the state in `sb/status`. /// /// `sb/pause` / `sb/resume` are the standardized lifecycle verbs (SOUTHBOUND.md §2.2). Pausing a -/// camera refuses NEW capture work with a stable `INSTANCE_PAUSED` code while leaving its sibling +/// camera refuses NEW capture work with the standardized `PAUSED` code while leaving its sibling /// untouched; the toggle is idempotent (`changed` reports whether it moved); and the paused flag is /// visible where an operator and the overview panel read status. Resuming clears it. #[tokio::test] @@ -4893,4 +4893,45 @@ fn the_panel_trio_is_registered_with_the_right_ids_orders_and_scope() { json!(["sb/status", "sb/reconnect", "sb/pause", "sb/resume"]) ); assert_eq!(panels[2]["verbs"], json!(["sb/discover", "sb/queue-status"])); + + // The renderable-descriptor floor: every panel carries a widget the shipped console renders + // today -- `summary`/`keyValueList` with `rows`, `commandSummary` with `verbs` -- and no widget + // advertises a `writeVerb` (the guarded-write console flow does not exist, and this adapter + // serves no southbound signal-write surface). The signal-adapter kinds (`signalGrid`, + // `treeBrowser`) are deliberately absent: no signal inventory, no hierarchical browse. + for panel in &panels { + let widgets = panel["widgets"].as_array().expect("widgets"); + assert!( + widgets.iter().any(|widget| { + let rows = widget["rows"].as_array().is_some_and(|rows| !rows.is_empty()); + let verbs = widget["kind"] == json!("commandSummary") + && widget["verbs"].as_array().is_some_and(|verbs| !verbs.is_empty()); + rows || verbs + }), + "panel {} carries a console-renderable widget", + panel["id"] + ); + for widget in widgets { + assert!(widget.get("writeVerb").is_none(), "no widget advertises writeVerb"); + assert!(widget.get("id").is_some() && widget.get("title").is_some()); + assert!(widget.get("kind") != Some(&json!("signalGrid"))); + assert!(widget.get("kind") != Some(&json!("treeBrowser"))); + } + } + // Every commandSummary lists verbs (not the retired `actions` key), and each listed verb is one + // the adapter registers. + let registered = crate::runtime::camera_command_verbs(); + for panel in &panels { + for widget in panel["widgets"].as_array().unwrap() { + assert!(widget.get("actions").is_none(), "the `actions` key is retired"); + if let Some(verbs) = widget["verbs"].as_array() { + for verb in verbs { + assert!( + registered.contains(&verb.as_str().unwrap()), + "panel widget binds only served verbs" + ); + } + } + } + } }