diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index dbedd306..56522125 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -11,6 +11,17 @@ ], "service": "barton", + // Forward the camera reference app's HTTP media server (`cs`). The app connects to the + // camera over WebRTC in-container (direct UDP, no TURN) and serves the video as fragmented + // MP4 over this port; it opens in VS Code's embedded Simple Browser (an editor tab). + "forwardPorts": [8088], + "portsAttributes": { + "8088": { + "label": "Camera stream", + "onAutoForward": "openPreview" + } + }, + // keep the local path of our tree the same within the container "workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind", "workspaceFolder": "${localWorkspaceFolder}", @@ -42,6 +53,22 @@ "GIT_PS1_SHOWCOLORHINTS": "true", "PROMPT_COMMAND": "${localEnv:PROMPT_COMMAND}", + // X11 display. Dev Containers already bridges the developer's display to the + // container's :0 socket (it sets REMOTE_CONTAINERS_DISPLAY_SOCK=/tmp/.X11-unix/X0 + // but does not export DISPLAY). Setting it here lets GUI/GStreamer apps — e.g. the + // camera reference app's autovideosink — render straight to the developer's screen + // with no VNC bridge or URL. Harmless when no host X server is present: GUI apps + // simply fail to open the display, and non-GUI processes ignore it. + "DISPLAY": ":0", + + // Force GStreamer's autovideosink to choose the software ximagesink here. The + // forwarded X display (:0 above) only supports basic X drawing, not OpenGL/Xv, so + // GPU sinks like glimagesink fail over the tunnel. This is a limitation of the + // VS Code remote devcontainer environment, NOT of reference-app environments in + // general — real reference devices have a local GPU and should let autovideosink + // pick a hardware sink — so the constraint lives here rather than in the app. + "GST_PLUGIN_FEATURE_RANK": "ximagesink:MAX", + // see `docker/setupDockerEnv.sh` for more details on these custom PATHs // NOTE: do not add a custom PATH here without first defining it in setupDockerEnv.sh // Build-tree path comes first so freshly compiled libraries are used without `make install`. diff --git a/.github/skills/validate-sbmd/SKILL.md b/.github/skills/validate-sbmd/SKILL.md index beb47730..372d4332 100644 --- a/.github/skills/validate-sbmd/SKILL.md +++ b/.github/skills/validate-sbmd/SKILL.md @@ -27,7 +27,7 @@ The `validate_sbmd_specs` target uses Node.js to extract each driver's registrat ### Validate SBMD Spec Files ```bash -python3 scripts/ci/validate_sbmd_v4_specs.py \ +python3 scripts/ci/validate_sbmd_specs.py \ core/deviceDrivers/matter/sbmd/schema \ core/deviceDrivers/matter/sbmd/specs/*.sbmd.js ``` @@ -35,7 +35,7 @@ python3 scripts/ci/validate_sbmd_v4_specs.py \ This: 1. Uses Node.js to evaluate each `.sbmd.js` file in a sandbox 2. Extracts the `SbmdDriver()` registration object as JSON (functions → `true`) -3. Validates the JSON against `sbmd-spec-schema-v4.0.json` +3. Validates the JSON against `sbmd-spec-schema.json` The validator evaluates each `.sbmd.js` file, extracts the `SbmdDriver()` registration object, and validates it against the JSON schema. @@ -44,9 +44,9 @@ The validator evaluates each `.sbmd.js` file, extracts the `SbmdDriver()` regist | Item | Location | |------|----------| | SBMD spec files | `core/deviceDrivers/matter/sbmd/specs/*.sbmd.js` | -| JSON schema | `core/deviceDrivers/matter/sbmd/schema/sbmd-spec-schema-v4.0.json` | +| JSON schema | `core/deviceDrivers/matter/sbmd/schema/sbmd-spec-schema.json` | | TypeScript definitions (editor tooling only — not used by the validator) | `core/deviceDrivers/matter/sbmd/scriptCommon/sbmd-script.d.ts` | -| Validation script | `scripts/ci/validate_sbmd_v4_specs.py` | +| Validation script | `scripts/ci/validate_sbmd_specs.py` | | Extraction harness | `scripts/ci/sbmd_extract_registration.js` | ## Discovering Available SBMD Specs diff --git a/api/c/src/barton-core-client.c b/api/c/src/barton-core-client.c index ba9808cb..33ee62cb 100644 --- a/api/c/src/barton-core-client.c +++ b/api/c/src/barton-core-client.c @@ -38,6 +38,7 @@ #include "deviceServicePrivate.h" #include "event/deviceEventProducer.h" #include "icTypes/icLinkedList.h" +#include "observability/observability.h" #include "icTypes/icLinkedListFuncs.h" #include "observability/observability.h" diff --git a/config/cmake/platforms/dev/linux.cmake b/config/cmake/platforms/dev/linux.cmake index 35d9de83..e32f36d4 100644 --- a/config/cmake/platforms/dev/linux.cmake +++ b/config/cmake/platforms/dev/linux.cmake @@ -34,6 +34,9 @@ set(CMAKE_PREFIX_PATH "${CMAKE_BINARY_DIR}/matter-install" CACHE PATH "Path to M set(BCORE_GEN_GIR ON CACHE BOOL "Gir generation") +# Build the reference app's camera stream command (GStreamer WebRTC) in the dev platform. +set(BCORE_REFERENCE_CAMERA_SUPPORT ON CACHE BOOL "Enable reference app camera support") + # Matter settings include(${CMAKE_SOURCE_DIR}/config/cmake/modules/BCoreMatterHelper.cmake) diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index e9ab3831..6b87507a 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -207,10 +207,10 @@ if (BCORE_MATTER) list(APPEND XTRA_LIBS ${BCORE_MATTER_LIB} ${OPENSSL_LINK_LIBRARIES} jsoncpp) link_directories(${CMAKE_BINARY_DIR}/matter-install/lib) - # SBMD v4 specification validation + # SBMD specification validation if (BCORE_MATTER_VALIDATE_SCHEMAS) set(SBMD_SCHEMA_DIR "${CMAKE_CURRENT_SOURCE_DIR}/deviceDrivers/matter/sbmd/schema") - set(SBMD_VALIDATOR "${CMAKE_SOURCE_DIR}/scripts/ci/validate_sbmd_v4_specs.py") + set(SBMD_VALIDATOR "${CMAKE_SOURCE_DIR}/scripts/ci/validate_sbmd_specs.py") set(SBMD_EXTRACTOR "${CMAKE_SOURCE_DIR}/scripts/ci/sbmd_extract_registration.js") find_package(Python3 COMPONENTS Interpreter REQUIRED) @@ -224,10 +224,10 @@ if (BCORE_MATTER) COMMAND ${Python3_EXECUTABLE} ${SBMD_VALIDATOR} ${SBMD_SCHEMA_DIR} ${SBMD_SPEC_FILES} DEPENDS ${SBMD_SPEC_FILES} ${SBMD_SCHEMA_FILES} ${SBMD_VALIDATOR} ${SBMD_EXTRACTOR} WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - COMMENT "Validating SBMD v4 specification files against schema..." + COMMENT "Validating SBMD specification files against schema..." ) else() - message(STATUS "No SBMD v4 spec files found in ${SBMD_SPECS_DIR}; skipping spec validation") + message(STATUS "No SBMD spec files found in ${SBMD_SPECS_DIR}; skipping spec validation") endif() endif() endif() diff --git a/core/deviceDrivers/matter/MatterDevice.cpp b/core/deviceDrivers/matter/MatterDevice.cpp index 9dc30e55..e99082a4 100644 --- a/core/deviceDrivers/matter/MatterDevice.cpp +++ b/core/deviceDrivers/matter/MatterDevice.cpp @@ -419,7 +419,12 @@ bool MatterDevice::SendCommandFromTlv(std::forward_list> &pro } bool isTimedRequest = timedInvokeTimeoutMs.has_value(); - auto commandSender = std::make_unique(this, &exchangeMgr, isTimedRequest); + + // Allow large (TCP) payloads when the session supports them so large commands do not fail + // to serialize against the MRP/UDP single-message limit. + bool allowLargePayload = sessionHandle->AllowsLargePayload(); + auto commandSender = + std::make_unique(this, &exchangeMgr, isTimedRequest, false, allowLargePayload); if (!commandSender) { @@ -545,7 +550,13 @@ bool MatterDevice::SendCommandWithCallbacks(chip::ClusterId clusterId, } bool isTimedRequest = timedInvokeTimeoutMs.has_value(); - auto commandSender = std::make_unique(this, &exchangeMgr, isTimedRequest); + + // Allow large (TCP) payloads when the session supports them. Without this the CommandSender + // caps its buffer at the MRP/UDP single-message limit (~1280 bytes), which makes large + // commands such as a WebRTC ProvideOffer SDP fail to serialize. + bool allowLargePayload = sessionHandle->AllowsLargePayload(); + auto commandSender = + std::make_unique(this, &exchangeMgr, isTimedRequest, false, allowLargePayload); if (!commandSender) { @@ -578,6 +589,10 @@ bool MatterDevice::SendCommandWithCallbacks(chip::ClusterId clusterId, if (err != CHIP_NO_ERROR) { + icError("Failed to enter TLV container for deferred command cluster 0x%x cmd 0x%x: %s", + clusterId, + commandId, + err.AsString()); return false; } @@ -587,12 +602,22 @@ bool MatterDevice::SendCommandWithCallbacks(chip::ClusterId clusterId, if (err != CHIP_NO_ERROR) { + // A buffer-too-small/no-memory error here typically means the payload exceeds what the + // session's transport allows (e.g. a large SDP over an MRP/UDP session that is not + // large-payload capable). Establishing the session with a large-payload (TCP) transport + // resolves it. + icError("Failed to copy TLV element for deferred command cluster 0x%x cmd 0x%x " + "(payload may exceed the session's transport limit): %s", + clusterId, + commandId, + err.AsString()); return false; } } if (err != CHIP_END_OF_TLV) { + icError("Malformed TLV for deferred command cluster 0x%x cmd 0x%x: %s", clusterId, commandId, err.AsString()); return false; } @@ -603,6 +628,13 @@ bool MatterDevice::SendCommandWithCallbacks(chip::ClusterId clusterId, if (err != CHIP_NO_ERROR) { + // As with CopyElement above, this commonly indicates the payload exceeds the session's + // transport limit; a large-payload (TCP) session is required for oversized commands. + icError("Failed to finish deferred command cluster 0x%x cmd 0x%x " + "(payload may exceed the session's transport limit): %s", + clusterId, + commandId, + err.AsString()); return false; } diff --git a/core/deviceDrivers/matter/MatterDeviceDriver.cpp b/core/deviceDrivers/matter/MatterDeviceDriver.cpp index 563c246d..e46839c5 100644 --- a/core/deviceDrivers/matter/MatterDeviceDriver.cpp +++ b/core/deviceDrivers/matter/MatterDeviceDriver.cpp @@ -948,25 +948,30 @@ bool MatterDeviceDriver::ConnectAndExecute(const std::string &deviceId, connect_ chip::Callback::Callback successCb(OnMatterDeviceConnectionSuccess, static_cast(&workWrapper)); - // SDK event size limitations prevent directly capturing too many objects. - // connectPromise is directly pointed at in failCb.mContext, so it is indirectly - // captured via failCb. - - auto err = chip::DeviceLayer::SystemLayer().ScheduleLambda([&deviceId, &successCb, &failCb]() { - auto nodeId = Subsystem::Matter::UuidToNodeId(deviceId); - - CHIP_ERROR getConnectedErr = - Matter::GetInstance().GetCommissioner()->GetConnectedDevice(nodeId, &successCb, &failCb); - if (getConnectedErr != CHIP_NO_ERROR) - { - icError("Failed to start device connection: %s", getConnectedErr.AsString()); - static_cast *>(failCb.mContext)->set_value(false); - } + // Initiate the connection on the Matter thread. GetConnectedDevice is non-blocking (it starts + // the connection and returns immediately; successCb/failCb fire later). RunOnMatterSync marshals + // this onto the Matter thread (via ScheduleLambda) but blocks until it has run, so + // GetConnectedDevice is guaranteed to execute before ConnectAndExecute can proceed or time out. + // That ordering is the point: unlike a bare ScheduleLambda whose deferred work could run *after* + // ConnectAndExecute has returned, the connection is never initiated once we are past this call, + // so a timed-out call can never later invoke GetConnectedDevice with dangling callback pointers. + // Blocking here also keeps the by-reference captures (including the stack-owned callbacks) alive + // for the whole call, and RunOnMatterSync's std::function work is not subject to the LambdaBridge + // 24-byte capture limit that applies to using ScheduleLambda directly. + chip::NodeId nodeId = Subsystem::Matter::UuidToNodeId(deviceId); + // Seed with an error so that if RunOnMatterSync fails to schedule the work (e.g. the stack goes + // down between the IsRunning() check above and this call), getConnectedErr stays non-OK and we + // fail fast below instead of waiting out the full connect timeout on a promise that would never + // be satisfied. + CHIP_ERROR getConnectedErr = CHIP_ERROR_INCORRECT_STATE; + + RunOnMatterSync([&]() { + getConnectedErr = Matter::GetInstance().GetCommissioner()->GetConnectedDevice(nodeId, &successCb, &failCb); }); - if (err != CHIP_NO_ERROR) + if (getConnectedErr != CHIP_NO_ERROR) { - icError("Failed to schedule connect task: %s", err.AsString()); + icError("Failed to start device connection: %s", getConnectedErr.AsString()); return false; } diff --git a/core/deviceDrivers/matter/sbmd/SpecBasedMatterDeviceDriver.cpp b/core/deviceDrivers/matter/sbmd/SpecBasedMatterDeviceDriver.cpp index 000e8033..25c254a9 100644 --- a/core/deviceDrivers/matter/sbmd/SpecBasedMatterDeviceDriver.cpp +++ b/core/deviceDrivers/matter/sbmd/SpecBasedMatterDeviceDriver.cpp @@ -35,6 +35,7 @@ #include "matter/sbmd/mquickjs/SbmdHandlerInvoker.h" #endif +#include #include #include #include @@ -531,6 +532,14 @@ std::optional SpecBasedMatterDeviceDriver::ConvertModesToBitmask(const { bitmask |= RESOURCE_MODE_EXECUTABLE; } + else if (mode == "dynamic" || mode == "emitEvents") + { + // Both are enabled by default (see the initial bitmask). Accept them as explicit + // no-ops so specs that list these default-on modes register successfully. Contradictory + // combinations with their opt-out counterparts ("static"/"noEvents") are rejected at + // spec-validation time by the schema's modes constraint, so no runtime resolution is + // needed here. + } else if (mode == "static") { bitmask &= ~(RESOURCE_MODE_DYNAMIC | RESOURCE_MODE_DYNAMIC_CAPABLE); @@ -547,6 +556,11 @@ std::optional SpecBasedMatterDeviceDriver::ConvertModesToBitmask(const { bitmask |= RESOURCE_MODE_SENSITIVE; } + else if (mode == "volatile") + { + // Caching policy (CACHING_POLICY_NEVER) is applied separately in + // DoRegisterDriverResources; this mode contributes no access bit. + } else { icError("Unsupported resource mode: %s", mode.c_str()); @@ -643,8 +657,17 @@ bool SpecBasedMatterDeviceDriver::DoRegisterDriverResources(icDevice *device) // Resources without explicit read handlers are updated via attribute subscriptions, // so use CACHING_POLICY_ALWAYS to return the DB-cached value on read. // Resources with explicit read handlers use CACHING_POLICY_NEVER so the driver is called. + // The 'volatile' mode also forces CACHING_POLICY_NEVER so that, for a resource that emits + // events, updateResource delivers an event on every call (no value-change suppression) even + // when the value is unchanged. CACHING_POLICY_NEVER also means updateResource does not + // persist the value (or dateOfLastSyncMillis) to the device DB, and readable resources are + // served from the driver rather than the DB cache. 'volatile' is therefore intended for + // event-only signaling resources and should not be applied to subscription-backed state + // resources whose reads rely on the DB-cached value. + bool isVolatile = + std::find(resource.modes.begin(), resource.modes.end(), "volatile") != resource.modes.end(); ResourceCachingPolicy cachingPolicy = - resource.read.has_value() ? CACHING_POLICY_NEVER : CACHING_POLICY_ALWAYS; + (resource.read.has_value() || isVolatile) ? CACHING_POLICY_NEVER : CACHING_POLICY_ALWAYS; // Seed initial value if there's a seed handler const char *initialValue = nullptr; diff --git a/core/deviceDrivers/matter/sbmd/schema/CHANGELOG.md b/core/deviceDrivers/matter/sbmd/schema/CHANGELOG.md index d72fec85..a4f7c10b 100644 --- a/core/deviceDrivers/matter/sbmd/schema/CHANGELOG.md +++ b/core/deviceDrivers/matter/sbmd/schema/CHANGELOG.md @@ -1,5 +1,27 @@ # SBMD Schema Changelog +The schema lives in a single file, `sbmd-spec-schema.json`, whose version is not +encoded in the filename. This changelog records the schema version history, and +each spec's `schemaVersion` field is validated against the schema's expected +version. Each versioned heading below describes the changes introduced by that +version. + +## v5.0 + +- The schema version is no longer encoded in the schema filename + (`sbmd-spec-schema.json`) or in docs; `schemaVersion` is still validated + against the schema's expected version +- Breaking: attribute/event/command handlers must bind via `aliases`. The inline + `clusterId` + `attributeId`/`attributeIds` (and `eventId`/`eventIds`, + `commandId`/`commandIds`) binding form has been removed; declare an alias in the + spec's `aliases` map and reference it by name +- Add the `volatile` resource mode: disables value caching (`CACHING_POLICY_NEVER`) + so event-only signaling resources emit an update event on every `updateResource` + call even when the value is unchanged; it also stops persisting the value to the + device DB, so it is for event-only resources, not subscription-backed state +- `modes` now rejects the mutually-exclusive pairs `static`/`dynamic` and + `noEvents`/`emitEvents` + ## v4.0 - First schema for the JavaScript-native driver format: specs are authored as diff --git a/core/deviceDrivers/matter/sbmd/schema/sbmd-spec-schema-v4.0.json b/core/deviceDrivers/matter/sbmd/schema/sbmd-spec-schema.json similarity index 79% rename from core/deviceDrivers/matter/sbmd/schema/sbmd-spec-schema-v4.0.json rename to core/deviceDrivers/matter/sbmd/schema/sbmd-spec-schema.json index e2a5e536..a40b84ab 100644 --- a/core/deviceDrivers/matter/sbmd/schema/sbmd-spec-schema-v4.0.json +++ b/core/deviceDrivers/matter/sbmd/schema/sbmd-spec-schema.json @@ -1,16 +1,16 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "sbmd-spec-schema-v4.0.json", - "title": "SBMD v4.0 Registration Schema", - "description": "JSON Schema for the SbmdDriver() registration object in .sbmd.js spec files (schema version 4.0). Functions are represented as `true` after extraction.", + "$id": "sbmd-spec-schema.json", + "title": "SBMD Registration Schema", + "description": "JSON Schema for the SbmdDriver() registration object in .sbmd.js spec files. Functions are represented as `true` after extraction.", "type": "object", "required": ["schemaVersion", "driverVersion", "name", "constants", "barton", "matter"], "additionalProperties": false, "properties": { "schemaVersion": { "type": "string", - "const": "4.0", - "description": "Schema version. Must be '4.0'." + "const": "5.0", + "description": "Schema version. Must be '5.0'; see schema/CHANGELOG.md for the version history." }, "driverVersion": { "oneOf": [ @@ -253,9 +253,15 @@ "type": "array", "items": { "type": "string", - "enum": ["read", "write", "dynamic", "static", "emitEvents", "noEvents", "lazySaveNext", "sensitive"] + "enum": ["read", "write", "dynamic", "static", "emitEvents", "noEvents", "lazySaveNext", "sensitive", "volatile"] }, - "description": "Access modes controlling resource behavior." + "not": { + "anyOf": [ + { "allOf": [{ "contains": { "const": "static" } }, { "contains": { "const": "dynamic" } }] }, + { "allOf": [{ "contains": { "const": "noEvents" } }, { "contains": { "const": "emitEvents" } }] } + ] + }, + "description": "Access modes controlling resource behavior. 'static'/'dynamic' and 'noEvents'/'emitEvents' are mutually exclusive (each pair's members default on/off, so listing both is contradictory). 'volatile' disables value caching (CACHING_POLICY_NEVER) so that, for a resource that emits events, updateResource delivers an event on every call even when the value is unchanged. CACHING_POLICY_NEVER also stops persisting the last value/dateOfLastSyncMillis to the device DB, so 'volatile' is intended for event-only signaling resources, not subscription-backed state resources whose reads rely on the DB-cached value." }, "prerequisites": { "type": "array", @@ -302,113 +308,50 @@ "attributeHandler": { "type": "object", - "required": ["handler"], + "required": ["handler", "aliases"], "additionalProperties": false, "properties": { "aliases": { "type": "array", "items": { "type": "string" }, "minItems": 1, - "description": "Alias names to match. Mutually exclusive with clusterId." - }, - "clusterId": { - "type": "number", - "description": "Cluster to match. Mutually exclusive with aliases." - }, - "attributeId": { - "oneOf": [ - { "type": "number" }, - { "const": "*" } - ], - "description": "Single attribute ID or '*' wildcard. Mutually exclusive with attributeIds." - }, - "attributeIds": { - "type": "array", - "items": { "type": "number" }, - "minItems": 1, - "description": "Multiple attribute IDs. Mutually exclusive with attributeId." + "description": "Alias names to match." }, "supplements": { "$ref": "#/$defs/supplements" }, "handler": { "$ref": "#/$defs/functionRef" } - }, - "oneOf": [ - { "required": ["aliases"] }, - { "required": ["clusterId"] } - ] + } }, "eventHandler": { "type": "object", - "required": ["handler"], + "required": ["handler", "aliases"], "additionalProperties": false, "properties": { "aliases": { "type": "array", "items": { "type": "string" }, "minItems": 1, - "description": "Alias names to match. Mutually exclusive with clusterId." - }, - "clusterId": { - "type": "number", - "description": "Cluster to match. Mutually exclusive with aliases." - }, - "eventId": { - "oneOf": [ - { "type": "number" }, - { "const": "*" } - ], - "description": "Single event ID or '*' wildcard. Mutually exclusive with eventIds." - }, - "eventIds": { - "type": "array", - "items": { "type": "number" }, - "minItems": 1, - "description": "Multiple event IDs. Mutually exclusive with eventId." + "description": "Alias names to match." }, "supplements": { "$ref": "#/$defs/supplements" }, "handler": { "$ref": "#/$defs/functionRef" } - }, - "oneOf": [ - { "required": ["aliases"] }, - { "required": ["clusterId"] } - ] + } }, "commandHandler": { "type": "object", - "required": ["handler"], + "required": ["handler", "aliases"], "additionalProperties": false, "properties": { "aliases": { "type": "array", "items": { "type": "string" }, "minItems": 1, - "description": "Alias names to match. Mutually exclusive with clusterId." - }, - "clusterId": { - "type": "number", - "description": "Cluster to match. Mutually exclusive with aliases." - }, - "commandId": { - "oneOf": [ - { "type": "number" }, - { "const": "*" } - ], - "description": "Single command ID or '*' wildcard. Mutually exclusive with commandIds." - }, - "commandIds": { - "type": "array", - "items": { "type": "number" }, - "minItems": 1, - "description": "Multiple command IDs. Mutually exclusive with commandId." + "description": "Alias names to match." }, "supplements": { "$ref": "#/$defs/supplements" }, "handler": { "$ref": "#/$defs/functionRef" } - }, - "oneOf": [ - { "required": ["aliases"] }, - { "required": ["clusterId"] } - ] + } } } } diff --git a/core/deviceDrivers/matter/sbmd/scriptCommon/sbmd-script.d.ts b/core/deviceDrivers/matter/sbmd/scriptCommon/sbmd-script.d.ts index da4dcb35..6beeb749 100644 --- a/core/deviceDrivers/matter/sbmd/scriptCommon/sbmd-script.d.ts +++ b/core/deviceDrivers/matter/sbmd/scriptCommon/sbmd-script.d.ts @@ -20,8 +20,8 @@ * Top-level registration object passed to `SbmdDriver()`. */ interface SbmdRegistration { - /** Schema version. Must be "4.0". */ - schemaVersion: "4.0"; + /** Schema version. Must be "5.0". */ + schemaVersion: "5.0"; /** Driver-specific version string or number. */ driverVersion: string | number; @@ -151,9 +151,10 @@ interface SbmdResource { /** * Access modes: "read", "write", "dynamic" (default on), "static" (opts out of dynamic), - * "emitEvents" (default on), "noEvents", "lazySaveNext", "sensitive". + * "emitEvents" (default on), "noEvents", "lazySaveNext", "sensitive", "volatile" (disables + * value caching). */ - modes?: Array<"read" | "write" | "dynamic" | "static" | "emitEvents" | "noEvents" | "lazySaveNext" | "sensitive">; + modes?: Array<"read" | "write" | "dynamic" | "static" | "emitEvents" | "noEvents" | "lazySaveNext" | "sensitive" | "volatile">; /** Alias names or cluster IDs that must be present before creating this resource. */ prerequisites?: Array; @@ -179,32 +180,22 @@ interface SbmdResource { // ============================================================================= interface SbmdAttributeHandler { - /** Alias names to match. Mutually exclusive with clusterId. */ - aliases?: string[]; - /** Cluster to match. Mutually exclusive with aliases. */ - clusterId?: number; - /** Single attribute ID or "*" wildcard. */ - attributeId?: number | "*"; - /** Multiple attribute IDs. */ - attributeIds?: number[]; + /** Alias names to match. */ + aliases: string[]; supplements?: SbmdSupplements; handler: SbmdHandlerFunction; } interface SbmdEventHandler { - aliases?: string[]; - clusterId?: number; - eventId?: number | "*"; - eventIds?: number[]; + /** Alias names to match. */ + aliases: string[]; supplements?: SbmdSupplements; handler: SbmdHandlerFunction; } interface SbmdCommandHandler { - aliases?: string[]; - clusterId?: number; - commandId?: number | "*"; - commandIds?: number[]; + /** Alias names to match. */ + aliases: string[]; supplements?: SbmdSupplements; handler: SbmdHandlerFunction; } diff --git a/core/deviceDrivers/matter/sbmd/specs/air-quality-sensor.sbmd.js b/core/deviceDrivers/matter/sbmd/specs/air-quality-sensor.sbmd.js index dd76e39b..8ae04090 100644 --- a/core/deviceDrivers/matter/sbmd/specs/air-quality-sensor.sbmd.js +++ b/core/deviceDrivers/matter/sbmd/specs/air-quality-sensor.sbmd.js @@ -29,7 +29,7 @@ // SbmdDriver({ - schemaVersion: '4.0', + schemaVersion: '5.0', driverVersion: 1, name: 'Air Quality Sensor', diff --git a/core/deviceDrivers/matter/sbmd/specs/camera.sbmd.js b/core/deviceDrivers/matter/sbmd/specs/camera.sbmd.js new file mode 100644 index 00000000..f320273f --- /dev/null +++ b/core/deviceDrivers/matter/sbmd/specs/camera.sbmd.js @@ -0,0 +1,1065 @@ +// ------------------------------ tabstop = 4 ---------------------------------- +// +// If not stated otherwise in this file or this component's LICENSE file the +// following copyright and licenses apply: +// +// Copyright 2026 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// +// ------------------------------ tabstop = 4 ---------------------------------- + +// ============================================================================= +// Camera SBMD Driver — Abstract Session Interface +// ============================================================================= +// +// This driver implements the abstract camera endpoint (profile: "camera") that +// provides a protocol-agnostic session lifecycle for camera streaming. It is +// the client-facing layer in a two-layer architecture: +// +// ep/camera (this driver) — abstract session lifecycle +// ep/webrtc, ep/direct, ... — protocol-specific signaling (separate drivers) +// +// Clients interact exclusively with the camera endpoint to manage sessions. +// Protocol-specific details are handled by dedicated endpoints. The abstract +// endpoint tells the client which protocol is active and where to go next via +// the return value of the stream execute (not a pushed status event). +// +// Session Lifecycle +// ----------------- +// The session interface exposes four execute resources: +// +// createSession [execute] — Allocates a new session and returns a sessionId. +// stream [execute] — Starts streaming for a given sessionId. Returns a +// JSON string encoding { protocol, entryPoint } that +// identifies the active protocol and the resource URI on +// the protocol-specific endpoint the client uses next. +// (SBMD execute success values are strings, so structured +// results are conveyed as a JSON string.) +// takePicture [execute] — Captures a snapshot for a given sessionId (not yet +// implemented). +// destroySession [execute] — Tears down a session and releases resources. +// +// The abstract endpoint carries no protocol-specific signaling and no status +// resource. In-session state, errors, and teardown are signaled on the +// protocol-specific endpoint (see ep/webrtc r/webrtcError below). +// +// Client Flow +// ----------- +// 1. Execute createSession → receive sessionId +// 2. Execute stream with sessionId → receive JSON string { protocol, entryPoint } +// 3. Follow entryPoint to the protocol-specific endpoint +// (e.g., //ep/webrtc/r/localSdp). Any negotiation details live on that +// endpoint, not in the abstract stream result; for WebRTC the client reads +// r/negotiationRole to learn the CAMERA's role — 'offerer' when the camera +// creates the SDP offer, 'answerer' when it answers — then adopts the opposite +// role. The backing Matter flow (SolicitOffer vs ProvideOffer) is hidden from +// the client. +// 4. Complete protocol-specific exchange (SDP, ICE, media URL, etc.) and +// subscribe to the protocol endpoint's event resources +// 5. Execute destroySession with sessionId when finished +// +// Session State +// ------------- +// Sessions are stored in transient data as a JSON object keyed by sessionId. +// Each session tracks its current state and protocol. Transient data entries +// expire after one hour (ONE_HOUR_SECS) as a leak-prevention backstop, but +// clients are expected to call destroySession for proper cleanup. +// +// Protocol Abstraction +// -------------------- +// The camera endpoint does not know or care which streaming protocol is in use. +// The protocol identifier (e.g., "webrtc", "direct") is stored per session and +// returned by the stream execute so clients can identify the technology. +// Currently, this driver hardcodes PROTO_WEBRTC for Matter cameras. Cameras using +// other technologies would have their own drivers that create the same abstract +// camera endpoint with a different protocol identifier. +// +// See also: openspec/changes/archive/2026-05-04-camera-architecture-redesign/ +// ============================================================================= + +SbmdDriver({ + schemaVersion: '5.0', + driverVersion: 1, + name: 'Camera', + + constants: { + // Endpoint + EP_CAMERA: 'camera', + EP_WEBRTC: 'webrtc', + + // Clusters + CL_WEBRTC_TRANSPORT_PROVIDER: 0x0553, + CL_WEBRTC_TRANSPORT_REQUESTOR: 0x0554, + CL_CAMERA_AV_STREAM_MGMT: 0x0551, + + // CameraAVStreamManagement feature bits + FEAT_WATERMARK: 0x40, + FEAT_OSD: 0x80, + + // CameraAVStreamManagement commands + CMD_VIDEO_STREAM_ALLOCATE: 0x03, + CMD_VIDEO_STREAM_ALLOCATE_RESP: 0x04, + + // CameraAVStreamManagement StreamUsageEnum value requested when allocating a stream and + // soliciting/providing an offer. 3 = LiveView. + STREAM_USAGE_LIVE_VIEW: 3, + + // WebRTCTransportProvider commands (outgoing — sent to camera) + CMD_SOLICIT_OFFER: 0x00, + CMD_SOLICIT_OFFER_RESP: 0x01, + CMD_PROVIDE_OFFER: 0x02, + CMD_PROVIDE_OFFER_RESP: 0x03, + CMD_PROVIDE_ANSWER: 0x04, + CMD_PROVIDE_ICE: 0x05, + CMD_END_SESSION: 0x06, + + // WebRTCEndReasonEnum used in the EndSession command's reason field. 2 = UserHangup. + WEBRTC_END_REASON_USER_HANGUP: 2, + + // Global attribute: AcceptedCommandList (0xFFF9) — used to pick the signaling flow. + ATTR_ACCEPTED_COMMAND_LIST: 0xfff9, + + // WebRTCTransportRequestor commands (incoming — from camera) + CMD_OFFER: 0x00, + CMD_ANSWER: 0x01, + CMD_ICE_CANDIDATES: 0x02, + CMD_END: 0x03, + + // The Matter endpoint on THIS node (Barton) that hosts the WebRTCTransportRequestor server + // cluster. It is sent to the camera as originatingEndpointID in SolicitOffer/ProvideOffer so + // the camera knows where to deliver its Offer/Answer/ICE/End commands. Real cameras reject + // the root endpoint (0), so the requestor is hosted on endpoint 1 (see + // third_party/matter/barton-library/barton-common/barton-library.zap and .matter). + // TODO: detect this at runtime from the local node's hosted-cluster map (e.g. walk the + // root Descriptor PartsList and each endpoint's ServerList for CL_WEBRTC_TRANSPORT_REQUESTOR) + // instead of hardcoding, so the driver stays correct if the requestor endpoint changes. + WEBRTC_REQUESTOR_ENDPOINT_ID: 1, + + // Negotiation role reported to the client on ep/webrtc r/negotiationRole. Because that + // endpoint is the camera's data model, the value is the CAMERA's role; the client adopts + // the opposite. The Matter command mapping (SolicitOffer vs ProvideOffer + ProvideAnswer) + // stays entirely inside this driver. + // 'offerer' — camera creates the SDP offer (SolicitOffer flow; the client answers) + // 'answerer' — camera answers the client's offer (ProvideOffer flow; the client offers) + ROLE_OFFERER: 'offerer', + ROLE_ANSWERER: 'answerer', + + // webrtcError event values (ep/webrtc r/webrtcError) + WEBRTC_ERROR_ENDED: 'ended', + WEBRTC_ERROR_FAILED: 'failed', + + // Transient data keys + TD_SESSIONS: 'sessions', + TD_NEXT_SESSION_ID: 'nextSessionId', + + // Protocol identifiers + PROTO_WEBRTC: 'webrtc', + + // Timing + ONE_HOUR_SECS: 3600 + }, + + barton: {deviceClass: 'camera', deviceClassVersion: 2}, + + matter: { + deviceTypes: [0x0142], + revision: 1, + featureClusters: [CL_WEBRTC_TRANSPORT_PROVIDER, CL_CAMERA_AV_STREAM_MGMT] + }, + + reporting: {minSecs: 1, maxSecs: ONE_HOUR_SECS}, + + aliases: { + incomingOffer: {clusterId: CL_WEBRTC_TRANSPORT_REQUESTOR, commandId: CMD_OFFER}, + incomingAnswer: {clusterId: CL_WEBRTC_TRANSPORT_REQUESTOR, commandId: CMD_ANSWER}, + incomingIceCandidates: { + clusterId: CL_WEBRTC_TRANSPORT_REQUESTOR, + commandId: CMD_ICE_CANDIDATES + }, + incomingEndSession: {clusterId: CL_WEBRTC_TRANSPORT_REQUESTOR, commandId: CMD_END}, + providerAcceptedCommands: { + clusterId: CL_WEBRTC_TRANSPORT_PROVIDER, + attributeId: ATTR_ACCEPTED_COMMAND_LIST + } + }, + + endpoints: { + camera: { + profile: 'camera', + profileVersion: 1, + resources: { + createSession: { + type: 'function', + + execute: { + supplements: {transientData: [TD_SESSIONS, TD_NEXT_SESSION_ID]}, + handler: executeCreateSession + } + }, + + stream: { + type: 'function', + + execute: { + supplements: {transientData: [TD_SESSIONS]}, + handler: executeStream + } + }, + + takePicture: { + type: 'function', + + execute: { + supplements: {transientData: [TD_SESSIONS]}, + handler: executeTakePicture + } + }, + + destroySession: { + type: 'function', + + execute: { + supplements: {transientData: [TD_SESSIONS]}, + handler: executeDestroySession + } + } + } + }, + webrtc: { + profile: 'webrtc', + profileVersion: 1, + resources: { + localSdp: { + type: 'function', + + execute: { + supplements: { + transientData: [TD_SESSIONS], + attributes: ['providerAcceptedCommands'] + }, + handler: executeLocalSdp + } + }, + + negotiationRole: { + type: 'string', + modes: ['read'], + + read: { + supplements: {attributes: ['providerAcceptedCommands']}, + handler: readNegotiationRole + } + }, + + remoteSdp: {type: 'string', modes: []}, + + localIceCandidates: { + type: 'function', + + execute: { + supplements: {transientData: [TD_SESSIONS]}, + handler: executeLocalIceCandidates + } + }, + + remoteIceCandidates: {type: 'string', modes: []}, + + webrtcError: { + type: 'string', + // 'volatile' registers this as non-cached so every updateResource emits an + // event even when the value is unchanged (e.g. two 'failed' across sessions). + modes: ['volatile'] + } + } + } + }, + + attributeHandlers: {}, + + commandHandlers: { + handleIncomingOffer: { + aliases: ['incomingOffer'], + supplements: {transientData: [TD_SESSIONS]}, + handler: handleIncomingOffer + }, + handleIncomingAnswer: { + aliases: ['incomingAnswer'], + supplements: {transientData: [TD_SESSIONS]}, + handler: handleIncomingAnswer + }, + handleIncomingIceCandidates: { + aliases: ['incomingIceCandidates'], + supplements: {transientData: [TD_SESSIONS]}, + handler: handleIncomingIceCandidates + }, + handleIncomingEndSession: { + aliases: ['incomingEndSession'], + supplements: {transientData: [TD_SESSIONS]}, + handler: handleIncomingEndSession + } + } +}); + +// ============================================================================= +// Handler Functions +// ============================================================================= + +function parseSessions(sessionsJson) { + if (!sessionsJson) { + return {}; + } + + try { + var parsed = JSON.parse(sessionsJson); + + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; + } + + return parsed; + } catch (e) { + return null; + } +} + +function findStreamingSessionId(sessions) { + // Return the id of the (single) session in the 'streaming' state, or null if there is none. + for (var id in sessions) { + if (sessions[id].state === 'streaming') { + return id; + } + } + + return null; +} + +function executeCreateSession(args) { + var sessionsJson = args.supplements.transientData[TD_SESSIONS]; + var sessions = parseSessions(sessionsJson); + + if (sessions === null) { + return Sbmd.result() + .storage.setTransientData(TD_SESSIONS, '', 0) + .error('Corrupt session data. Sessions data reset.'); + } + + var nextIdStr = args.supplements.transientData[TD_NEXT_SESSION_ID]; + var nextId = nextIdStr ? parseInt(nextIdStr, 10) : NaN; + + if (isNaN(nextId) || nextId < 1) { + nextId = 1; + + for (var id in sessions) { + var n = parseInt(id, 10); + + if (!isNaN(n) && n >= nextId) { + nextId = n + 1; + } + } + } + var sessionId = nextId.toString(); + + sessions[sessionId] = {state: 'created', protocol: PROTO_WEBRTC}; + + return Sbmd.result() + .storage.setTransientData(TD_SESSIONS, JSON.stringify(sessions), ONE_HOUR_SECS) + .storage.setTransientData(TD_NEXT_SESSION_ID, (nextId + 1).toString(), ONE_HOUR_SECS) + .success(sessionId); +} + +function cameraIsOfferer(args) { + // Determine, from the camera's advertised capabilities, whether the CAMERA generates the SDP + // offer (the SolicitOffer flow) or answers the client's offer (the ProvideOffer flow). Prefer + // SolicitOffer (real cameras favor it); fall back to ProvideOffer only when SolicitOffer is not + // accepted; default to SolicitOffer when the AcceptedCommandList is unavailable. + var raw = + args.supplements && args.supplements.attributes + ? args.supplements.attributes.providerAcceptedCommands + : null; + + // Attribute supplements are delivered as base64-encoded TLV (see MakeAttrFetcher). The + // AcceptedCommandList is a TLV array, which decodes to a plain array of command IDs. Decode + // defensively: any failure leaves 'accepted' null and falls through to the SolicitOffer default. + var accepted = null; + + if (raw) { + try { + accepted = Sbmd.Tlv.decode(raw); + } catch (e) { + accepted = null; + } + } + + if ( + Array.isArray(accepted) && + accepted.indexOf(CMD_SOLICIT_OFFER) === -1 && + accepted.indexOf(CMD_PROVIDE_OFFER) !== -1 + ) { + // The camera accepts only ProvideOffer: it answers and the client offers. + return false; + } + + // SolicitOffer accepted, or the list is unavailable: the camera generates the offer. + return true; +} + +function readNegotiationRole(args) { + // Report the CAMERA's WebRTC negotiation role, because ep/webrtc is the camera's data model. The + // camera is the 'offerer' when it generates the SDP offer (SolicitOffer flow) and the 'answerer' + // when it answers the client's offer (ProvideOffer flow); the client adopts the opposite role. + // The role lives here on the webrtc endpoint rather than in the abstract stream result. + return Sbmd.result().success(cameraIsOfferer(args) ? ROLE_OFFERER : ROLE_ANSWERER); +} + +function executeStream(args) { + var input = args.resource.input; + + if (!input) { + return Sbmd.result().error('sessionId required'); + } + + var sessionId = input.toString(); + + var sessionsJson = args.supplements.transientData[TD_SESSIONS]; + var sessions = parseSessions(sessionsJson); + + if (sessions === null) { + return Sbmd.result() + .storage.setTransientData(TD_SESSIONS, '', 0) + .error('Corrupt session data. Sessions data reset.'); + } + + if (!sessions[sessionId]) { + return Sbmd.result().error('unknown sessionId: ' + sessionId); + } + + sessions[sessionId].state = 'streaming'; + + var deviceId = args.deviceUuid; + var entryPoint = '/' + deviceId + '/ep/webrtc/r/localSdp'; + + // The abstract stream result names the active protocol and the entry-point resource where + // signaling begins. The entry point necessarily references the protocol-specific endpoint + // (here ep/webrtc r/localSdp), but the negotiation details — the WebRTC role (read from + // ep/webrtc r/negotiationRole) and the ProvideOffer/SolicitOffer/ProvideAnswer commands — live + // on that endpoint rather than in this abstract result. + var streamInfo = { + protocol: sessions[sessionId].protocol, + entryPoint: entryPoint + }; + + var result = Sbmd.result().storage.setTransientData( + TD_SESSIONS, + JSON.stringify(sessions), + ONE_HOUR_SECS + ); + + return result.success(JSON.stringify(streamInfo)); +} + +function executeTakePicture(args) { + // TODO: implement snapshot capture via CameraAvStreamManagement. The session's transient data + // is supplied via TD_SESSIONS (see the resource registration) once implemented. + return Sbmd.result().error('takePicture not yet implemented'); +} + +function executeDestroySession(args) { + var input = args.resource.input; + + if (!input) { + return Sbmd.result().error('sessionId required'); + } + + var sessionId = input.toString(); + + var sessionsJson = args.supplements.transientData[TD_SESSIONS]; + var sessions = parseSessions(sessionsJson); + + if (sessions === null) { + return Sbmd.result() + .storage.setTransientData(TD_SESSIONS, '', 0) + .error('Corrupt session data. Sessions data reset.'); + } + + if (!sessions[sessionId]) { + return Sbmd.result().error('unknown sessionId: ' + sessionId); + } + + var wasStreaming = + sessions[sessionId].state === 'streaming' && + sessions[sessionId].webRTCSessionID !== undefined; + var webRTCSessionID = sessions[sessionId].webRTCSessionID; + + delete sessions[sessionId]; + + var result = Sbmd.result().storage.setTransientData( + TD_SESSIONS, + JSON.stringify(sessions), + ONE_HOUR_SECS + ); + + if (wasStreaming) { + var endSchema = { + webRTCSessionID: {tag: 0, type: 'uint16'}, + reason: {tag: 1, type: 'enum8'} + }; + var endPayload = Sbmd.Tlv.encodeStruct( + {webRTCSessionID: webRTCSessionID, reason: WEBRTC_END_REASON_USER_HANGUP}, + endSchema + ); + + return result.device.sendCommand(CL_WEBRTC_TRANSPORT_PROVIDER, CMD_END_SESSION, endPayload); + } + + return result.success(); +} + +// ============================================================================= +// WebRTC Endpoint Execute Handlers +// ============================================================================= + +function executeLocalSdp(args) { + var sessionsJson = args.supplements.transientData[TD_SESSIONS]; + var sessions = parseSessions(sessionsJson); + + if (sessions === null) { + return Sbmd.result().error('No active sessions'); + } + + // Find the active streaming session + var sessionId = findStreamingSessionId(sessions); + + if (!sessionId) { + return Sbmd.result().error('No active streaming session'); + } + + var input = args.resource.input; + var sdp = input ? input.toString() : ''; + + if (cameraIsOfferer(args)) { + // SolicitOffer flow (the camera generates the offer). Route by how far negotiation has + // progressed rather than by the SDP payload: until the camera has offered there is no + // webRTCSessionID, so this call opens the flow (allocate a stream, then SolicitOffer in + // handleAllocateForSolicit). Once the camera's offer has arrived (webRTCSessionID recorded, + // remoteSdp emitted) the next call carries our answer, which we relay via ProvideAnswer. + var haveCameraSession = + sessions[sessionId].webRTCSessionID !== undefined && + sessions[sessionId].webRTCSessionID !== null; + + if (!haveCameraSession) { + return allocateThenSolicitOffer(args, sessions, sessionId); + } + + return sendProvideAnswer(sessions, sessionId, sdp); + } + + // ProvideOffer flow (the camera answers): this local SDP is our offer — allocate a stream, then + // send it directly. + if (sdp === '') { + return Sbmd.result().error('SDP string required'); + } + + return allocateThenProvideOffer(args, sessions, sessionId, sdp); +} + +function sendProvideAnswer(sessions, sessionId, sdp) { + if (!sdp) { + return Sbmd.result().error('SDP string required'); + } + + var webRTCSessionID = sessions[sessionId].webRTCSessionID; + + if (webRTCSessionID === undefined || webRTCSessionID === null) { + return Sbmd.result().error('No webRTCSessionID (camera offer not yet received)'); + } + + var schema = { + webRTCSessionID: {tag: 0, type: 'uint16'}, + sdp: {tag: 1, type: 'string'} + }; + + var payload = Sbmd.Tlv.encodeStruct({webRTCSessionID: webRTCSessionID, sdp: sdp}, schema); + + return Sbmd.result().device.sendCommand( + CL_WEBRTC_TRANSPORT_PROVIDER, + CMD_PROVIDE_ANSWER, + payload + ); +} + +function allocateThenSolicitOffer(args, sessions, sessionId) { + // SolicitOffer flow, step 1: allocate a video stream (the camera requires one before it will + // honor SolicitOffer). handleAllocateForSolicit then sends SolicitOffer for the allocated + // stream. Both legs use requestCommand so their promises stay alive across the deferred chain. + var featureMap = args.clusterFeatureMaps[CL_CAMERA_AV_STREAM_MGMT] || 0; + var allocPayload = buildVideoStreamAllocatePayload(featureMap); + + return Sbmd.result() + .storage.setTransientData(TD_SESSIONS, JSON.stringify(sessions), ONE_HOUR_SECS) + .device.requestCommand(CL_CAMERA_AV_STREAM_MGMT, CMD_VIDEO_STREAM_ALLOCATE, allocPayload, { + responseCommandId: CMD_VIDEO_STREAM_ALLOCATE_RESP, + onResponse: handleAllocateForSolicit, + onError: handleVideoStreamAllocateError, + context: {sessionId: sessionId, sessions: sessions}, + timeoutMs: 5000 + }); +} + +function buildVideoStreamAllocatePayload(featureMap) { + // Build a VideoStreamAllocate payload. The camera requires an allocated stream before either + // ProvideOffer or SolicitOffer will succeed, so both negotiation flows share this builder. + var allocSchema = { + streamUsage: {tag: 0, type: 'enum8'}, + videoCodec: {tag: 1, type: 'enum8'}, + minFrameRate: {tag: 2, type: 'uint16'}, + maxFrameRate: {tag: 3, type: 'uint16'}, + minResolution: {tag: 4, type: 'struct'}, + maxResolution: {tag: 5, type: 'struct'}, + minBitRate: {tag: 6, type: 'uint32'}, + maxBitRate: {tag: 7, type: 'uint32'}, + keyFrameInterval: {tag: 8, type: 'uint16'} + }; + + // The requested parameters must fall within a stream configuration the + // camera supports, otherwise VideoStreamAllocate is rejected with + // DYNAMIC_CONSTRAINT_ERROR. These values target a widely-supported H.264 + // configuration: VGA-to-720p resolution, a single steady frame rate, a + // conservative bit-rate ceiling, and the spec-recommended 4s key-frame + // interval (expressed in milliseconds). + var allocData = { + streamUsage: STREAM_USAGE_LIVE_VIEW, + videoCodec: 0, + minFrameRate: 30, + maxFrameRate: 30, + minResolution: {0: 640, 1: 480}, + maxResolution: {0: 1280, 1: 720}, + minBitRate: 10000, + maxBitRate: 2000000, + keyFrameInterval: 4000 + }; + + // If the camera supports Watermark or OSD, those fields are mandatory. + if (featureMap & FEAT_WATERMARK) { + allocSchema.watermarkEnabled = {tag: 9, type: 'bool'}; + allocData.watermarkEnabled = false; + } + + if (featureMap & FEAT_OSD) { + allocSchema.OSDEnabled = {tag: 10, type: 'bool'}; + allocData.OSDEnabled = false; + } + + return Sbmd.Tlv.encodeStruct(allocData, allocSchema); +} + +function allocateThenProvideOffer(args, sessions, sessionId, sdp) { + // Step 1: Allocate a video stream on the camera. + // The camera requires an allocated stream before ProvideOffer will succeed. + var featureMap = args.clusterFeatureMaps[CL_CAMERA_AV_STREAM_MGMT] || 0; + var allocPayload = buildVideoStreamAllocatePayload(featureMap); + + return Sbmd.result() + .storage.setTransientData(TD_SESSIONS, JSON.stringify(sessions), ONE_HOUR_SECS) + .device.requestCommand(CL_CAMERA_AV_STREAM_MGMT, CMD_VIDEO_STREAM_ALLOCATE, allocPayload, { + responseCommandId: CMD_VIDEO_STREAM_ALLOCATE_RESP, + onResponse: handleVideoStreamAllocateResponse, + onError: handleVideoStreamAllocateError, + context: {sdp: sdp, sessionId: sessionId, sessions: sessions}, + timeoutMs: 5000 + }); +} + +function handleVideoStreamAllocateResponse(args) { + var ctx = args.handlerContext; + var responseData = args.response.data; + + // VideoStreamAllocateResponse: tag 0 = VideoStreamID (uint16) + var decoded = Sbmd.Tlv.decode(responseData); + var videoStreamID = decoded[0]; + + // Now send ProvideOffer with the allocated video stream ID + var schema = { + webRTCSessionID: {tag: 0, type: 'uint16'}, + sdp: {tag: 1, type: 'string'}, + streamUsage: {tag: 2, type: 'enum8'}, + originatingEndpointID: {tag: 3, type: 'uint16'}, + videoStreamID: {tag: 4, type: 'uint16'} + }; + + var tlvBase64 = Sbmd.Tlv.encodeStruct( + { + webRTCSessionID: null, + sdp: ctx.sdp, + streamUsage: STREAM_USAGE_LIVE_VIEW, + originatingEndpointID: WEBRTC_REQUESTOR_ENDPOINT_ID, + videoStreamID: videoStreamID + }, + schema + ); + + return Sbmd.result().device.requestCommand( + CL_WEBRTC_TRANSPORT_PROVIDER, + CMD_PROVIDE_OFFER, + tlvBase64, + { + responseCommandId: CMD_PROVIDE_OFFER_RESP, + onResponse: handleProvideOfferResponse, + onError: handleProvideOfferError, + context: {sessionId: ctx.sessionId, sessions: ctx.sessions}, + timeoutMs: 10000 + } + ); +} + +function handleVideoStreamAllocateError(args) { + // Async failure after the local SDP was posted: deliver it to the client via the + // webrtcError event (a bare .error() here has no client channel). Covers timeouts too, + // since a requestCommand deadline is reported through onError with type 'timeout'. + var metadata = { + sessionId: args.handlerContext ? args.handlerContext.sessionId : 'unknown', + reason: args.error ? args.error.type : 'error', + detail: 'VideoStreamAllocate failed: ' + (args.error ? args.error.message : 'unknown') + }; + + return Sbmd.result() + .dataModel.updateResource(EP_WEBRTC, 'webrtcError', WEBRTC_ERROR_FAILED, metadata) + .success(); +} + +function handleAllocateForSolicit(args) { + var ctx = args.handlerContext; + var responseData = args.response.data; + + // VideoStreamAllocateResponse: tag 0 = VideoStreamID (uint16) + var decoded = Sbmd.Tlv.decode(responseData); + var videoStreamID = decoded[0]; + + // Record the allocated stream on the session for later reference. + if (ctx.sessions[ctx.sessionId]) { + ctx.sessions[ctx.sessionId].videoStreamID = videoStreamID; + } + + // SolicitOffer: ask the camera to generate the offer for the allocated stream. The camera + // replies with SolicitOfferResponse (carrying the webRTCSessionID) and then sends its Offer + // command, which arrives asynchronously as a remoteSdp event. Chaining another requestCommand + // here (never sendCommand) keeps the deferred operation's promise alive until the response. + var solicitSchema = { + streamUsage: {tag: 0, type: 'enum8'}, + originatingEndpointID: {tag: 1, type: 'uint16'}, + videoStreamID: {tag: 2, type: 'uint16'} + }; + var solicitPayload = Sbmd.Tlv.encodeStruct( + { + streamUsage: STREAM_USAGE_LIVE_VIEW, + originatingEndpointID: WEBRTC_REQUESTOR_ENDPOINT_ID, + videoStreamID: videoStreamID + }, + solicitSchema + ); + + return Sbmd.result() + .storage.setTransientData(TD_SESSIONS, JSON.stringify(ctx.sessions), ONE_HOUR_SECS) + .device.requestCommand(CL_WEBRTC_TRANSPORT_PROVIDER, CMD_SOLICIT_OFFER, solicitPayload, { + responseCommandId: CMD_SOLICIT_OFFER_RESP, + onResponse: handleSolicitOfferResponse, + onError: handleSolicitOfferError, + context: {sessionId: ctx.sessionId, sessions: ctx.sessions}, + timeoutMs: 10000 + }); +} + +function handleSolicitOfferResponse(args) { + var ctx = args.handlerContext; + var responseData = args.response.data; + + // SolicitOfferResponse: tag 0 = WebRTCSessionID (uint16), tag 1 = DeferredOffer (bool). + // Record the session ID now; the camera's subsequent Offer command also carries it, but + // capturing it here means ProvideAnswer works even if the two arrive out of order. + var decoded = Sbmd.Tlv.decode(responseData); + var webRTCSessionID = decoded[0]; + + if (ctx.sessions[ctx.sessionId]) { + ctx.sessions[ctx.sessionId].webRTCSessionID = webRTCSessionID; + } + + return Sbmd.result() + .storage.setTransientData(TD_SESSIONS, JSON.stringify(ctx.sessions), ONE_HOUR_SECS) + .success(); +} + +function handleSolicitOfferError(args) { + // Async failure in the allocate -> SolicitOffer chain: deliver it to the client via the + // webrtcError event (covers timeouts too, reported as onError type 'timeout'). + var metadata = { + sessionId: args.handlerContext ? args.handlerContext.sessionId : 'unknown', + reason: args.error ? args.error.type : 'error', + detail: 'SolicitOffer failed: ' + (args.error ? args.error.message : 'unknown') + }; + + return Sbmd.result() + .dataModel.updateResource(EP_WEBRTC, 'webrtcError', WEBRTC_ERROR_FAILED, metadata) + .success(); +} + +function handleProvideOfferResponse(args) { + var ctx = args.handlerContext; + var responseData = args.response.data; + + // ProvideOfferResponse: tag 0 = WebRTCSessionID (uint16) + var decoded = Sbmd.Tlv.decode(responseData); + var webRTCSessionID = decoded[0]; + + // Store the allocated WebRTC session ID for later commands (ICE, EndSession). + ctx.sessions[ctx.sessionId].webRTCSessionID = webRTCSessionID; + + return Sbmd.result() + .storage.setTransientData(TD_SESSIONS, JSON.stringify(ctx.sessions), ONE_HOUR_SECS) + .success(); +} + +function handleProvideOfferError(args) { + // Async failure after the local SDP was posted: deliver it to the client via the + // webrtcError event. Covers timeouts too (onError type 'timeout'). + var metadata = { + sessionId: args.handlerContext ? args.handlerContext.sessionId : 'unknown', + reason: args.error ? args.error.type : 'error', + detail: 'ProvideOffer failed: ' + (args.error ? args.error.message : 'unknown') + }; + + return Sbmd.result() + .dataModel.updateResource(EP_WEBRTC, 'webrtcError', WEBRTC_ERROR_FAILED, metadata) + .success(); +} + +function executeLocalIceCandidates(args) { + var input = args.resource.input; + + if (!input) { + return Sbmd.result().error('ICE candidates JSON array required'); + } + + var candidates; + + try { + candidates = JSON.parse(input.toString()); + } catch (e) { + return Sbmd.result().error('Invalid JSON: ' + e.message); + } + + if (!Array.isArray(candidates)) { + return Sbmd.result().error('Input must be a JSON array of ICE candidate strings'); + } + + var sessionsJson = args.supplements.transientData[TD_SESSIONS]; + var sessions = parseSessions(sessionsJson); + + if (sessions === null) { + return Sbmd.result().error('No active sessions'); + } + + // Find the active streaming session with a webRTCSessionID. + var sessionId = findStreamingSessionId(sessions); + var webRTCSessionID = + sessionId !== null && sessions[sessionId].webRTCSessionID !== undefined + ? sessions[sessionId].webRTCSessionID + : null; + + if (webRTCSessionID === null) { + return Sbmd.result().error('No active WebRTC session'); + } + + // Build ICECandidateStruct array: each element has {candidate, SDPMid, SDPMLineIndex} + var iceCandidateStructs = []; + + for (var i = 0; i < candidates.length; i++) { + iceCandidateStructs.push({0: candidates[i], 1: null, 2: null}); + } + + var schema = { + webRTCSessionID: {tag: 0, type: 'uint16'}, + ICECandidates: {tag: 1, type: 'array'} + }; + + var tlvBase64 = Sbmd.Tlv.encodeStruct( + {webRTCSessionID: webRTCSessionID, ICECandidates: iceCandidateStructs}, + schema + ); + + return Sbmd.result().device.sendCommand( + CL_WEBRTC_TRANSPORT_PROVIDER, + CMD_PROVIDE_ICE, + tlvBase64 + ); +} + +// ============================================================================= +// WebRTC Command Handlers (Incoming from Camera) +// ============================================================================= + +function handleIncomingOffer(args) { + var tlvBase64 = args.command.tlvBase64; + + if (!tlvBase64) { + return Sbmd.result().error('No TLV payload in Offer command'); + } + + var decoded = Sbmd.Tlv.decode(tlvBase64); + + // Offer fields: webRTCSessionID (tag 0), sdp (tag 1) + var webRTCSessionID = decoded[0]; + var sdp = decoded[1]; + + if (sdp === undefined || sdp === null) { + return Sbmd.result().error('Offer command missing SDP'); + } + + // Store the Matter webRTCSessionID for correlation + var sessionsJson = args.supplements.transientData[TD_SESSIONS]; + var sessions = parseSessions(sessionsJson); + + if (sessions === null) { + // Corrupt session data: reset it, but still surface the remote SDP to the client. + return Sbmd.result() + .storage.setTransientData(TD_SESSIONS, '', 0) + .dataModel.updateResource(EP_WEBRTC, 'remoteSdp', sdp.toString()) + .success(); + } + + // Find the active streaming session and associate the Matter session ID. + var sessionId = findStreamingSessionId(sessions); + + if (sessionId) { + sessions[sessionId].webRTCSessionID = webRTCSessionID; + } + + return Sbmd.result() + .storage.setTransientData(TD_SESSIONS, JSON.stringify(sessions), ONE_HOUR_SECS) + .dataModel.updateResource(EP_WEBRTC, 'remoteSdp', sdp.toString()) + .success(); +} + +function handleIncomingAnswer(args) { + var tlvBase64 = args.command.tlvBase64; + + if (!tlvBase64) { + return Sbmd.result().error('No TLV payload in Answer command'); + } + + var decoded = Sbmd.Tlv.decode(tlvBase64); + + // Answer fields: webRTCSessionID (tag 0), sdp (tag 1) + var webRTCSessionID = decoded[0]; + var sdp = decoded[1]; + + if (sdp === undefined || sdp === null) { + return Sbmd.result().error('Answer command missing SDP'); + } + + // Store the camera-allocated webRTCSessionID for use by subsequent commands + // (ProvideICECandidates, EndSession) + var sessionsJson = args.supplements.transientData[TD_SESSIONS]; + var sessions = parseSessions(sessionsJson); + + if (sessions && webRTCSessionID !== undefined && webRTCSessionID !== null) { + var answerSessionId = findStreamingSessionId(sessions); + + if (answerSessionId) { + sessions[answerSessionId].webRTCSessionID = webRTCSessionID; + } + } + + return Sbmd.result() + .storage.setTransientData(TD_SESSIONS, JSON.stringify(sessions || {}), ONE_HOUR_SECS) + .dataModel.updateResource(EP_WEBRTC, 'remoteSdp', sdp.toString()) + .success(); +} + +function handleIncomingIceCandidates(args) { + var tlvBase64 = args.command.tlvBase64; + + if (!tlvBase64) { + return Sbmd.result().error('No TLV payload in ICECandidates command'); + } + + var decoded = Sbmd.Tlv.decode(tlvBase64); + + // ICECandidates fields: webRTCSessionID (tag 0), ICECandidates (tag 1, array of structs) + var candidateStructs = decoded[1]; + + if (!candidateStructs || !Array.isArray(candidateStructs)) { + return Sbmd.result().error('ICECandidates command missing candidates array'); + } + + // Extract candidate strings from ICECandidateStruct array + // Each struct has: candidate (tag 0), SDPMid (tag 1), SDPMLineIndex (tag 2) + var candidates = []; + + for (var i = 0; i < candidateStructs.length; i++) { + var cs = candidateStructs[i]; + candidates.push(cs[0] || ''); + } + + return Sbmd.result() + .dataModel.updateResource(EP_WEBRTC, 'remoteIceCandidates', JSON.stringify(candidates)) + .success(); +} + +function handleIncomingEndSession(args) { + var tlvBase64 = args.command.tlvBase64; + var reason = 12; // UnknownReason default + + if (tlvBase64) { + var decoded = Sbmd.Tlv.decode(tlvBase64); + + // End fields: webRTCSessionID (tag 0), reason (tag 1) + if (decoded[1] !== undefined) { + reason = decoded[1]; + } + } + + // Find and clean up the associated session + var sessionsJson = args.supplements.transientData[TD_SESSIONS]; + var sessions = parseSessions(sessionsJson); + + if (sessions === null) { + // Corrupt session data: reset it, but still emit the ended event to the client. + return Sbmd.result() + .storage.setTransientData(TD_SESSIONS, '', 0) + .dataModel.updateResource(EP_WEBRTC, 'webrtcError', WEBRTC_ERROR_ENDED, { + sessionId: 'unknown', + reason: reason, + detail: 'WebRTC session ended by camera (reason: ' + reason + ')' + }) + .success(); + } + + var sessionId = findStreamingSessionId(sessions); + + if (sessionId) { + delete sessions[sessionId]; + } + + var metadata = { + sessionId: sessionId || 'unknown', + reason: reason, + detail: 'WebRTC session ended by camera (reason: ' + reason + ')' + }; + + return Sbmd.result() + .storage.setTransientData(TD_SESSIONS, JSON.stringify(sessions), ONE_HOUR_SECS) + .dataModel.updateResource(EP_WEBRTC, 'webrtcError', WEBRTC_ERROR_ENDED, metadata) + .success(); +} diff --git a/core/deviceDrivers/matter/sbmd/specs/contact-sensor.sbmd.js b/core/deviceDrivers/matter/sbmd/specs/contact-sensor.sbmd.js index ea4c0935..e6feb772 100644 --- a/core/deviceDrivers/matter/sbmd/specs/contact-sensor.sbmd.js +++ b/core/deviceDrivers/matter/sbmd/specs/contact-sensor.sbmd.js @@ -29,7 +29,7 @@ // SbmdDriver({ - schemaVersion: '4.0', + schemaVersion: '5.0', driverVersion: 1, name: 'Contact Sensor', diff --git a/core/deviceDrivers/matter/sbmd/specs/door-lock.sbmd.js b/core/deviceDrivers/matter/sbmd/specs/door-lock.sbmd.js index a28bd004..a4fd6d39 100644 --- a/core/deviceDrivers/matter/sbmd/specs/door-lock.sbmd.js +++ b/core/deviceDrivers/matter/sbmd/specs/door-lock.sbmd.js @@ -32,7 +32,7 @@ // SbmdDriver({ - schemaVersion: '4.0', + schemaVersion: '5.0', driverVersion: 1, name: 'Door Lock', diff --git a/core/deviceDrivers/matter/sbmd/specs/humidity-sensor.sbmd.js b/core/deviceDrivers/matter/sbmd/specs/humidity-sensor.sbmd.js index c14aba8e..569c46c8 100644 --- a/core/deviceDrivers/matter/sbmd/specs/humidity-sensor.sbmd.js +++ b/core/deviceDrivers/matter/sbmd/specs/humidity-sensor.sbmd.js @@ -29,7 +29,7 @@ // SbmdDriver({ - schemaVersion: '4.0', + schemaVersion: '5.0', driverVersion: 1, name: 'Humidity Sensor', diff --git a/core/deviceDrivers/matter/sbmd/specs/ikea-timmerflotte.sbmd.js b/core/deviceDrivers/matter/sbmd/specs/ikea-timmerflotte.sbmd.js index ed163a14..d3189360 100644 --- a/core/deviceDrivers/matter/sbmd/specs/ikea-timmerflotte.sbmd.js +++ b/core/deviceDrivers/matter/sbmd/specs/ikea-timmerflotte.sbmd.js @@ -29,7 +29,7 @@ // SbmdDriver({ - schemaVersion: '4.0', + schemaVersion: '5.0', driverVersion: 1, name: 'IKEA TIMMERFLOTTE', diff --git a/core/deviceDrivers/matter/sbmd/specs/light.sbmd.js b/core/deviceDrivers/matter/sbmd/specs/light.sbmd.js index 2567c817..90fe99b5 100644 --- a/core/deviceDrivers/matter/sbmd/specs/light.sbmd.js +++ b/core/deviceDrivers/matter/sbmd/specs/light.sbmd.js @@ -30,7 +30,7 @@ // SbmdDriver({ - schemaVersion: '4.0', + schemaVersion: '5.0', driverVersion: 1, name: 'Light', diff --git a/core/deviceDrivers/matter/sbmd/specs/occupancy-sensor.sbmd.js b/core/deviceDrivers/matter/sbmd/specs/occupancy-sensor.sbmd.js index 505489d4..8783277d 100644 --- a/core/deviceDrivers/matter/sbmd/specs/occupancy-sensor.sbmd.js +++ b/core/deviceDrivers/matter/sbmd/specs/occupancy-sensor.sbmd.js @@ -29,7 +29,7 @@ // SbmdDriver({ - schemaVersion: '4.0', + schemaVersion: '5.0', driverVersion: 1, name: 'Occupancy Sensor', diff --git a/core/deviceDrivers/matter/sbmd/specs/temperature-sensor.sbmd.js b/core/deviceDrivers/matter/sbmd/specs/temperature-sensor.sbmd.js index f51b1740..1687a96f 100644 --- a/core/deviceDrivers/matter/sbmd/specs/temperature-sensor.sbmd.js +++ b/core/deviceDrivers/matter/sbmd/specs/temperature-sensor.sbmd.js @@ -29,7 +29,7 @@ // SbmdDriver({ - schemaVersion: '4.0', + schemaVersion: '5.0', driverVersion: 1, name: 'Temperature Sensor', diff --git a/core/deviceDrivers/matter/sbmd/specs/thermostat.sbmd.js b/core/deviceDrivers/matter/sbmd/specs/thermostat.sbmd.js index c5364486..a3146726 100644 --- a/core/deviceDrivers/matter/sbmd/specs/thermostat.sbmd.js +++ b/core/deviceDrivers/matter/sbmd/specs/thermostat.sbmd.js @@ -30,7 +30,7 @@ // SbmdDriver({ - schemaVersion: '4.0', + schemaVersion: '5.0', driverVersion: 1, name: 'Thermostat', diff --git a/core/deviceDrivers/matter/sbmd/specs/water-leak-detector.sbmd.js b/core/deviceDrivers/matter/sbmd/specs/water-leak-detector.sbmd.js index f27ec9b6..c374e588 100644 --- a/core/deviceDrivers/matter/sbmd/specs/water-leak-detector.sbmd.js +++ b/core/deviceDrivers/matter/sbmd/specs/water-leak-detector.sbmd.js @@ -29,7 +29,7 @@ // SbmdDriver({ - schemaVersion: '4.0', + schemaVersion: '5.0', driverVersion: 1, name: 'Water Leak Detector', diff --git a/core/src/observability/inmemory/observabilityInMemory.c b/core/src/observability/inmemory/observabilityInMemory.c new file mode 100644 index 00000000..fff07a65 --- /dev/null +++ b/core/src/observability/inmemory/observabilityInMemory.c @@ -0,0 +1,835 @@ +// ------------------------------ tabstop = 4 ---------------------------------- +// +// If not stated otherwise in this file or this component's LICENSE file the +// following copyright and licenses apply: +// +// Copyright 2026 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// +// ------------------------------ tabstop = 4 ---------------------------------- + +/* + * In-memory observability backend. + * + * Backs all metric instruments with thread-safe in-process data structures. + * Counters use atomic uint64, gauges use atomic int64, histograms use a + * mutex-protected fixed-bucket distribution. + * + * Instruments are registered in a global list so observabilityDumpJson() + * can enumerate and serialize them. + */ + +#include "observability/observability.h" +#include "observability/observabilityMetrics.h" + +#include + +#include +#include +#include +#include +#include +#include + +/* ------------------------------------------------------------------ */ +/* Attribute key-value pair */ +/* ------------------------------------------------------------------ */ + +typedef struct AttrPair +{ + char *key; + char *value; +} AttrPair; + +typedef struct AttrSet +{ + AttrPair *pairs; + int count; +} AttrSet; + +static AttrSet attrSetFromVa(va_list ap) +{ + AttrSet set = {NULL, 0}; + int cap = 4; + set.pairs = malloc(sizeof(AttrPair) * cap); + + while (true) + { + const char *key = va_arg(ap, const char *); + + if (key == NULL) + { + break; + } + + const char *val = va_arg(ap, const char *); + + if (val == NULL) + { + break; + } + + if (set.count >= cap) + { + cap *= 2; + set.pairs = realloc(set.pairs, sizeof(AttrPair) * cap); + } + + set.pairs[set.count].key = strdup(key); + set.pairs[set.count].value = strdup(val); + set.count++; + } + + return set; +} + +static void attrSetFree(AttrSet *set) +{ + for (int i = 0; i < set->count; i++) + { + free(set->pairs[i].key); + free(set->pairs[i].value); + } + + free(set->pairs); + set->pairs = NULL; + set->count = 0; +} + +static bool attrSetEqual(const AttrSet *a, const AttrSet *b) +{ + if (a->count != b->count) + { + return false; + } + + for (int i = 0; i < a->count; i++) + { + if (strcmp(a->pairs[i].key, b->pairs[i].key) != 0 || + strcmp(a->pairs[i].value, b->pairs[i].value) != 0) + { + return false; + } + } + + return true; +} + +static AttrSet attrSetClone(const AttrSet *src) +{ + AttrSet dst = {NULL, src->count}; + + if (src->count > 0) + { + dst.pairs = malloc(sizeof(AttrPair) * src->count); + + for (int i = 0; i < src->count; i++) + { + dst.pairs[i].key = strdup(src->pairs[i].key); + dst.pairs[i].value = strdup(src->pairs[i].value); + } + } + + return dst; +} + +/* ------------------------------------------------------------------ */ +/* Keyed data point — value associated with an attribute set */ +/* ------------------------------------------------------------------ */ + +typedef struct CounterDataPoint +{ + AttrSet attrs; + uint64_t value; +} CounterDataPoint; + +typedef struct GaugeDataPoint +{ + AttrSet attrs; + int64_t value; +} GaugeDataPoint; + +/* ------------------------------------------------------------------ */ +/* Histogram buckets */ +/* ------------------------------------------------------------------ */ + +/* Default bucket boundaries matching OpenTelemetry SDK defaults */ +static const double kHistogramBounds[] = { + 0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000}; +static const int kNumBounds = sizeof(kHistogramBounds) / sizeof(kHistogramBounds[0]); + +typedef struct HistogramDataPoint +{ + AttrSet attrs; + uint64_t count; + double sum; + double min; + double max; + uint64_t buckets[sizeof(kHistogramBounds) / sizeof(kHistogramBounds[0]) + 1]; +} HistogramDataPoint; + +/* ------------------------------------------------------------------ */ +/* Instrument types */ +/* ------------------------------------------------------------------ */ + +typedef enum +{ + INSTRUMENT_COUNTER, + INSTRUMENT_GAUGE, + INSTRUMENT_HISTOGRAM +} InstrumentType; + +typedef struct InstrumentBase +{ + InstrumentType type; + char *name; + char *description; + char *unit; + int refCount; + struct InstrumentBase *next; /* linked list in global registry */ +} InstrumentBase; + +struct ObservabilityCounter +{ + InstrumentBase base; + pthread_mutex_t lock; + CounterDataPoint *dataPoints; + int dataPointCount; + int dataPointCapacity; +}; + +struct ObservabilityGauge +{ + InstrumentBase base; + pthread_mutex_t lock; + GaugeDataPoint *dataPoints; + int dataPointCount; + int dataPointCapacity; +}; + +struct ObservabilityHistogram +{ + InstrumentBase base; + pthread_mutex_t lock; + HistogramDataPoint *dataPoints; + int dataPointCount; + int dataPointCapacity; +}; + +/* ------------------------------------------------------------------ */ +/* Global registry */ +/* ------------------------------------------------------------------ */ + +static pthread_mutex_t registryLock = PTHREAD_MUTEX_INITIALIZER; +static InstrumentBase *registryHead = NULL; +static bool initialized = false; + +static void registryAdd(InstrumentBase *inst) +{ + pthread_mutex_lock(®istryLock); + inst->next = registryHead; + registryHead = inst; + pthread_mutex_unlock(®istryLock); +} + +static void registryRemove(InstrumentBase *inst) +{ + pthread_mutex_lock(®istryLock); + InstrumentBase **pp = ®istryHead; + + while (*pp) + { + if (*pp == inst) + { + *pp = inst->next; + break; + } + + pp = &(*pp)->next; + } + + pthread_mutex_unlock(®istryLock); +} + +/* ------------------------------------------------------------------ */ +/* Init / Shutdown */ +/* ------------------------------------------------------------------ */ + +int observabilityInit(void) +{ + pthread_mutex_lock(®istryLock); + initialized = true; + pthread_mutex_unlock(®istryLock); + + return 0; +} + +void observabilityShutdown(void) +{ + pthread_mutex_lock(®istryLock); + initialized = false; + + /* Release all instruments still in the registry */ + while (registryHead) + { + InstrumentBase *inst = registryHead; + registryHead = inst->next; + inst->next = NULL; + + /* We don't free here — instruments may still be referenced. + * Just detach from registry. */ + } + + pthread_mutex_unlock(®istryLock); +} + +/* ------------------------------------------------------------------ */ +/* Counter */ +/* ------------------------------------------------------------------ */ + +static CounterDataPoint *counterFindOrAdd(ObservabilityCounter *counter, const AttrSet *attrs) +{ + for (int i = 0; i < counter->dataPointCount; i++) + { + if (attrSetEqual(&counter->dataPoints[i].attrs, attrs)) + { + return &counter->dataPoints[i]; + } + } + + if (counter->dataPointCount >= counter->dataPointCapacity) + { + counter->dataPointCapacity = counter->dataPointCapacity == 0 ? 4 : counter->dataPointCapacity * 2; + counter->dataPoints = realloc(counter->dataPoints, sizeof(CounterDataPoint) * counter->dataPointCapacity); + } + + CounterDataPoint *dp = &counter->dataPoints[counter->dataPointCount++]; + dp->attrs = attrSetClone(attrs); + dp->value = 0; + + return dp; +} + +ObservabilityCounter *observabilityCounterCreate(const char *name, const char *description, const char *unit) +{ + ObservabilityCounter *c = calloc(1, sizeof(ObservabilityCounter)); + c->base.type = INSTRUMENT_COUNTER; + c->base.name = strdup(name); + c->base.description = description ? strdup(description) : strdup(""); + c->base.unit = unit ? strdup(unit) : strdup("1"); + c->base.refCount = 1; + pthread_mutex_init(&c->lock, NULL); + + registryAdd(&c->base); + + return c; +} + +void observabilityCounterAdd(ObservabilityCounter *counter, uint64_t value) +{ + if (counter == NULL) + { + return; + } + + AttrSet empty = {NULL, 0}; + pthread_mutex_lock(&counter->lock); + CounterDataPoint *dp = counterFindOrAdd(counter, &empty); + dp->value += value; + pthread_mutex_unlock(&counter->lock); +} + +void observabilityCounterAddWithAttrs(ObservabilityCounter *counter, uint64_t value, ...) +{ + if (counter == NULL) + { + return; + } + + va_list ap; + va_start(ap, value); + AttrSet attrs = attrSetFromVa(ap); + va_end(ap); + + pthread_mutex_lock(&counter->lock); + CounterDataPoint *dp = counterFindOrAdd(counter, &attrs); + dp->value += value; + pthread_mutex_unlock(&counter->lock); + + attrSetFree(&attrs); +} + +void observabilityCounterRelease(ObservabilityCounter *counter) +{ + if (counter == NULL) + { + return; + } + + int remaining = __sync_sub_and_fetch(&counter->base.refCount, 1); + + if (remaining <= 0) + { + registryRemove(&counter->base); + pthread_mutex_destroy(&counter->lock); + + for (int i = 0; i < counter->dataPointCount; i++) + { + attrSetFree(&counter->dataPoints[i].attrs); + } + + free(counter->dataPoints); + free(counter->base.name); + free(counter->base.description); + free(counter->base.unit); + free(counter); + } +} + +/* ------------------------------------------------------------------ */ +/* Gauge */ +/* ------------------------------------------------------------------ */ + +static GaugeDataPoint *gaugeFindOrAdd(ObservabilityGauge *gauge, const AttrSet *attrs) +{ + for (int i = 0; i < gauge->dataPointCount; i++) + { + if (attrSetEqual(&gauge->dataPoints[i].attrs, attrs)) + { + return &gauge->dataPoints[i]; + } + } + + if (gauge->dataPointCount >= gauge->dataPointCapacity) + { + gauge->dataPointCapacity = gauge->dataPointCapacity == 0 ? 4 : gauge->dataPointCapacity * 2; + gauge->dataPoints = realloc(gauge->dataPoints, sizeof(GaugeDataPoint) * gauge->dataPointCapacity); + } + + GaugeDataPoint *dp = &gauge->dataPoints[gauge->dataPointCount++]; + dp->attrs = attrSetClone(attrs); + dp->value = 0; + + return dp; +} + +ObservabilityGauge *observabilityGaugeCreate(const char *name, const char *description, const char *unit) +{ + ObservabilityGauge *g = calloc(1, sizeof(ObservabilityGauge)); + g->base.type = INSTRUMENT_GAUGE; + g->base.name = strdup(name); + g->base.description = description ? strdup(description) : strdup(""); + g->base.unit = unit ? strdup(unit) : strdup("1"); + g->base.refCount = 1; + pthread_mutex_init(&g->lock, NULL); + + registryAdd(&g->base); + + return g; +} + +void observabilityGaugeRecord(ObservabilityGauge *gauge, int64_t value) +{ + if (gauge == NULL) + { + return; + } + + AttrSet empty = {NULL, 0}; + pthread_mutex_lock(&gauge->lock); + GaugeDataPoint *dp = gaugeFindOrAdd(gauge, &empty); + dp->value = value; + pthread_mutex_unlock(&gauge->lock); +} + +void observabilityGaugeRecordWithAttrs(ObservabilityGauge *gauge, int64_t value, ...) +{ + if (gauge == NULL) + { + return; + } + + va_list ap; + va_start(ap, value); + AttrSet attrs = attrSetFromVa(ap); + va_end(ap); + + pthread_mutex_lock(&gauge->lock); + GaugeDataPoint *dp = gaugeFindOrAdd(gauge, &attrs); + dp->value = value; + pthread_mutex_unlock(&gauge->lock); + + attrSetFree(&attrs); +} + +void observabilityGaugeRelease(ObservabilityGauge *gauge) +{ + if (gauge == NULL) + { + return; + } + + int remaining = __sync_sub_and_fetch(&gauge->base.refCount, 1); + + if (remaining <= 0) + { + registryRemove(&gauge->base); + pthread_mutex_destroy(&gauge->lock); + + for (int i = 0; i < gauge->dataPointCount; i++) + { + attrSetFree(&gauge->dataPoints[i].attrs); + } + + free(gauge->dataPoints); + free(gauge->base.name); + free(gauge->base.description); + free(gauge->base.unit); + free(gauge); + } +} + +/* ------------------------------------------------------------------ */ +/* Histogram */ +/* ------------------------------------------------------------------ */ + +static HistogramDataPoint *histogramFindOrAdd(ObservabilityHistogram *h, const AttrSet *attrs) +{ + for (int i = 0; i < h->dataPointCount; i++) + { + if (attrSetEqual(&h->dataPoints[i].attrs, attrs)) + { + return &h->dataPoints[i]; + } + } + + if (h->dataPointCount >= h->dataPointCapacity) + { + h->dataPointCapacity = h->dataPointCapacity == 0 ? 4 : h->dataPointCapacity * 2; + h->dataPoints = realloc(h->dataPoints, sizeof(HistogramDataPoint) * h->dataPointCapacity); + } + + HistogramDataPoint *dp = &h->dataPoints[h->dataPointCount++]; + dp->attrs = attrSetClone(attrs); + dp->count = 0; + dp->sum = 0; + dp->min = 0; + dp->max = 0; + memset(dp->buckets, 0, sizeof(dp->buckets)); + + return dp; +} + +static void histogramRecordValue(HistogramDataPoint *dp, double value) +{ + dp->count++; + dp->sum += value; + + if (dp->count == 1) + { + dp->min = value; + dp->max = value; + } + else + { + if (value < dp->min) + { + dp->min = value; + } + + if (value > dp->max) + { + dp->max = value; + } + } + + /* Find bucket: first boundary where value <= bound */ + int bucket = kNumBounds; /* overflow bucket */ + + for (int i = 0; i < kNumBounds; i++) + { + if (value <= kHistogramBounds[i]) + { + bucket = i; + break; + } + } + + dp->buckets[bucket]++; +} + +ObservabilityHistogram *observabilityHistogramCreate(const char *name, const char *description, const char *unit) +{ + ObservabilityHistogram *h = calloc(1, sizeof(ObservabilityHistogram)); + h->base.type = INSTRUMENT_HISTOGRAM; + h->base.name = strdup(name); + h->base.description = description ? strdup(description) : strdup(""); + h->base.unit = unit ? strdup(unit) : strdup("1"); + h->base.refCount = 1; + pthread_mutex_init(&h->lock, NULL); + + registryAdd(&h->base); + + return h; +} + +void observabilityHistogramRecord(ObservabilityHistogram *histogram, double value) +{ + if (histogram == NULL) + { + return; + } + + AttrSet empty = {NULL, 0}; + pthread_mutex_lock(&histogram->lock); + HistogramDataPoint *dp = histogramFindOrAdd(histogram, &empty); + histogramRecordValue(dp, value); + pthread_mutex_unlock(&histogram->lock); +} + +void observabilityHistogramRecordWithAttrs(ObservabilityHistogram *histogram, double value, ...) +{ + if (histogram == NULL) + { + return; + } + + va_list ap; + va_start(ap, value); + AttrSet attrs = attrSetFromVa(ap); + va_end(ap); + + pthread_mutex_lock(&histogram->lock); + HistogramDataPoint *dp = histogramFindOrAdd(histogram, &attrs); + histogramRecordValue(dp, value); + pthread_mutex_unlock(&histogram->lock); + + attrSetFree(&attrs); +} + +void observabilityHistogramRelease(ObservabilityHistogram *histogram) +{ + if (histogram == NULL) + { + return; + } + + int remaining = __sync_sub_and_fetch(&histogram->base.refCount, 1); + + if (remaining <= 0) + { + registryRemove(&histogram->base); + pthread_mutex_destroy(&histogram->lock); + + for (int i = 0; i < histogram->dataPointCount; i++) + { + attrSetFree(&histogram->dataPoints[i].attrs); + } + + free(histogram->dataPoints); + free(histogram->base.name); + free(histogram->base.description); + free(histogram->base.unit); + free(histogram); + } +} + +/* ------------------------------------------------------------------ */ +/* JSON dump */ +/* ------------------------------------------------------------------ */ + +static cJSON *attrSetToJson(const AttrSet *attrs) +{ + if (attrs->count == 0) + { + return NULL; + } + + cJSON *obj = cJSON_CreateObject(); + + for (int i = 0; i < attrs->count; i++) + { + cJSON_AddStringToObject(obj, attrs->pairs[i].key, attrs->pairs[i].value); + } + + return obj; +} + +static cJSON *counterToJson(ObservabilityCounter *c) +{ + cJSON *obj = cJSON_CreateObject(); + cJSON_AddStringToObject(obj, "type", "counter"); + cJSON_AddStringToObject(obj, "description", c->base.description); + cJSON_AddStringToObject(obj, "unit", c->base.unit); + + cJSON *dataPoints = cJSON_CreateArray(); + + pthread_mutex_lock(&c->lock); + + for (int i = 0; i < c->dataPointCount; i++) + { + cJSON *dp = cJSON_CreateObject(); + cJSON_AddNumberToObject(dp, "value", (double) c->dataPoints[i].value); + + cJSON *attrs = attrSetToJson(&c->dataPoints[i].attrs); + + if (attrs) + { + cJSON_AddItemToObject(dp, "attributes", attrs); + } + + cJSON_AddItemToArray(dataPoints, dp); + } + + pthread_mutex_unlock(&c->lock); + + cJSON_AddItemToObject(obj, "dataPoints", dataPoints); + + return obj; +} + +static cJSON *gaugeToJson(ObservabilityGauge *g) +{ + cJSON *obj = cJSON_CreateObject(); + cJSON_AddStringToObject(obj, "type", "gauge"); + cJSON_AddStringToObject(obj, "description", g->base.description); + cJSON_AddStringToObject(obj, "unit", g->base.unit); + + cJSON *dataPoints = cJSON_CreateArray(); + + pthread_mutex_lock(&g->lock); + + for (int i = 0; i < g->dataPointCount; i++) + { + cJSON *dp = cJSON_CreateObject(); + cJSON_AddNumberToObject(dp, "value", (double) g->dataPoints[i].value); + + cJSON *attrs = attrSetToJson(&g->dataPoints[i].attrs); + + if (attrs) + { + cJSON_AddItemToObject(dp, "attributes", attrs); + } + + cJSON_AddItemToArray(dataPoints, dp); + } + + pthread_mutex_unlock(&g->lock); + + cJSON_AddItemToObject(obj, "dataPoints", dataPoints); + + return obj; +} + +static cJSON *histogramToJson(ObservabilityHistogram *h) +{ + cJSON *obj = cJSON_CreateObject(); + cJSON_AddStringToObject(obj, "type", "histogram"); + cJSON_AddStringToObject(obj, "description", h->base.description); + cJSON_AddStringToObject(obj, "unit", h->base.unit); + + cJSON *dataPoints = cJSON_CreateArray(); + + pthread_mutex_lock(&h->lock); + + for (int i = 0; i < h->dataPointCount; i++) + { + HistogramDataPoint *hdp = &h->dataPoints[i]; + cJSON *dp = cJSON_CreateObject(); + cJSON_AddNumberToObject(dp, "count", (double) hdp->count); + cJSON_AddNumberToObject(dp, "sum", hdp->sum); + cJSON_AddNumberToObject(dp, "min", hdp->min); + cJSON_AddNumberToObject(dp, "max", hdp->max); + + cJSON *buckets = cJSON_CreateArray(); + + for (int b = 0; b <= kNumBounds; b++) + { + cJSON *bucket = cJSON_CreateObject(); + + if (b < kNumBounds) + { + cJSON_AddNumberToObject(bucket, "le", kHistogramBounds[b]); + } + else + { + cJSON_AddStringToObject(bucket, "le", "+Inf"); + } + + cJSON_AddNumberToObject(bucket, "count", (double) hdp->buckets[b]); + cJSON_AddItemToArray(buckets, bucket); + } + + cJSON_AddItemToObject(dp, "buckets", buckets); + + cJSON *attrs = attrSetToJson(&hdp->attrs); + + if (attrs) + { + cJSON_AddItemToObject(dp, "attributes", attrs); + } + + cJSON_AddItemToArray(dataPoints, dp); + } + + pthread_mutex_unlock(&h->lock); + + cJSON_AddItemToObject(obj, "dataPoints", dataPoints); + + return obj; +} + +char *observabilityDumpJson(void) +{ + cJSON *root = cJSON_CreateObject(); + cJSON *metrics = cJSON_CreateObject(); + + pthread_mutex_lock(®istryLock); + + for (InstrumentBase *inst = registryHead; inst != NULL; inst = inst->next) + { + cJSON *metric = NULL; + + switch (inst->type) + { + case INSTRUMENT_COUNTER: + metric = counterToJson((ObservabilityCounter *) inst); + break; + + case INSTRUMENT_GAUGE: + metric = gaugeToJson((ObservabilityGauge *) inst); + break; + + case INSTRUMENT_HISTOGRAM: + metric = histogramToJson((ObservabilityHistogram *) inst); + break; + } + + if (metric) + { + cJSON_AddItemToObject(metrics, inst->name, metric); + } + } + + pthread_mutex_unlock(®istryLock); + + cJSON_AddItemToObject(root, "metrics", metrics); + + char *json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + + return json; +} diff --git a/core/src/subsystems/matter/DeviceDataCache.cpp b/core/src/subsystems/matter/DeviceDataCache.cpp index 0772db16..8128ff3e 100644 --- a/core/src/subsystems/matter/DeviceDataCache.cpp +++ b/core/src/subsystems/matter/DeviceDataCache.cpp @@ -121,11 +121,14 @@ std::future DeviceDataCache::Start() [](intptr_t arg) { auto *self = reinterpret_cast(arg); - if (self->controller->GetConnectedDevice(barton::Subsystem::Matter::UuidToNodeId(self->deviceUuid), + CHIP_ERROR err = + self->controller->GetConnectedDevice(barton::Subsystem::Matter::UuidToNodeId(self->deviceUuid), &self->mOnDeviceConnectedCallback, - &self->mOnDeviceConnectionFailureCallback) != CHIP_NO_ERROR) + &self->mOnDeviceConnectionFailureCallback); + + if (err != CHIP_NO_ERROR) { - icError("Failed to start device connection"); + icError("Failed to start device connection: %s", err.AsString()); std::lock_guard lock(self->startupPromiseMutex); if (self->startupPromise) { @@ -734,6 +737,14 @@ void DeviceDataCache::OnSubscriptionEstablished(chip::SubscriptionId aSubscripti // Ensure that, once a subscription is established, the naturally negotiated liveness timeout is used readClient->OverrideLivenessTimeout(chip::System::Clock::kZero); + + // Forward to the registered callback (e.g. MatterDevice::CacheCallback) so it can react to + // subscription establishment, such as caching cluster feature maps now that the priming + // report has populated the cache. + if (callback) + { + callback->OnSubscriptionEstablished(aSubscriptionId); + } } void DeviceDataCache::OnError(CHIP_ERROR aError) diff --git a/core/src/subsystems/matter/Matter.cpp b/core/src/subsystems/matter/Matter.cpp index b23c99b3..239630b0 100644 --- a/core/src/subsystems/matter/Matter.cpp +++ b/core/src/subsystems/matter/Matter.cpp @@ -62,6 +62,8 @@ extern "C" { #include #include #include +#include +#include #include #include #include @@ -657,6 +659,7 @@ CHIP_ERROR Matter::InitCommissioner() fabricTable->SetFabricLabel(fabricIndex, labelSpan); ReturnErrorOnFailure(ConfigureOTAProviderNode()); + ReturnErrorOnFailure(ConfigureWebRtcRequestorNode()); ReturnLogErrorOnFailure(fabricTable->CommitPendingFabricData()); @@ -1164,18 +1167,15 @@ CHIP_ERROR Matter::GetFabricIndex(FabricTable *fabricTable, return CHIP_NO_ERROR; } -bool Matter::IsAccessibleByOTARequestors() +bool Matter::HasCaseOperateAclEntryForCluster(chip::ClusterId clusterId) { icDebug(); - bool result = false; - // This will always return false if the node isn't initialized - if (myFabricId == chip::kUndefinedFabricId || myNodeId == chip::kUndefinedNodeId) { icWarn("Node isn't initialized"); - return result; + return false; } size_t index = 0; @@ -1198,37 +1198,60 @@ bool Matter::IsAccessibleByOTARequestors() continue; } + LogErrorOnFailure(AccessControlDump(entry)); + chip::FabricIndex currFabricIndex; chip::Access::Privilege currPrivilege; chip::Access::AuthMode currAuthMode; - LogErrorOnFailure(AccessControlDump(entry)); + if (entry.GetFabricIndex(currFabricIndex) != CHIP_NO_ERROR || + entry.GetPrivilege(currPrivilege) != CHIP_NO_ERROR || entry.GetAuthMode(currAuthMode) != CHIP_NO_ERROR) + { + continue; + } + + if (currFabricIndex != myFabricIndex || currPrivilege < chip::Access::Privilege::kOperate || + currAuthMode != chip::Access::AuthMode::kCase) + { + continue; + } - if (entry.GetFabricIndex(currFabricIndex) == CHIP_NO_ERROR && - entry.GetPrivilege(currPrivilege) == CHIP_NO_ERROR && entry.GetAuthMode(currAuthMode) == CHIP_NO_ERROR) + size_t targetCount = 0; + + if (entry.GetTargetCount(targetCount) != CHIP_NO_ERROR) { - if (currFabricIndex == myFabricIndex && currPrivilege >= chip::Access::Privilege::kOperate && - currAuthMode == chip::Access::AuthMode::kCase) + continue; + } + + // An ACL entry with no targets applies to all clusters on all endpoints, + // so it grants operate access to the requested cluster. + if (targetCount == 0) + { + return true; + } + + for (size_t i = 0; i < targetCount; ++i) + { + Access::AccessControl::Entry::Target target; + + if (entry.GetTarget(i, target) == CHIP_NO_ERROR && + (target.flags & Access::AccessControl::Entry::Target::kCluster) != 0 && target.cluster == clusterId) { - // OTA Requestors need at least the "operate" privilege over CASE - result = true; - break; + return true; } } } - return result; + return false; } -CHIP_ERROR Matter::AppendOTARequestorsACLEntry(chip::FabricId fabricId, chip::FabricIndex fabricIndex) +CHIP_ERROR Matter::AppendCaseOperateAclEntryForCluster(chip::FabricIndex fabricIndex, chip::ClusterId clusterId) { icDebug(); - CHIP_ERROR err = CHIP_NO_ERROR; - if (fabricIndex == chip::kUndefinedFabricIndex) { - icError("No such entry on fabric table for fabricId: %" PRIu64, fabricId); + icError("No such entry on fabric table"); return CHIP_ERROR_INVALID_FABRIC_INDEX; } @@ -1240,7 +1263,7 @@ CHIP_ERROR Matter::AppendOTARequestorsACLEntry(chip::FabricId fabricId, chip::Fa icDebug("fabricIndex = %d", fabricIndex); chip::Access::AccessControl::Entry::Target target = { .flags = chip::Access::AccessControl::Entry::Target::kCluster, - .cluster = chip::app::Clusters::OtaSoftwareUpdateProvider::Id, + .cluster = clusterId, }; chip::Access::AccessControl::Entry entry; @@ -1252,11 +1275,13 @@ CHIP_ERROR Matter::AppendOTARequestorsACLEntry(chip::FabricId fabricId, chip::Fa LogErrorOnFailure(AccessControlDump(entry)); - err = Access::GetAccessControl().CreateEntry(&subjectDescriptor, fabricIndex, nullptr, entry); + CHIP_ERROR err = Access::GetAccessControl().CreateEntry(&subjectDescriptor, fabricIndex, nullptr, entry); if (err != CHIP_NO_ERROR) { - icError("Failed to add ACL entry: %" CHIP_ERROR_FORMAT, err.Format()); + icError("Failed to add ACL entry for cluster 0x%" PRIx32 ": %" CHIP_ERROR_FORMAT, + static_cast(clusterId), + err.Format()); return err; } @@ -1267,10 +1292,11 @@ CHIP_ERROR Matter::ConfigureOTAProviderNode() { icDebug(); - if (IsAccessibleByOTARequestors() == false) + if (!HasCaseOperateAclEntryForCluster(chip::app::Clusters::OtaSoftwareUpdateProvider::Id)) { icDebug("Adding ACL entries so that OTA Requestors can poll our node"); - ReturnErrorOnFailure(AppendOTARequestorsACLEntry(myFabricId, myFabricIndex)); + ReturnErrorOnFailure( + AppendCaseOperateAclEntryForCluster(myFabricIndex, chip::app::Clusters::OtaSoftwareUpdateProvider::Id)); } // This functionality will be added back when we support being a Controller @@ -1279,6 +1305,46 @@ CHIP_ERROR Matter::ConfigureOTAProviderNode() return CHIP_NO_ERROR; } +// Returns true if any endpoint in the running data model hosts the given cluster as a +// server. This runtime check is necessary for external clusters (not a standard registered +// cluster server). +static bool DataModelHostsClusterServer(chip::ClusterId clusterId) +{ + uint16_t endpointCount = emberAfEndpointCount(); + + for (uint16_t index = 0; index < endpointCount; ++index) + { + if (emberAfContainsServerFromIndex(index, clusterId)) + { + return true; + } + } + + return false; +} + +CHIP_ERROR Matter::ConfigureWebRtcRequestorNode() +{ + icDebug(); + + // Only wire up WebRTC peer access if this build's data model actually hosts the + // WebRTCTransportRequestor cluster. + if (!DataModelHostsClusterServer(chip::app::Clusters::WebRTCTransportRequestor::Id)) + { + icDebug("WebRTCTransportRequestor cluster not configured; skipping WebRTC ACL setup"); + return CHIP_NO_ERROR; + } + + if (!HasCaseOperateAclEntryForCluster(chip::app::Clusters::WebRTCTransportRequestor::Id)) + { + icDebug("Adding ACL entry so commissioned WebRTC peers can invoke our WebRTCTransportRequestor cluster"); + ReturnErrorOnFailure( + AppendCaseOperateAclEntryForCluster(myFabricIndex, chip::app::Clusters::WebRTCTransportRequestor::Id)); + } + + return CHIP_NO_ERROR; +} + CHIP_ERROR Matter::AccessControlDump(const chip::Access::AccessControl::Entry &entry) { // TODO: Write a proper implementation when there is value in doing so @@ -1649,3 +1715,9 @@ void emberAfThreadBorderRouterManagementClusterInitCallback(EndpointId endpoint) } } #endif // ZCL_USING_THREAD_DIAGNOSTICS_CLUSTER_SERVER + +// WebRTCTransportRequestor cluster commands are handled externally via CommandHandlerInterface +// in SpecBasedMatterDeviceDriver. These stubs satisfy the ZAP-generated init/shutdown callbacks. +void MatterWebRTCTransportRequestorPluginServerInitCallback() {} + +void MatterWebRTCTransportRequestorPluginServerShutdownCallback() {} diff --git a/core/src/subsystems/matter/Matter.h b/core/src/subsystems/matter/Matter.h index 63531daf..f59e1b11 100644 --- a/core/src/subsystems/matter/Matter.h +++ b/core/src/subsystems/matter/Matter.h @@ -43,10 +43,10 @@ #include #include #include +#include +#include #include #include -#include -#include #include @@ -157,12 +157,14 @@ namespace barton * freeing the setupCode and qrCode. * * @param nodeId the nodeId of the device to open the commissioning window for, or NULL for local - * @param timeoutSeconds the number of seconds to perform discovery before automatically stopping or 0 for default + * @param timeoutSeconds the number of seconds to perform discovery before automatically stopping or 0 for + * default * @param setupCode receives the setup code if successful * @param qrCode receives the QR code if successful * @return true on success */ - bool OpenCommissioningWindow(chip::NodeId nodeId, uint16_t timeoutSecs, std::string &setupCode, std::string &qrCode); + bool + OpenCommissioningWindow(chip::NodeId nodeId, uint16_t timeoutSecs, std::string &setupCode, std::string &qrCode); /** * @brief Clear the AccessRestrictionList for certification testing @@ -206,8 +208,12 @@ namespace barton static chip::NodeId LoadOrGenerateLocalNodeId(); CHIP_ERROR ConfigureOTAProviderNode(); - bool IsAccessibleByOTARequestors(); - CHIP_ERROR AppendOTARequestorsACLEntry(chip::FabricId fabricId, chip::FabricIndex fabricIndex); + CHIP_ERROR ConfigureWebRtcRequestorNode(); + + // Shared ACL helpers for granting commissioned peers CASE/operate access to a + // specific cluster (an existing matching entry is left untouched). + bool HasCaseOperateAclEntryForCluster(chip::ClusterId clusterId); + CHIP_ERROR AppendCaseOperateAclEntryForCluster(chip::FabricIndex fabricIndex, chip::ClusterId clusterId); bool IsDevelopmentMode() { @@ -284,9 +290,7 @@ namespace barton */ bool SetAccessRestrictionList(); - bool OpenLocalCommissioningWindow(uint16_t discriminator, - uint16_t timeoutSecs, - SetupPayload &setupPayload); + bool OpenLocalCommissioningWindow(uint16_t discriminator, uint16_t timeoutSecs, SetupPayload &setupPayload); bool serverIsInitialized = false; diff --git a/core/test/CMakeLists.txt b/core/test/CMakeLists.txt index 280b1545..988bfc19 100644 --- a/core/test/CMakeLists.txt +++ b/core/test/CMakeLists.txt @@ -314,6 +314,7 @@ if (BCORE_MATTER) bcore_add_cpp_test( NAME testSbmdHandlerInvoker SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/SbmdHandlerInvokerTest.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/SbmdDriverTestSupport.cpp ${PROJECT_SOURCE_DIR}/core/deviceDrivers/matter/sbmd/mquickjs/SbmdHandlerInvoker.cpp ${PROJECT_SOURCE_DIR}/core/deviceDrivers/matter/sbmd/mquickjs/SbmdResultExecutor.cpp ${PROJECT_SOURCE_DIR}/core/deviceDrivers/matter/sbmd/mquickjs/SbmdLoader.cpp @@ -354,6 +355,31 @@ if (BCORE_MATTER) target_link_libraries(testSbmdFactory bCoreConfig) endif() + bcore_add_cpp_test( + NAME testSbmdCameraWebrtc + SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/SbmdCameraWebrtcTest.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/SbmdDriverTestSupport.cpp + ${PROJECT_SOURCE_DIR}/core/deviceDrivers/matter/sbmd/SbmdDriver.cpp + ${PROJECT_SOURCE_DIR}/core/deviceDrivers/matter/sbmd/SbmdDispatch.cpp + ${PROJECT_SOURCE_DIR}/core/deviceDrivers/matter/sbmd/mquickjs/SbmdHandlerInvoker.cpp + ${PROJECT_SOURCE_DIR}/core/deviceDrivers/matter/sbmd/mquickjs/SbmdResultExecutor.cpp + ${PROJECT_SOURCE_DIR}/core/deviceDrivers/matter/sbmd/mquickjs/SbmdLoader.cpp + ${PROJECT_SOURCE_DIR}/core/deviceDrivers/matter/sbmd/mquickjs/SbmdJsUtil.cpp + ${PROJECT_SOURCE_DIR}/core/deviceDrivers/matter/sbmd/mquickjs/MQuickJsRuntime.cpp + ${PROJECT_SOURCE_DIR}/core/deviceDrivers/matter/sbmd/mquickjs/SbmdBundleLoader.cpp + ${PROJECT_SOURCE_DIR}/core/deviceDrivers/matter/sbmd/mquickjs/MQuickJsStdlib.c + ${SBMD_METRICS_SRC} + ${SBMD_OBS_NOOP_SRC} + LIBS mquickjs gmock BartonCommon::xhLog BartonCommon::xhConcurrent cjson + INCLUDES ${BARTON_PRIVATE_INCLUDES} + ${PROJECT_SOURCE_DIR}/core + ) + + if (TARGET testSbmdCameraWebrtc) + target_link_libraries(testSbmdCameraWebrtc bCoreConfig) + target_compile_definitions(testSbmdCameraWebrtc PRIVATE -DSBMD_SPEC_DIR="${CMAKE_SOURCE_DIR}/core/deviceDrivers/matter/sbmd/specs/") + endif() + # SBMD observability tests — only meaningful with the in-memory backend. if (BCORE_OBSERVABILITY_BACKEND STREQUAL "memory") bcore_add_cpp_test( diff --git a/core/test/src/SbmdCameraWebrtcTest.cpp b/core/test/src/SbmdCameraWebrtcTest.cpp new file mode 100644 index 00000000..9a16ae30 --- /dev/null +++ b/core/test/src/SbmdCameraWebrtcTest.cpp @@ -0,0 +1,925 @@ +//------------------------------ tabstop = 4 ---------------------------------- +// +// If not stated otherwise in this file or this component's LICENSE file the +// following copyright and licenses apply: +// +// Copyright 2026 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// +//------------------------------ tabstop = 4 ---------------------------------- + +/* + * Unit tests for camera.sbmd.js WebRTC endpoint handlers. + * + * Tests load and activate the real camera.sbmd.js spec file, then exercise + * the actual handler functions — no inline copies that can drift. + * + * Tests cover: + * - readNegotiationRole: reports the CAMERA's role (offerer/answerer) from the AcceptedCommandList + * - executeLocalSdp (client-offers / ProvideOffer flow): TLV encoding (null webRTCSessionID, tags), error paths + * - executeLocalIceCandidates: valid JSON array → sendCommand, invalid JSON → error + * - handleIncomingOffer / handleIncomingAnswer / handleIncomingIceCandidates / handleIncomingEndSession + * - executeDestroySession with streaming session: sends EndSession command + */ + +#include "SbmdDriverTestBase.h" + +#include "deviceDrivers/matter/sbmd/SbmdDriver.h" +#include "deviceDrivers/matter/sbmd/mquickjs/MQuickJsRuntime.h" +#include "deviceDrivers/matter/sbmd/mquickjs/SbmdHandlerInvoker.h" +#include "deviceDrivers/matter/sbmd/mquickjs/SbmdLoader.h" + +#include +#include +#include +#include +#include + +using namespace barton; +using namespace barton::test; + +namespace +{ + // ======================================================================== + // Constants matching camera.sbmd.js + // ======================================================================== + constexpr uint32_t CL_WEBRTC_TRANSPORT_PROVIDER = 0x0553; + constexpr uint32_t CL_WEBRTC_TRANSPORT_REQUESTOR = 0x0554; + constexpr uint32_t CL_CAMERA_AV_STREAM_MGMT = 0x0551; + constexpr uint32_t CMD_PROVIDE_OFFER = 0x02; + constexpr uint32_t CMD_PROVIDE_OFFER_RESP = 0x03; + constexpr uint32_t CMD_PROVIDE_ICE = 0x05; + constexpr uint32_t CMD_END_SESSION = 0x06; + constexpr uint32_t CMD_VIDEO_STREAM_ALLOCATE = 0x03; + constexpr uint32_t CMD_VIDEO_STREAM_ALLOCATE_RESP = 0x04; + constexpr uint32_t CMD_OFFER = 0x00; + constexpr uint32_t CMD_ANSWER = 0x01; + constexpr uint32_t CMD_ICE_CANDIDATES = 0x02; + constexpr uint32_t CMD_END = 0x03; + + // providerAcceptedCommands (AcceptedCommandList) as base64 TLV: a top-level TLV array of + // command IDs advertising ProvideOffer (0x02) but NOT SolicitOffer (0x00). The camera answers + // the client's offer (ProvideOffer flow), so cameraIsOfferer() is false — the camera's role is + // 'answerer' and the client drives the offer. + // TLV bytes: 0x16(array) 0x04(uint8) 0x02 0x18(end). + constexpr const char *CAMERA_ANSWERER_ACCEPTED_CMDS = "FgQCGA=="; + + // Same shape advertising SolicitOffer (0x00) but NOT ProvideOffer, so the camera generates the + // offer (SolicitOffer flow), cameraIsOfferer() is true, and the camera's role is 'offerer'. + // TLV bytes: 0x16(array) 0x04(uint8) 0x00 0x18(end). + constexpr const char *CAMERA_OFFERER_ACCEPTED_CMDS = "FgQAGA=="; + + // ======================================================================== + // Test Fixture — loads the real camera.sbmd.js via SbmdDriver + // ======================================================================== + class SbmdCameraWebrtcTest : public SbmdDriverTestBase + { + protected: + static std::unique_ptr s_driver; + + static void SetUpTestSuite() + { + InitRuntime(); + auto *ctx = MQuickJsRuntime::GetSharedContext(); + + // Load the real camera.sbmd.js spec file + std::string specPath = std::string(SBMD_SPEC_DIR) + "camera.sbmd.js"; + std::ifstream file(specPath, std::ios::binary | std::ios::ate); + ASSERT_TRUE(file.is_open()) << "Failed to open " << specPath; + auto fileSize = file.tellg(); + file.seekg(0, std::ios::beg); + std::string source(static_cast(fileSize), '\0'); + file.read(source.data(), fileSize); + file.close(); + + auto reg = SbmdLoader::LoadDriver(ctx, specPath, source.c_str(), source.size()); + ASSERT_NE(reg, nullptr) << "SbmdLoader::LoadDriver failed for camera.sbmd.js"; + + s_driver = std::make_unique(std::move(reg), std::move(source)); + ASSERT_TRUE(s_driver->Activate(ctx)) << "SbmdDriver::Activate failed"; + } + + static void TearDownTestSuite() + { + if (s_driver) + { + auto *ctx = MQuickJsRuntime::GetSharedContext(); + s_driver->Deactivate(ctx); + s_driver.reset(); + } + + ShutdownRuntime(); + } + + HandlerContext MakeContext() { return SbmdDriverTestBase::MakeContext("test-camera-uuid"); } + + // Build a transient-data "sessions" JSON blob for a single session. + static std::string SessionsJson(const std::string &id, const std::string &state) + { + return R"({")" + id + R"(":{"state":")" + state + R"(","protocol":"webrtc"}})"; + } + + static std::string SessionsJson(const std::string &id, const std::string &state, int webRtcSessionId) + { + return R"({")" + id + R"(":{"state":")" + state + R"(","protocol":"webrtc","webRTCSessionID":)" + + std::to_string(webRtcSessionId) + R"(}})"; + } + + /** + * Find a resource execute handler by endpoint and resource ID. + */ + JSValue FindResourceHandler(const std::string &endpointId, const std::string &resourceId) + { + const auto ® = s_driver->GetRegistration(); + + for (const auto &ep : reg.endpoints) + { + if (ep.id == endpointId) + { + for (const auto &r : ep.resources) + { + if (r.id == resourceId && r.execute.has_value()) + { + return r.execute->Fn(); + } + } + } + } + + return JS_UNDEFINED; + } + + /** + * Find the supplements for a resource execute handler. + */ + const SbmdSupplements *FindResourceSupplements(const std::string &endpointId, const std::string &resourceId) + { + const auto ® = s_driver->GetRegistration(); + + for (const auto &ep : reg.endpoints) + { + if (ep.id == endpointId) + { + for (const auto &r : ep.resources) + { + if (r.id == resourceId && r.execute.has_value()) + { + return &r.execute->supplements; + } + } + } + } + + return nullptr; + } + + /** + * Find a command handler by name from the registration's commandHandlers vector. + * The camera command handlers are bound via aliases; this test drives them directly by + * registration name rather than going through the dispatch table. + */ + JSValue FindCommandHandler(const std::string &name) + { + const auto ® = s_driver->GetRegistration(); + + for (const auto &ch : reg.commandHandlers) + { + if (ch.name == name) + { + return ch.Fn(); + } + } + + return JS_UNDEFINED; + } + + /** + * Find supplements for a command handler by name. + */ + const SbmdSupplements *FindCommandSupplements(const std::string &name) + { + const auto ® = s_driver->GetRegistration(); + + for (const auto &ch : reg.commandHandlers) + { + if (ch.name == name) + { + return &ch.supplements; + } + } + + return nullptr; + } + + /** + * Build and invoke a resource execute handler from the loaded driver. + */ + std::optional InvokeExecuteHandler(const std::string &endpointId, + const std::string &resourceId, + const std::string &input, + const std::string &sessionsJson, + const std::string &webrtcMapJson = "", + const std::map &featureMaps = {}, + const std::string &acceptedCmdsBase64 = "") + { + auto hctx = MakeContext(); + hctx.clusterFeatureMaps = featureMaps; + std::lock_guard lock(MQuickJsRuntime::GetMutex()); + + // Root the handler: BuildResourceArgs/PrefetchSupplements/AddSupplements below allocate, + // and mquickjs's moving GC can relocate an unrooted function object, leaving a raw JSValue + // snapshot stale. SafeJSValue::Get() always yields the current (relocated) function. + SafeJSValue handler(Ctx(), FindResourceHandler(endpointId, resourceId)); + + if (JS_IsUndefined(handler.Get())) + { + ADD_FAILURE() << "No execute handler for " << endpointId << "/" << resourceId; + return std::nullopt; + } + + SafeJSValue args = SbmdHandlerInvoker::BuildResourceArgs(Ctx(), hctx, resourceId, input); + + const auto *sup = FindResourceSupplements(endpointId, resourceId); + SbmdSupplements supplements = sup ? *sup : SbmdSupplements {}; + + auto fetched = SbmdHandlerInvoker::PrefetchSupplements( + supplements, + [&](const std::string &aliasName) -> std::optional { + if (aliasName == "providerAcceptedCommands" && !acceptedCmdsBase64.empty()) + { + return acceptedCmdsBase64; + } + + return std::nullopt; + }, + [](const std::string &) { return std::nullopt; }, + [](const std::string &) { return std::nullopt; }, + [&](const std::string &key) -> std::optional { + if (key == "sessions") + { + return sessionsJson; + } + + if (key == "webrtcSessionMap") + { + return webrtcMapJson.empty() ? std::nullopt : std::optional(webrtcMapJson); + } + + return std::nullopt; + }); + + SbmdHandlerInvoker::AddSupplements(Ctx(), args, supplements, fetched); + + return SbmdHandlerInvoker::InvokeHandler(Ctx(), handler.Get(), args); + } + + /** + * Build and invoke a resource READ handler (e.g. negotiationRole) with the camera's + * advertised AcceptedCommandList supplied as the providerAcceptedCommands supplement. + */ + std::optional InvokeReadHandler(const std::string &endpointId, + const std::string &resourceId, + const std::string &acceptedCmdsBase64) + { + auto hctx = MakeContext(); + std::lock_guard lock(MQuickJsRuntime::GetMutex()); + + const SbmdHandler *readHandler = nullptr; + const auto ® = s_driver->GetRegistration(); + + for (const auto &ep : reg.endpoints) + { + if (ep.id != endpointId) + { + continue; + } + + for (const auto &r : ep.resources) + { + if (r.id == resourceId && r.read.has_value()) + { + readHandler = &r.read.value(); + } + } + } + + if (readHandler == nullptr) + { + ADD_FAILURE() << "No read handler for " << endpointId << "/" << resourceId; + return std::nullopt; + } + + // Root the handler (see InvokeExecuteHandler): the arg/supplement building below + // allocates and can relocate an unrooted function object under mquickjs's moving GC. + SafeJSValue handler(Ctx(), readHandler->Fn()); + SafeJSValue args = SbmdHandlerInvoker::BuildResourceArgs(Ctx(), hctx, resourceId, ""); + + SbmdSupplements supplements = readHandler->supplements; + + auto fetched = SbmdHandlerInvoker::PrefetchSupplements( + supplements, + [&](const std::string &aliasName) -> std::optional { + if (aliasName == "providerAcceptedCommands" && !acceptedCmdsBase64.empty()) + { + return acceptedCmdsBase64; + } + + return std::nullopt; + }, + [](const std::string &) { return std::nullopt; }, + [](const std::string &) { return std::nullopt; }, + [](const std::string &) { return std::nullopt; }); + + SbmdHandlerInvoker::AddSupplements(Ctx(), args, supplements, fetched); + + return SbmdHandlerInvoker::InvokeHandler(Ctx(), handler.Get(), args); + } + + /** + * Build and invoke a command handler from the loaded driver by handler name. + */ + std::optional InvokeCommandHandler(const std::string &handlerName, + uint32_t clusterId, + uint32_t commandId, + const std::string &tlvBase64, + const std::string &sessionsJson, + const std::string &webrtcMapJson = "") + { + auto hctx = MakeContext(); + std::lock_guard lock(MQuickJsRuntime::GetMutex()); + + // Root the handler (see InvokeExecuteHandler): the arg/supplement building below allocates + // and can relocate an unrooted function object under mquickjs's moving GC. + SafeJSValue handler(Ctx(), FindCommandHandler(handlerName)); + + if (JS_IsUndefined(handler.Get())) + { + ADD_FAILURE() << "No command handler named '" << handlerName << "'"; + return std::nullopt; + } + + SafeJSValue args = SbmdHandlerInvoker::BuildCommandArgs(Ctx(), hctx, clusterId, commandId, tlvBase64); + + const auto *sup = FindCommandSupplements(handlerName); + SbmdSupplements supplements = sup ? *sup : SbmdSupplements {}; + + auto fetched = SbmdHandlerInvoker::PrefetchSupplements( + supplements, + [](const std::string &) { return std::nullopt; }, + [](const std::string &) { return std::nullopt; }, + [](const std::string &) { return std::nullopt; }, + [&](const std::string &key) -> std::optional { + if (key == "sessions") + { + return sessionsJson; + } + + if (key == "webrtcSessionMap") + { + return webrtcMapJson.empty() ? std::nullopt : std::optional(webrtcMapJson); + } + + return std::nullopt; + }); + + SbmdHandlerInvoker::AddSupplements(Ctx(), args, supplements, fetched); + + return SbmdHandlerInvoker::InvokeHandler(Ctx(), handler.Get(), args); + } + + /** + * Invoke a callback captured in a parsed result (e.g. a requestCommand's onError/onResponse + * continuation) with a caller-supplied args object. Used to exercise the offer-flow error + * handlers, which are not registered execute/command handlers. + * + * @param fn the callback captured in a RequestCommand terminal + * @param argsExpr a JS expression yielding the args object + */ + std::optional InvokeCallback(const SafeJSValue &fn, const std::string &argsExpr) + { + std::lock_guard lock(MQuickJsRuntime::GetMutex()); + + if (!fn.HasValue()) + { + ADD_FAILURE() << "Callback has no value"; + return std::nullopt; + } + + JSValue argsVal = JS_Eval(Ctx(), argsExpr.c_str(), argsExpr.size(), "", JS_EVAL_RETVAL); + SafeJSValue args(Ctx(), argsVal); + + return SbmdHandlerInvoker::InvokeHandler(Ctx(), fn.Get(), args); + } + }; + + std::unique_ptr SbmdCameraWebrtcTest::s_driver; + + // ======================================================================== + // readNegotiationRole — reports the CAMERA's role + // ======================================================================== + + TEST_F(SbmdCameraWebrtcTest, NegotiationRoleReportsCameraRole) + { + // SolicitOffer accepted: the camera generates the offer, so its role is 'offerer'. + auto solicit = InvokeReadHandler("webrtc", "negotiationRole", CAMERA_OFFERER_ACCEPTED_CMDS); + ExpectSuccess(solicit); + EXPECT_EQ(std::get(solicit->terminal.data).value, "offerer"); + + // ProvideOffer only: the camera answers the client's offer, so its role is 'answerer'. + auto provide = InvokeReadHandler("webrtc", "negotiationRole", CAMERA_ANSWERER_ACCEPTED_CMDS); + ExpectSuccess(provide); + EXPECT_EQ(std::get(provide->terminal.data).value, "answerer"); + + // AcceptedCommandList unavailable: default SolicitOffer flow, camera's role is 'offerer'. + auto def = InvokeReadHandler("webrtc", "negotiationRole", ""); + ExpectSuccess(def); + EXPECT_EQ(std::get(def->terminal.data).value, "offerer"); + } + + // ======================================================================== + // 5.1 — executeOfferSdp + // ======================================================================== + + TEST_F(SbmdCameraWebrtcTest, ExecuteOfferSdpValidSessionProducesVideoStreamAllocate) + { + std::string sessions = SessionsJson("1", "streaming"); + auto result = InvokeExecuteHandler( + "webrtc", "localSdp", "test-offer-sdp", sessions, "", {}, CAMERA_ANSWERER_ACCEPTED_CMDS); + + auto &cmd = ExpectRequestCommand(result, CL_CAMERA_AV_STREAM_MGMT, CMD_VIDEO_STREAM_ALLOCATE); + EXPECT_EQ(cmd.responseCommandId, CMD_VIDEO_STREAM_ALLOCATE_RESP); + EXPECT_FALSE(JS_IsUndefined(cmd.onResponse)); + EXPECT_FALSE(JS_IsUndefined(cmd.onError)); + + ASSERT_GE(result->ops.size(), 1u); + EXPECT_TRUE(std::holds_alternative(result->ops[0].data)); + } + + TEST_F(SbmdCameraWebrtcTest, ExecuteOfferSdpAllocateTlvHasStreamUsage) + { + std::string sessions = SessionsJson("1", "streaming"); + auto result = InvokeExecuteHandler( + "webrtc", "localSdp", "test-offer-sdp", sessions, "", {}, CAMERA_ANSWERER_ACCEPTED_CMDS); + auto &cmd = ExpectRequestCommand(result, CL_CAMERA_AV_STREAM_MGMT, CMD_VIDEO_STREAM_ALLOCATE); + + std::lock_guard lock(MQuickJsRuntime::GetMutex()); + JSValue decoded = DecodeTlv(cmd.tlvBase64); + + // Tag 0 = StreamUsage (enum8), value 3 = LiveView + JSValue tag0 = JS_GetPropertyUint32(Ctx(), decoded, 0); + ASSERT_FALSE(JS_IsUndefined(tag0)) << "StreamUsage (tag 0) must be present"; + int32_t streamUsage = 0; + JS_ToInt32(Ctx(), &streamUsage, tag0); + EXPECT_EQ(streamUsage, 3) << "StreamUsage should be 3 (LiveView)"; + } + + TEST_F(SbmdCameraWebrtcTest, ExecuteOfferSdpAllocateTlvHasCorrectFields) + { + std::string sessions = SessionsJson("1", "streaming"); + auto result = InvokeExecuteHandler( + "webrtc", "localSdp", "test-offer-sdp", sessions, "", {}, CAMERA_ANSWERER_ACCEPTED_CMDS); + auto &cmd = ExpectRequestCommand(result, CL_CAMERA_AV_STREAM_MGMT, CMD_VIDEO_STREAM_ALLOCATE); + + std::lock_guard lock(MQuickJsRuntime::GetMutex()); + JSValue decoded = DecodeTlv(cmd.tlvBase64); + + // Tag 0 = StreamUsage (enum8) = 3 (LiveView) + JSValue tag0 = JS_GetPropertyUint32(Ctx(), decoded, 0); + int32_t streamUsage = 0; + JS_ToInt32(Ctx(), &streamUsage, tag0); + EXPECT_EQ(streamUsage, 3); + + // Tag 1 = VideoCodec (enum8) = 0 (H264) + JSValue tag1 = JS_GetPropertyUint32(Ctx(), decoded, 1); + ASSERT_FALSE(JS_IsUndefined(tag1)) << "VideoCodec (tag 1) must be present"; + int32_t videoCodec = -1; + JS_ToInt32(Ctx(), &videoCodec, tag1); + EXPECT_EQ(videoCodec, 0) << "VideoCodec should be 0 (H264)"; + + // Tag 2 = MinFrameRate (uint16) >= 1 + JSValue tag2 = JS_GetPropertyUint32(Ctx(), decoded, 2); + ASSERT_FALSE(JS_IsUndefined(tag2)) << "MinFrameRate (tag 2) must be present"; + int32_t minFps = 0; + JS_ToInt32(Ctx(), &minFps, tag2); + EXPECT_GE(minFps, 1); + + // Tag 3 = MaxFrameRate (uint16) >= MinFrameRate + JSValue tag3 = JS_GetPropertyUint32(Ctx(), decoded, 3); + ASSERT_FALSE(JS_IsUndefined(tag3)) << "MaxFrameRate (tag 3) must be present"; + int32_t maxFps = 0; + JS_ToInt32(Ctx(), &maxFps, tag3); + EXPECT_GE(maxFps, minFps); + + // Tag 4 = MinResolution (struct with Width/Height) + JSValue tag4 = JS_GetPropertyUint32(Ctx(), decoded, 4); + ASSERT_FALSE(JS_IsUndefined(tag4)) << "MinResolution (tag 4) must be present"; + + // Tag 5 = MaxResolution (struct with Width/Height) + JSValue tag5 = JS_GetPropertyUint32(Ctx(), decoded, 5); + ASSERT_FALSE(JS_IsUndefined(tag5)) << "MaxResolution (tag 5) must be present"; + + // Tag 6 = MinBitRate (uint32) >= 1 + JSValue tag6 = JS_GetPropertyUint32(Ctx(), decoded, 6); + ASSERT_FALSE(JS_IsUndefined(tag6)) << "MinBitRate (tag 6) must be present"; + + // Tag 7 = MaxBitRate (uint32) >= MinBitRate + JSValue tag7 = JS_GetPropertyUint32(Ctx(), decoded, 7); + ASSERT_FALSE(JS_IsUndefined(tag7)) << "MaxBitRate (tag 7) must be present"; + + // Tag 8 = KeyFrameInterval (uint16) + JSValue tag8 = JS_GetPropertyUint32(Ctx(), decoded, 8); + ASSERT_FALSE(JS_IsUndefined(tag8)) << "KeyFrameInterval (tag 8) must be present"; + + // Tags 9 and 10 should NOT be present when featureMap has no Watermark/OSD + JSValue tag9 = JS_GetPropertyUint32(Ctx(), decoded, 9); + EXPECT_TRUE(JS_IsUndefined(tag9)) << "WatermarkEnabled (tag 9) must NOT be present without feature"; + JSValue tag10 = JS_GetPropertyUint32(Ctx(), decoded, 10); + EXPECT_TRUE(JS_IsUndefined(tag10)) << "OSDEnabled (tag 10) must NOT be present without feature"; + } + + TEST_F(SbmdCameraWebrtcTest, ExecuteOfferSdpAllocateIncludesWatermarkAndOsdWhenFeatured) + { + std::string sessions = SessionsJson("1", "streaming"); + // Feature bits: kWatermark=0x40, kOnScreenDisplay=0x80 + std::map featureMaps = { + {CL_CAMERA_AV_STREAM_MGMT, 0xC0} + }; + auto result = InvokeExecuteHandler( + "webrtc", "localSdp", "test-offer-sdp", sessions, "", featureMaps, CAMERA_ANSWERER_ACCEPTED_CMDS); + auto &cmd = ExpectRequestCommand(result, CL_CAMERA_AV_STREAM_MGMT, CMD_VIDEO_STREAM_ALLOCATE); + + std::lock_guard lock(MQuickJsRuntime::GetMutex()); + JSValue decoded = DecodeTlv(cmd.tlvBase64); + + // Tag 9 = WatermarkEnabled (bool) + JSValue tag9 = JS_GetPropertyUint32(Ctx(), decoded, 9); + ASSERT_FALSE(JS_IsUndefined(tag9)) << "WatermarkEnabled (tag 9) must be present with kWatermark feature"; + + // Tag 10 = OSDEnabled (bool) + JSValue tag10 = JS_GetPropertyUint32(Ctx(), decoded, 10); + ASSERT_FALSE(JS_IsUndefined(tag10)) << "OSDEnabled (tag 10) must be present with kOnScreenDisplay feature"; + } + + TEST_F(SbmdCameraWebrtcTest, ExecuteOfferSdpContextCarriesSdp) + { + std::string sessions = SessionsJson("1", "streaming"); + auto result = InvokeExecuteHandler( + "webrtc", "localSdp", "test-offer-sdp", sessions, "", {}, CAMERA_ANSWERER_ACCEPTED_CMDS); + auto &cmd = ExpectRequestCommand(result, CL_CAMERA_AV_STREAM_MGMT, CMD_VIDEO_STREAM_ALLOCATE); + + // Verify context carries the SDP for the chained ProvideOffer + ASSERT_FALSE(JS_IsUndefined(cmd.context)); + std::lock_guard lock(MQuickJsRuntime::GetMutex()); + JSValue sdpVal = JS_GetPropertyStr(Ctx(), cmd.context, "sdp"); + ASSERT_TRUE(JS_IsString(Ctx(), sdpVal)) << "context.sdp must be a string"; + JSCStringBuf buf; + const char *sdpStr = JS_ToCString(Ctx(), sdpVal, &buf); + EXPECT_STREQ(sdpStr, "test-offer-sdp"); + } + + TEST_F(SbmdCameraWebrtcTest, ExecuteOfferSdpMissingSessionReturnsError) + { + std::string sessions = SessionsJson("1", "created"); + ExpectError(InvokeExecuteHandler( + "webrtc", "localSdp", "test-offer-sdp", sessions, "", {}, CAMERA_ANSWERER_ACCEPTED_CMDS), + "No active streaming session"); + } + + TEST_F(SbmdCameraWebrtcTest, ExecuteOfferSdpMissingInputReturnsError) + { + std::string sessions = SessionsJson("1", "streaming"); + ExpectError(InvokeExecuteHandler("webrtc", "localSdp", "", sessions, "", {}, CAMERA_ANSWERER_ACCEPTED_CMDS), + "SDP string required"); + } + + // ======================================================================== + // 5.2 — executeOfferIceCandidates + // ======================================================================== + + TEST_F(SbmdCameraWebrtcTest, ExecuteOfferIceCandidatesValidArrayProducesSendCommand) + { + std::string sessions = SessionsJson("1", "streaming", 42); + std::string input = R"(["candidate:1 udp 123 192.168.1.1 5000 typ host"])"; + auto result = InvokeExecuteHandler("webrtc", "localIceCandidates", input, sessions); + ExpectSendCommand(result, CL_WEBRTC_TRANSPORT_PROVIDER, CMD_PROVIDE_ICE); + } + + TEST_F(SbmdCameraWebrtcTest, ExecuteOfferIceCandidatesInvalidJsonReturnsError) + { + std::string sessions = SessionsJson("1", "streaming", 42); + ExpectErrorContains(InvokeExecuteHandler("webrtc", "localIceCandidates", "not valid json{", sessions), + "Invalid JSON"); + } + + TEST_F(SbmdCameraWebrtcTest, ExecuteOfferIceCandidatesNotArrayReturnsError) + { + std::string sessions = SessionsJson("1", "streaming", 42); + ExpectErrorContains(InvokeExecuteHandler("webrtc", "localIceCandidates", R"({"not":"array"})", sessions), + "JSON array"); + } + + TEST_F(SbmdCameraWebrtcTest, ExecuteOfferIceCandidatesTlvHasCorrectFields) + { + // ProvideICECandidates (0x0553 cmd 0x05): WebRTCSessionID(0), ICECandidates(1) + std::string sessions = SessionsJson("1", "streaming", 42); + std::string input = R"(["candidate:1 udp 123 192.168.1.1 5000 typ host"])"; + auto result = InvokeExecuteHandler("webrtc", "localIceCandidates", input, sessions); + auto &cmd = ExpectSendCommand(result, CL_WEBRTC_TRANSPORT_PROVIDER, CMD_PROVIDE_ICE); + + std::lock_guard lock(MQuickJsRuntime::GetMutex()); + JSValue decoded = DecodeTlv(cmd.tlvBase64); + + // Tag 0 = WebRTCSessionID (uint16) + JSValue tag0 = JS_GetPropertyUint32(Ctx(), decoded, 0); + ASSERT_FALSE(JS_IsUndefined(tag0)) << "WebRTCSessionID (tag 0) must be present"; + ASSERT_FALSE(JS_IsNull(tag0)) << "WebRTCSessionID must not be null for ICE candidates"; + int32_t sessionId = 0; + JS_ToInt32(Ctx(), &sessionId, tag0); + EXPECT_EQ(sessionId, 42); + + // Tag 1 = ICECandidates (array of ICECandidateStruct) + JSValue tag1 = JS_GetPropertyUint32(Ctx(), decoded, 1); + ASSERT_FALSE(JS_IsUndefined(tag1)) << "ICECandidates (tag 1) must be present"; + // Verify it's an array by checking it has a 'length' property + JSValue len = JS_GetPropertyStr(Ctx(), tag1, "length"); + ASSERT_FALSE(JS_IsUndefined(len)) << "ICECandidates must be an array (has length)"; + int32_t arrLen = 0; + JS_ToInt32(Ctx(), &arrLen, len); + EXPECT_GE(arrLen, 1) << "ICECandidates array must have at least one entry"; + } + + TEST_F(SbmdCameraWebrtcTest, ReproLiveIceCandidates) + { + std::string sessions = SessionsJson("10", "streaming", 10); + std::string input = + R"([)" + R"("candidate:1 1 UDP 2015363327 172.29.0.2 44332 typ host",)" + R"("candidate:2 1 TCP 1015021823 172.29.0.2 9 typ host tcptype active",)" + R"("candidate:3 1 TCP 1010827519 172.29.0.2 51837 typ host tcptype passive",)" + R"("candidate:4 1 UDP 2015363583 fd00:c93:eb1::2 43089 typ host",)" + R"("candidate:5 1 TCP 1015022079 fd00:c93:eb1::2 9 typ host tcptype active",)" + R"("candidate:6 1 TCP 1010827775 fd00:c93:eb1::2 34401 typ host tcptype passive",)" + R"("candidate:7 1 UDP 2015363839 fe80::a4a6:7bff:fe56:592e 50953 typ host",)" + R"("candidate:8 1 TCP 1015022335 fe80::a4a6:7bff:fe56:592e 9 typ host tcptype active",)" + R"("candidate:9 1 TCP 1010828031 fe80::a4a6:7bff:fe56:592e 60109 typ host tcptype passive",)" + R"("")" + R"(])"; + auto result = InvokeExecuteHandler("webrtc", "localIceCandidates", input, sessions); + ASSERT_TRUE(result.has_value()) << "handler returned no result (threw)"; + + if (std::holds_alternative(result->terminal.data)) + { + FAIL() << "handler error: " << std::get(result->terminal.data).message; + } + + ExpectSendCommand(result, CL_WEBRTC_TRANSPORT_PROVIDER, CMD_PROVIDE_ICE); + } + + // Reproduce the live "not a function" failure by forcing garbage collection + // between/around handler invocations. In live, localIceCandidates and + // destroySession run late in a long-running shared context after many GCs; + // if any handler JSValue (or a global it depends on) is collectible, JS_Call + // throws TypeError "not a function" before entering the handler. + TEST_F(SbmdCameraWebrtcTest, IceCandidatesSurvivesGc) + { + std::string sessions = SessionsJson("10", "streaming", 10); + std::string input = R"(["candidate:1 1 UDP 2015363327 172.29.0.2 44332 typ host",""])"; + + // Force GC repeatedly, invoking the handler after each collection. + for (int i = 0; i < 10; i++) + { + { + std::lock_guard lock(MQuickJsRuntime::GetMutex()); + const char *expr = "gc()"; + JSValue v = JS_Eval(Ctx(), expr, strlen(expr), "", JS_EVAL_RETVAL); + ASSERT_FALSE(JS_IsException(v)) << "gc() threw on iteration " << i; + } + + auto result = InvokeExecuteHandler("webrtc", "localIceCandidates", input, sessions); + ASSERT_TRUE(result.has_value()) << "localIceCandidates threw after gc on iteration " << i; + + if (std::holds_alternative(result->terminal.data)) + { + FAIL() << "iteration " << i + << " error: " << std::get(result->terminal.data).message; + } + + ExpectSendCommand(result, CL_WEBRTC_TRANSPORT_PROVIDER, CMD_PROVIDE_ICE); + + auto destroy = InvokeExecuteHandler("camera", "destroySession", "10", sessions); + ASSERT_TRUE(destroy.has_value()) << "destroySession threw after gc on iteration " << i; + } + } + + // ======================================================================== + // 5.3 — EndSession TLV conformance + // ======================================================================== + + TEST_F(SbmdCameraWebrtcTest, DestroySessionTlvHasCorrectEndSessionFields) + { + // EndSession (0x0553 cmd 0x06): WebRTCSessionID(0), Reason(1) + std::string sessions = SessionsJson("1", "streaming", 42); + auto result = InvokeExecuteHandler("camera", "destroySession", "1", sessions); + auto &cmd = ExpectSendCommand(result, CL_WEBRTC_TRANSPORT_PROVIDER, CMD_END_SESSION); + + std::lock_guard lock(MQuickJsRuntime::GetMutex()); + JSValue decoded = DecodeTlv(cmd.tlvBase64); + + // Tag 0 = WebRTCSessionID (uint16) + JSValue tag0 = JS_GetPropertyUint32(Ctx(), decoded, 0); + ASSERT_FALSE(JS_IsUndefined(tag0)) << "WebRTCSessionID (tag 0) must be present"; + ASSERT_FALSE(JS_IsNull(tag0)) << "WebRTCSessionID must not be null for EndSession"; + int32_t sessionId = 0; + JS_ToInt32(Ctx(), &sessionId, tag0); + EXPECT_EQ(sessionId, 42); + + // Tag 1 = Reason (enum8) + JSValue tag1 = JS_GetPropertyUint32(Ctx(), decoded, 1); + ASSERT_FALSE(JS_IsUndefined(tag1)) << "Reason (tag 1) must be present"; + int32_t reason = -1; + JS_ToInt32(Ctx(), &reason, tag1); + EXPECT_GE(reason, 0); + EXPECT_LE(reason, 12) << "Reason must be WebRTCEndReasonEnum (0..12)"; + } + + // ======================================================================== + // 5.4 — Incoming command handlers + // ======================================================================== + + TEST_F(SbmdCameraWebrtcTest, HandleIncomingOfferUpdatesRemoteSdp) + { + std::string sessions = SessionsJson("1", "streaming"); + auto tlv = EncodeTlv("{webRTCSessionID:{tag:0,type:'uint16'}, sdp:{tag:1,type:'string'}}", + "{webRTCSessionID: 42, sdp: 'remote-offer-sdp'}"); + + auto result = + InvokeCommandHandler("handleIncomingOffer", CL_WEBRTC_TRANSPORT_REQUESTOR, CMD_OFFER, tlv, sessions); + + ExpectSuccess(result); + ExpectUpdateResource(*result, "remoteSdp", "remote-offer-sdp"); + } + + TEST_F(SbmdCameraWebrtcTest, HandleIncomingAnswerUpdatesRemoteSdp) + { + std::string sessions = SessionsJson("1", "streaming"); + auto tlv = EncodeTlv("{webRTCSessionID:{tag:0,type:'uint16'}, sdp:{tag:1,type:'string'}}", + "{webRTCSessionID: 99, sdp: 'remote-answer-sdp'}"); + + auto result = + InvokeCommandHandler("handleIncomingAnswer", CL_WEBRTC_TRANSPORT_REQUESTOR, CMD_ANSWER, tlv, sessions); + + ExpectSuccess(result); + ExpectUpdateResource(*result, "remoteSdp", "remote-answer-sdp"); + } + + TEST_F(SbmdCameraWebrtcTest, HandleIncomingAnswerStoresWebRTCSessionID) + { + std::string sessions = SessionsJson("s1", "streaming"); + auto tlv = EncodeTlv("{webRTCSessionID:{tag:0,type:'uint16'}, sdp:{tag:1,type:'string'}}", + "{webRTCSessionID: 77, sdp: 'answer-sdp'}"); + + auto result = + InvokeCommandHandler("handleIncomingAnswer", CL_WEBRTC_TRANSPORT_REQUESTOR, CMD_ANSWER, tlv, sessions); + + ExpectSuccess(result); + + auto *td = FindTransientData(*result, "sessions"); + ASSERT_NE(td, nullptr) << "Expected SetTransientData for sessions"; + EXPECT_TRUE(td->value.find("\"webRTCSessionID\":77") != std::string::npos) + << "Sessions must store camera-allocated webRTCSessionID. Got: " << td->value; + } + + TEST_F(SbmdCameraWebrtcTest, HandleIncomingIceCandidatesUpdatesRemoteCandidates) + { + std::string sessions = SessionsJson("1", "streaming", 42); + auto tlv = EncodeTlv("{webRTCSessionID:{tag:0,type:'uint16'}, ICECandidates:{tag:1,type:'array'}}", + "{webRTCSessionID: 42, ICECandidates: [{0:'candidate:1 udp host', 1:null, 2:null}]}"); + + auto result = InvokeCommandHandler( + "handleIncomingIceCandidates", CL_WEBRTC_TRANSPORT_REQUESTOR, CMD_ICE_CANDIDATES, tlv, sessions); + + ExpectSuccess(result); + ExpectUpdateResource(*result, "remoteIceCandidates"); + } + + TEST_F(SbmdCameraWebrtcTest, HandleIncomingEndEmitsWebrtcErrorEnded) + { + std::string sessions = SessionsJson("1", "streaming", 42); + auto tlv = EncodeTlv("{webRTCSessionID:{tag:0,type:'uint16'}, reason:{tag:1,type:'enum8'}}", + "{webRTCSessionID: 42, reason: 2}"); + + auto result = + InvokeCommandHandler("handleIncomingEndSession", CL_WEBRTC_TRANSPORT_REQUESTOR, CMD_END, tlv, sessions); + + ExpectSuccess(result); + const auto *ur = ExpectUpdateResource(*result, "webrtcError", "ended"); + ASSERT_TRUE(ur->metadata.has_value()); + EXPECT_TRUE(ur->metadata->find("sessionId") != std::string::npos); + EXPECT_TRUE(ur->metadata->find("reason") != std::string::npos); + } + + TEST_F(SbmdCameraWebrtcTest, ExecuteStreamReturnsProtocolAndEntryPoint) + { + std::string sessions = SessionsJson("1", "created"); + auto result = InvokeExecuteHandler("camera", "stream", "1", sessions); + + // The stream execute returns { protocol, entryPoint } rather than emitting a status event. + const auto &value = std::get(result->terminal.data).value; + EXPECT_TRUE(value.find("\"protocol\":\"webrtc\"") != std::string::npos) << "Got: " << value; + EXPECT_TRUE(value.find("/ep/webrtc/r/localSdp") != std::string::npos) << "Got: " << value; + + // It must not emit a sessionStatus event (the abstract endpoint has no such resource). + EXPECT_EQ(FindUpdateResource(*result, "sessionStatus"), nullptr); + } + + TEST_F(SbmdCameraWebrtcTest, HandleVideoStreamAllocateErrorEmitsWebrtcErrorFailed) + { + // Drive the offer flow far enough to capture the VideoStreamAllocate requestCommand, then + // invoke its onError continuation (handleVideoStreamAllocateError) directly. + std::string sessions = SessionsJson("1", "streaming"); + auto offer = + InvokeExecuteHandler("webrtc", "localSdp", "dummy-sdp", sessions, "", {}, CAMERA_ANSWERER_ACCEPTED_CMDS); + auto &alloc = ExpectRequestCommand(offer, CL_CAMERA_AV_STREAM_MGMT, CMD_VIDEO_STREAM_ALLOCATE); + + auto result = InvokeCallback( + alloc.onError, + "({error:{type:'commandError',message:'DYNAMIC_CONSTRAINT_ERROR'}, handlerContext:{sessionId:'1'}})"); + + ExpectSuccess(result); + const auto *ur = ExpectUpdateResource(*result, "webrtcError", "failed"); + ASSERT_TRUE(ur->metadata.has_value()); + EXPECT_TRUE(ur->metadata->find("DYNAMIC_CONSTRAINT_ERROR") != std::string::npos); + } + + TEST_F(SbmdCameraWebrtcTest, HandleProvideOfferErrorEmitsWebrtcErrorFailed) + { + // Drive the offer flow through the allocate response to capture the ProvideOffer + // requestCommand, then invoke its onError (handleProvideOfferError). A requestCommand + // deadline is reported through onError with type 'timeout', so this also covers the + // offer-flow timeout path. + std::string sessions = SessionsJson("1", "streaming"); + auto offer = + InvokeExecuteHandler("webrtc", "localSdp", "dummy-sdp", sessions, "", {}, CAMERA_ANSWERER_ACCEPTED_CMDS); + auto &alloc = ExpectRequestCommand(offer, CL_CAMERA_AV_STREAM_MGMT, CMD_VIDEO_STREAM_ALLOCATE); + + auto allocRespTlv = EncodeTlv("{videoStreamID:{tag:0,type:'uint16'}}", "{videoStreamID: 5}"); + std::string allocRespArgs = "({response:{data:'" + allocRespTlv + + "'}, handlerContext:{sdp:'dummy-sdp', sessionId:'1', " + "sessions:{'1':{state:'streaming',protocol:'webrtc'}}}})"; + auto provide = InvokeCallback(alloc.onResponse, allocRespArgs); + auto &po = ExpectRequestCommand(provide, CL_WEBRTC_TRANSPORT_PROVIDER, CMD_PROVIDE_OFFER); + + auto result = InvokeCallback( + po.onError, + "({error:{type:'timeout',message:'Overall operation deadline exceeded'}, handlerContext:{sessionId:'1'}})"); + + ExpectSuccess(result); + const auto *ur = ExpectUpdateResource(*result, "webrtcError", "failed"); + ASSERT_TRUE(ur->metadata.has_value()); + EXPECT_TRUE(ur->metadata->find("timeout") != std::string::npos); + } + + // ======================================================================== + // 5.4 — executeDestroySession sends EndSession when streaming + // ======================================================================== + + TEST_F(SbmdCameraWebrtcTest, DestroySessionStreamingSendsEndSession) + { + std::string sessions = SessionsJson("1", "streaming", 42); + auto result = InvokeExecuteHandler("camera", "destroySession", "1", sessions); + ExpectSendCommand(result, CL_WEBRTC_TRANSPORT_PROVIDER, CMD_END_SESSION); + + ASSERT_GE(result->ops.size(), 1u); + EXPECT_TRUE(std::holds_alternative(result->ops[0].data)); + } + + TEST_F(SbmdCameraWebrtcTest, DestroySessionCreatedStateDoesNotSendEndSession) + { + std::string sessions = SessionsJson("1", "created"); + auto result = InvokeExecuteHandler("camera", "destroySession", "1", sessions); + + ExpectSuccess(result); + + // No sendCommand ops expected for a non-streaming session + // Just SetTransientData + for (const auto &op : result->ops) + { + EXPECT_FALSE(std::holds_alternative(op.data)) + << "Non-streaming destroy should not update any resources"; + } + } + +} // namespace diff --git a/core/test/src/SbmdDriverTestBase.h b/core/test/src/SbmdDriverTestBase.h new file mode 100644 index 00000000..b2b2a665 --- /dev/null +++ b/core/test/src/SbmdDriverTestBase.h @@ -0,0 +1,362 @@ +//------------------------------ tabstop = 4 ---------------------------------- +// +// If not stated otherwise in this file or this component's LICENSE file the +// following copyright and licenses apply: +// +// Copyright 2026 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// +//------------------------------ tabstop = 4 ---------------------------------- + +/* + * Shared harness for SBMD driver/handler unit tests. + * + * Provides the generic, driver-agnostic scaffolding that every SBMD test needs: + * - the C API stubs (updateResource / setMetadata / deviceServiceSetMetadata) that + * ExecuteOps links against, recording their calls into the g_*Calls vectors + * (defined in SbmdDriverTestSupport.cpp) + * - MQuickJS runtime setup/teardown + * - JS helpers (Ctx / EvalFunc / GetStringProp / GetUint32Prop / EncodeTlv / DecodeTlv) + * - supplement prefetch-and-attach plumbing + * - ParsedResult assertion helpers (ExpectError / ExpectSuccess / ExpectSendCommand / + * ExpectRequestCommand / FindUpdateResource / ExpectUpdateResource / FindTransientData) + * + * Driver-specific concerns (loading a particular .sbmd.js and finding its handlers) live + * in the concrete fixtures that derive from SbmdDriverTestBase. + */ + +#pragma once + +#include "deviceDrivers/matter/sbmd/mquickjs/MQuickJsRuntime.h" +#include "deviceDrivers/matter/sbmd/mquickjs/SbmdBundleLoader.h" +#include "deviceDrivers/matter/sbmd/mquickjs/SbmdHandlerInvoker.h" +#include "deviceDrivers/matter/sbmd/mquickjs/SbmdLoader.h" + +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +} + +namespace barton::test +{ + // ======================================================================== + // Recorded C-API calls (populated by the stubs in SbmdDriverTestSupport.cpp) + // ======================================================================== + struct UpdateResourceCall + { + std::string deviceUuid; + std::string endpointId; + std::string resourceId; + std::string value; + std::string metadata; // JSON string, empty if null + }; + + struct SetMetadataCall + { + std::string deviceUuid; + std::string endpointId; + std::string key; + std::string value; + }; + + struct SetPersistentDataCall + { + std::string uri; + std::string value; + }; + + extern std::vector g_updateResourceCalls; + extern std::vector g_setMetadataCalls; + extern std::vector g_setPersistentDataCalls; + + // ======================================================================== + // Base fixture: generic SBMD test harness + // ======================================================================== + class SbmdDriverTestBase : public ::testing::Test + { + protected: + /** + * Bring up the shared MQuickJS runtime and load the SBMD JS bundle. Concrete + * fixtures call this from their SetUpTestSuite before loading a specific driver. + */ + static void InitRuntime() + { + ASSERT_TRUE(MQuickJsRuntime::Initialize(512 * 1024)); + auto *ctx = MQuickJsRuntime::GetSharedContext(); + ASSERT_NE(ctx, nullptr); + ASSERT_TRUE(SbmdBundleLoader::LoadBundle(ctx)); + ASSERT_TRUE(SbmdLoader::InjectCaptureFunction(ctx)); + } + + static void ShutdownRuntime() { MQuickJsRuntime::Shutdown(); } + + void SetUp() override + { + g_updateResourceCalls.clear(); + g_setMetadataCalls.clear(); + g_setPersistentDataCalls.clear(); + } + + JSContext *Ctx() { return MQuickJsRuntime::GetSharedContext(); } + + HandlerContext MakeContext(const std::string &deviceUuid, + const std::string &endpointId = "1", + const std::map &featureMaps = {}) + { + HandlerContext hctx; + hctx.deviceUuid = deviceUuid; + hctx.endpointId = endpointId; + hctx.clusterFeatureMaps = featureMaps; + + return hctx; + } + + JSValue EvalFunc(const char *expr) { return JS_Eval(Ctx(), expr, strlen(expr), "", JS_EVAL_RETVAL); } + + /** + * Get a string property from a JSValue object ("" if absent/null). + */ + std::string GetStringProp(JSValue obj, const char *name) + { + auto *ctx = Ctx(); + JSValue val = JS_GetPropertyStr(ctx, obj, name); + + if (JS_IsUndefined(val) || JS_IsNull(val)) + { + return ""; + } + + JSCStringBuf buf; + const char *str = JS_ToCString(ctx, val, &buf); + + return str ? std::string(str) : ""; + } + + uint32_t GetUint32Prop(JSValue obj, const char *name) + { + auto *ctx = Ctx(); + JSValue val = JS_GetPropertyStr(ctx, obj, name); + + if (JS_IsUndefined(val)) + { + return 0; + } + + uint32_t result = 0; + JS_ToUint32(ctx, &result, val); + + return result; + } + + /** + * Encode a TLV struct and return the base64 string. + * @param schema JS object literal for the schema, e.g. "{f:{tag:0,type:'uint16'}}" + * @param values JS object literal for the values, e.g. "{f: 42}" + */ + std::string EncodeTlv(const char *schema, const char *values) + { + std::string expr = + std::string("(function(){return Sbmd.Tlv.encodeStruct(") + values + "," + schema + ");})()"; + std::lock_guard lock(MQuickJsRuntime::GetMutex()); + JSValue val = JS_Eval(Ctx(), expr.c_str(), expr.size(), "", JS_EVAL_RETVAL); + EXPECT_FALSE(JS_IsException(val)) << "TLV encode failed for: " << expr; + JSCStringBuf buf; + const char *str = JS_ToCString(Ctx(), val, &buf); + EXPECT_NE(str, nullptr); + + return str ? std::string(str) : std::string(); + } + + /** + * Decode a TLV base64 string and return the JS decoded object. + * Caller must hold MQuickJsRuntime::GetMutex(). + */ + JSValue DecodeTlv(const std::string &tlvBase64) + { + std::string expr = "(function(){ return Sbmd.Tlv.decode('" + tlvBase64 + "'); })()"; + JSValue decoded = JS_Eval(Ctx(), expr.c_str(), expr.size(), "", JS_EVAL_RETVAL); + EXPECT_FALSE(JS_IsException(decoded)) << "TLV decode failed"; + + return decoded; + } + + /** + * Resolve the declared supplements via PrefetchSupplements, then attach them with + * AddSupplements — the full fetch-then-attach path through the two-phase seam. + */ + template + void FetchAndAddSupplements(JSContext *ctx, + SafeJSValue &args, + const SbmdSupplements &supplements, + AttrFetcher attrFetcher, + ResFetcher resFetcher, + PersistFetcher persistFetcher, + TransientFetcher transientFetcher) + { + FetchedSupplements fetched = SbmdHandlerInvoker::PrefetchSupplements( + supplements, attrFetcher, resFetcher, persistFetcher, transientFetcher); + SbmdHandlerInvoker::AddSupplements(ctx, args, supplements, fetched); + } + + // ---- ParsedResult assertion helpers ---- + + /** + * Assert the result is an error with the given message. + */ + void ExpectError(const std::optional &result, const std::string &message) + { + ASSERT_TRUE(result.has_value()); + ASSERT_TRUE(std::holds_alternative(result->terminal.data)); + EXPECT_EQ(std::get(result->terminal.data).message, message); + } + + /** + * Assert the result is an error containing the given substring. + */ + void ExpectErrorContains(const std::optional &result, const std::string &substr) + { + ASSERT_TRUE(result.has_value()); + ASSERT_TRUE(std::holds_alternative(result->terminal.data)); + EXPECT_TRUE(std::get(result->terminal.data).message.find(substr) != + std::string::npos) + << "Expected error containing '" << substr + << "', got: " << std::get(result->terminal.data).message; + } + + /** + * Assert the result is a SendCommand and return the command data. + */ + const ResultTerminal::SendCommand & + ExpectSendCommand(const std::optional &result, uint32_t clusterId, uint32_t commandId) + { + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(std::holds_alternative(result->terminal.data)); + + auto &cmd = std::get(result->terminal.data); + EXPECT_EQ(cmd.clusterId, clusterId); + EXPECT_EQ(cmd.commandId, commandId); + EXPECT_FALSE(cmd.tlvBase64.empty()); + + return cmd; + } + + /** + * Assert the result is a RequestCommand and return the command data. + */ + const ResultTerminal::RequestCommand & + ExpectRequestCommand(const std::optional &result, uint32_t clusterId, uint32_t commandId) + { + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(std::holds_alternative(result->terminal.data)); + + auto &cmd = std::get(result->terminal.data); + EXPECT_EQ(cmd.clusterId, clusterId); + EXPECT_EQ(cmd.commandId, commandId); + + return cmd; + } + + /** + * Find the first UpdateResource op matching the given resource ID, or nullptr. + */ + const ResultOp::UpdateResource *FindUpdateResource(const ParsedResult &result, const std::string &resourceId) + { + for (const auto &op : result.ops) + { + if (std::holds_alternative(op.data)) + { + auto &ur = std::get(op.data); + + if (ur.resource == resourceId) + { + return &ur; + } + } + } + + return nullptr; + } + + /** + * Assert the result is a Success terminal. + */ + void ExpectSuccess(const std::optional &result) + { + ASSERT_TRUE(result.has_value()); + ASSERT_TRUE(std::holds_alternative(result->terminal.data)); + } + + /** + * Assert an UpdateResource op for the given resource exists (on the webrtc endpoint when an + * endpoint is set) and return it. + */ + const ResultOp::UpdateResource *ExpectUpdateResource(const ParsedResult &result, const std::string &resourceId) + { + const auto *ur = FindUpdateResource(result, resourceId); + EXPECT_NE(ur, nullptr) << "Expected updateResource for " << resourceId; + + if (ur != nullptr && ur->endpoint.has_value()) + { + EXPECT_EQ(*ur->endpoint, "webrtc"); + } + + return ur; + } + + /** + * As above, additionally asserting the resource's value. + */ + const ResultOp::UpdateResource * + ExpectUpdateResource(const ParsedResult &result, const std::string &resourceId, const std::string &value) + { + const auto *ur = ExpectUpdateResource(result, resourceId); + + if (ur != nullptr) + { + EXPECT_EQ(ur->value, value); + } + + return ur; + } + + /** + * Find the first SetTransientData op matching the given key, or nullptr. + */ + const ResultOp::SetTransientData *FindTransientData(const ParsedResult &result, const std::string &key) + { + for (const auto &op : result.ops) + { + if (std::holds_alternative(op.data)) + { + auto &td = std::get(op.data); + + if (td.key == key) + { + return &td; + } + } + } + + return nullptr; + } + }; +} // namespace barton::test diff --git a/core/test/src/SbmdDriverTestSupport.cpp b/core/test/src/SbmdDriverTestSupport.cpp new file mode 100644 index 00000000..f1e67607 --- /dev/null +++ b/core/test/src/SbmdDriverTestSupport.cpp @@ -0,0 +1,82 @@ +//------------------------------ tabstop = 4 ---------------------------------- +// +// If not stated otherwise in this file or this component's LICENSE file the +// following copyright and licenses apply: +// +// Copyright 2026 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// +//------------------------------ tabstop = 4 ---------------------------------- + +/* + * Shared implementation for the SBMD test harness: the recording call vectors and the + * C API stubs (updateResource / setMetadata / deviceServiceSetMetadata) that the SBMD + * result executor links against. Linked into every SBMD test executable. + */ + +#include "SbmdDriverTestBase.h" + +extern "C" { +#include +} + +namespace barton::test +{ + std::vector g_updateResourceCalls; + std::vector g_setMetadataCalls; + std::vector g_setPersistentDataCalls; +} // namespace barton::test + +extern "C" { +void updateResource(const char *deviceUuid, + const char *endpointId, + const char *resourceId, + const char *newValue, + void *metadata) +{ + std::string metaStr; + + if (metadata != nullptr) + { + char *printed = cJSON_PrintUnformatted(static_cast(metadata)); + + if (printed != nullptr) + { + metaStr = printed; + free(printed); + } + } + + barton::test::g_updateResourceCalls.push_back({deviceUuid ? deviceUuid : "", + endpointId ? endpointId : "", + resourceId ? resourceId : "", + newValue ? newValue : "", + metaStr}); +} + +void setMetadata(const char *deviceUuid, const char *endpointId, const char *name, const char *value) +{ + barton::test::g_setMetadataCalls.push_back( + {deviceUuid ? deviceUuid : "", endpointId ? endpointId : "", name ? name : "", value ? value : ""}); +} + +bool deviceServiceSetMetadata(const char *uri, const char *value) +{ + barton::test::g_setPersistentDataCalls.push_back({uri ? uri : "", value ? value : ""}); + + return true; +} +} diff --git a/core/test/src/SbmdHandlerInvokerTest.cpp b/core/test/src/SbmdHandlerInvokerTest.cpp index 8e546a96..2f627a55 100644 --- a/core/test/src/SbmdHandlerInvokerTest.cpp +++ b/core/test/src/SbmdHandlerInvokerTest.cpp @@ -26,196 +26,34 @@ * result parsing, and non-terminal op execution. */ -#include "deviceDrivers/matter/sbmd/mquickjs/SbmdHandlerInvoker.h" +#include "SbmdDriverTestBase.h" + #include "deviceDrivers/matter/sbmd/mquickjs/MQuickJsRuntime.h" -#include "deviceDrivers/matter/sbmd/mquickjs/SbmdBundleLoader.h" -#include "deviceDrivers/matter/sbmd/mquickjs/SbmdLoader.h" +#include "deviceDrivers/matter/sbmd/mquickjs/SbmdHandlerInvoker.h" #include #include -#include - -extern "C" { -#include -#include -} using namespace barton; - -// ============================================================================ -// Test stubs for C APIs called by ExecuteOps -// ============================================================================ - -namespace -{ - struct UpdateResourceCall - { - std::string deviceUuid; - std::string endpointId; - std::string resourceId; - std::string value; - std::string metadata; // JSON string, empty if null - }; - - struct SetMetadataCall - { - std::string deviceUuid; - std::string endpointId; - std::string key; - std::string value; - }; - - struct SetPersistentDataCall - { - std::string uri; - std::string value; - }; - - std::vector g_updateResourceCalls; - std::vector g_setMetadataCalls; - std::vector g_setPersistentDataCalls; -} // namespace - -extern "C" { -void updateResource(const char *deviceUuid, - const char *endpointId, - const char *resourceId, - const char *newValue, - void *metadata) -{ - std::string metaStr; - - if (metadata != nullptr) - { - char *printed = cJSON_PrintUnformatted(static_cast(metadata)); - - if (printed != nullptr) - { - metaStr = printed; - free(printed); - } - } - - g_updateResourceCalls.push_back({deviceUuid ? deviceUuid : "", - endpointId ? endpointId : "", - resourceId ? resourceId : "", - newValue ? newValue : "", - metaStr}); -} - -void setMetadata(const char *deviceUuid, const char *endpointId, const char *name, const char *value) -{ - g_setMetadataCalls.push_back( - {deviceUuid ? deviceUuid : "", endpointId ? endpointId : "", name ? name : "", value ? value : ""}); -} - -bool deviceServiceSetMetadata(const char *uri, const char *value) -{ - g_setPersistentDataCalls.push_back({uri ? uri : "", value ? value : ""}); - - return true; -} -} +using namespace barton::test; namespace { - // Test helper mirroring the pre-split AddSupplements(fetchers...) signature: - // resolves the declared supplements via PrefetchSupplements, then attaches - // them with AddSupplements. Keeps these tests exercising the full - // fetch-then-attach path through the two-phase seam. - template - void FetchAndAddSupplements(JSContext *ctx, - SafeJSValue &args, - const SbmdSupplements &supplements, - AttrFetcher attrFetcher, - ResFetcher resFetcher, - PersistFetcher persistFetcher, - TransientFetcher transientFetcher) - { - FetchedSupplements fetched = SbmdHandlerInvoker::PrefetchSupplements( - supplements, attrFetcher, resFetcher, persistFetcher, transientFetcher); - SbmdHandlerInvoker::AddSupplements(ctx, args, supplements, fetched); - } - - class SbmdHandlerInvokerTest : public ::testing::Test + class SbmdHandlerInvokerTest : public SbmdDriverTestBase { protected: - static void SetUpTestSuite() - { - ASSERT_TRUE(MQuickJsRuntime::Initialize(512 * 1024)); - auto *ctx = MQuickJsRuntime::GetSharedContext(); - ASSERT_NE(ctx, nullptr); - ASSERT_TRUE(SbmdBundleLoader::LoadBundle(ctx)); - ASSERT_TRUE(SbmdLoader::InjectCaptureFunction(ctx)); - } + static void SetUpTestSuite() { InitRuntime(); } - static void TearDownTestSuite() { MQuickJsRuntime::Shutdown(); } - - void SetUp() override - { - g_updateResourceCalls.clear(); - g_setMetadataCalls.clear(); - g_setPersistentDataCalls.clear(); - } - - JSContext *Ctx() { return MQuickJsRuntime::GetSharedContext(); } + static void TearDownTestSuite() { ShutdownRuntime(); } HandlerContext MakeContext() { - HandlerContext hctx; - hctx.deviceUuid = "test-device-uuid"; - hctx.endpointId = "1"; - hctx.clusterFeatureMaps = { - {6, 0x01}, - {8, 0x03} - }; - - return hctx; - } - - /** - * Evaluate a JS expression and return it as a function JSValue. - */ - JSValue EvalFunc(const char *expr) - { - auto *ctx = Ctx(); - - return JS_Eval(ctx, expr, strlen(expr), "", JS_EVAL_RETVAL); - } - - /** - * Get a string property from a JSValue object. - */ - std::string GetStringProp(JSValue obj, const char *name) - { - auto *ctx = Ctx(); - JSValue val = JS_GetPropertyStr(ctx, obj, name); - - if (JS_IsUndefined(val) || JS_IsNull(val)) - { - return ""; - } - - JSCStringBuf buf; - const char *str = JS_ToCString(ctx, val, &buf); - - return str ? std::string(str) : ""; - } - - uint32_t GetUint32Prop(JSValue obj, const char *name) - { - auto *ctx = Ctx(); - JSValue val = JS_GetPropertyStr(ctx, obj, name); - - if (JS_IsUndefined(val)) - { - return 0; - } - - uint32_t result = 0; - JS_ToUint32(ctx, &result, val); - - return result; + return SbmdDriverTestBase::MakeContext("test-device-uuid", + "1", + { + {6, 0x01}, + {8, 0x03} + }); } }; diff --git a/docker/Dockerfile b/docker/Dockerfile index 7ab18db4..37fcb27c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -174,7 +174,16 @@ RUN apt-get update && apt-get -y upgrade && DEBIAN_FRONTEND='noninteractive' apt checkinstall \ zlib1g-dev \ socat \ - libyaml-cpp-dev + libyaml-cpp-dev \ + gstreamer1.0-plugins-base \ + gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad \ + gstreamer1.0-plugins-ugly \ + gstreamer1.0-nice \ + libgstreamer1.0-dev \ + libgstreamer-plugins-base1.0-dev \ + libgstreamer-plugins-bad1.0-dev \ + gstreamer1.0-tools # Fake Function Framework (FFF) # @@ -288,12 +297,16 @@ RUN test -n "${MATTER_REF}" || (echo "Error: MATTER_REF build arg is required" > --target linux-x64-thermostat \ --target linux-x64-contact-sensor \ --target linux-x64-chip-tool \ + --target linux-x64-camera \ + --target linux-x64-camera-controller \ build && \ cp out/linux-x64-light-rpc/chip-lighting-app /usr/local/bin && \ cp out/linux-x64-lock/chip-lock-app /usr/local/bin && \ cp out/linux-x64-thermostat/thermostat-app /usr/local/bin && \ cp out/linux-x64-contact-sensor/contact-sensor-app /usr/local/bin && \ cp out/linux-x64-chip-tool/chip-tool /usr/local/bin && \ + cp out/linux-x64-camera/chip-camera-app /usr/local/bin && \ + cp out/linux-x64-camera-controller/chip-camera-controller /usr/local/bin && \ rm -rf out && \ cd /tmp && \ rm -rf /tmp/matter && \ diff --git a/docker/version b/docker/version index 123a39a8..e3d06964 100644 --- a/docker/version +++ b/docker/version @@ -1 +1 @@ -2.14 +2.15 diff --git a/docs/SBMD.md b/docs/SBMD.md index cf6efeee..d214d5d5 100644 --- a/docs/SBMD.md +++ b/docs/SBMD.md @@ -44,7 +44,7 @@ supported device type adds too much friction to the goal of broad device support SBMD addresses this by using textual specification files that map between Matter types and Barton resources, enabling new device type support without rebuilding or -redeploying firmware. Each driver is a single `.sbmd.js` file (schema version 4) +redeploying firmware. Each driver is a single `.sbmd.js` file where the full driver — metadata, resources, and handler logic — is expressed in JavaScript. @@ -147,7 +147,7 @@ Every `.sbmd.js` file has two sections: ```js SbmdDriver({ - schemaVersion: "4.0", + schemaVersion: "5.0", driverVersion: "1.0", name: "...", constants: { ... }, @@ -174,7 +174,7 @@ function myHandler(args) { ... } | Field | Type | Required | Description | |---|---|---|---| -| `schemaVersion` | string | yes | Schema version. Currently `"4.0"`. | +| `schemaVersion` | string | yes | Schema version; validated against the current schema. See `schema/CHANGELOG.md` for the version history. | | `driverVersion` | string | yes | Driver-specific version string. | | `name` | string | yes | Human-readable driver name. | | `constants` | object | yes | Named constants (see [4.2](#42-constants)). | @@ -544,35 +544,26 @@ Attribute handlers process incoming Matter attribute reports from the device. ```js attributeHandlers: { - // Alias form — resolved to cluster + attribute from the aliases section + // Bound via aliases, each resolved to a cluster + attribute from the aliases section. handlerName: { - aliases: string[], // alias names (mutually exclusive with clusterId) - supplements: { ... }, // optional: pre-fetched data - handler: functionRef, // required: handler function - }, - - // Explicit form — cluster + attribute ID(s) specified directly - handlerName: { - clusterId: number, // required: cluster to match - attributeId: number | "*", // single attribute or wildcard - attributeIds: number[], // OR: multiple attributes (mutually exclusive with attributeId) + aliases: string[], // required: alias names to match supplements: { ... }, // optional: pre-fetched data handler: functionRef, // required: handler function }, } ``` -The `aliases` field and `clusterId` + `attributeId`/`attributeIds` fields are -mutually exclusive. When `aliases` is used, the runtime resolves each entry to -its corresponding cluster and attribute from the `aliases` section. The handler -fires for any matching alias. +The runtime resolves each entry in `aliases` to its corresponding cluster and +attribute from the `aliases` section (or to a cluster wildcard when the alias +declares no `attributeId`). The handler fires for any matching alias. **Trigger dispatch**: -- **Single**: `attributeId: ATTR_LOCK_STATE` — fires for one specific attribute. -- **Multiple**: `attributeIds: [ATTR_ACTUATOR_ENABLED, ATTR_DOOR_STATE]` — fires - for any of the listed attributes. The handler is called once per triggering +- **Single**: an alias bound to one `attributeId` (e.g. `lockState`) — fires for + that specific attribute. +- **Multiple**: list several aliases — the handler is called once per triggering attribute change; `args.attribute` identifies which one fired. -- **Wildcard**: `attributeId: "*"` — fires for any attribute on the cluster. +- **Wildcard**: an alias that declares only a `clusterId` (no `attributeId`) — + fires for any attribute on the cluster. When multiple handlers match the same attribute report, all matching handlers fire. More specific handlers (single/multi) fire before wildcard handlers. @@ -583,25 +574,16 @@ Event handlers process incoming Matter events from the device. ```js eventHandlers: { - // Alias form handlerName: { aliases: string[], supplements: { ... }, handler: functionRef, }, - - // Explicit form - handlerName: { - clusterId: number, - eventId: number | "*", - eventIds: number[], - supplements: { ... }, - handler: functionRef, - }, } ``` -Same dispatch rules and aliases/explicit mutual exclusivity as attribute handlers. +Same dispatch rules as attribute handlers: aliases resolve to a cluster + event +(or a cluster wildcard when the alias declares no `eventId`). ### 4.11 Command Handlers @@ -611,25 +593,16 @@ is, commands that are not correlated to a pending `.device.requestCommand()` ```js commandHandlers: { - // Alias form handlerName: { aliases: string[], supplements: { ... }, handler: functionRef, }, - - // Explicit form - handlerName: { - clusterId: number, - commandId: number | "*", - commandIds: number[], - supplements: { ... }, - handler: functionRef, - }, } ``` -Same dispatch rules and aliases/explicit mutual exclusivity as attribute handlers. +Same dispatch rules as attribute handlers: aliases resolve to a cluster + command +(or a cluster wildcard when the alias declares no `commandId`). **Important**: When a command arrives that matches a pending `requestCommand`'s `responseCommandId`, the request's response handler is called instead. Command @@ -1302,7 +1275,7 @@ level control). ```js SbmdDriver({ - schemaVersion: "4.0", + schemaVersion: "5.0", driverVersion: "1.0", name: "Light", @@ -1454,7 +1427,7 @@ but shows the minimum required structure. ```js SbmdDriver({ - schemaVersion: "4.0", + schemaVersion: "5.0", driverVersion: "1.0", name: "Light (Inline)", @@ -1517,7 +1490,7 @@ production drivers. ```js SbmdDriver({ - schemaVersion: "4.0", + schemaVersion: "5.0", driverVersion: "1.0", name: "Light (Minimal)", @@ -1586,13 +1559,13 @@ production driver. Demonstrates: device-level resources, endpoint-scoped resources, aliases and prerequisites with `optional: true`, resource seeding, modes (`static`, -`noEvents`), single/multi/wildcard attribute/event/command handlers (alias and -explicit forms), supplements, persistent and transient data storage, invoke with +`noEvents`), single/multi/wildcard attribute/event/command handlers bound via +aliases, supplements, persistent and transient data storage, invoke with `responseCommandId`, TLV encoding, and feature map inspection. ```js SbmdDriver({ - schemaVersion: "4.0", + schemaVersion: "5.0", driverVersion: "1.0", name: "Door Lock", @@ -1656,10 +1629,31 @@ SbmdDriver({ clusterId: CL_DOOR_LOCK, eventId: EVT_LOCK_OPERATION, }, + doorLockAlarm: { + clusterId: CL_DOOR_LOCK, + eventId: EVT_DOOR_LOCK_ALARM, + }, + lockUserChange: { + clusterId: CL_DOOR_LOCK, + eventId: EVT_LOCK_USER_CHANGE, + }, getCredentialStatusResp: { clusterId: CL_DOOR_LOCK, commandId: CMD_GET_CREDENTIAL_STATUS_RESP, }, + getUserResp: { + clusterId: CL_DOOR_LOCK, + commandId: CMD_GET_USER_RESP, + }, + setCredentialResp: { + clusterId: CL_DOOR_LOCK, + commandId: CMD_SET_CREDENTIAL_RESP, + }, + // An alias with only a clusterId (no attribute/event/command id) is a + // cluster-wide wildcard: it matches any element of that cluster. + doorLockAny: { + clusterId: CL_DOOR_LOCK, + }, }, barton: { @@ -1755,20 +1749,18 @@ SbmdDriver({ handler: handleLockStateAttribute, }, - // Multiple attributes — explicit form, shared handler + // Multiple attributes via aliases, shared handler lockActuator: { - clusterId: CL_DOOR_LOCK, - attributeIds: [ATTR_ACTUATOR_ENABLED, ATTR_DOOR_STATE], + aliases: ["actuatorEnabled", "doorState"], supplements: { resources: [EP_LOCK + "/" + RES_LOCKED], }, handler: handleActuatorAttributes, }, - // Wildcard — catch-all for any attribute on a cluster + // Wildcard — catch-all for any attribute on a cluster (cluster-wide alias) lockDiagnostics: { - clusterId: CL_DOOR_LOCK, - attributeId: "*", + aliases: ["doorLockAny"], handler: handleLockDiagnostics, }, }, @@ -1784,20 +1776,18 @@ SbmdDriver({ handler: handleLockOperation, }, - // Multiple events — explicit form + // Multiple events via aliases lockAlarms: { - clusterId: CL_DOOR_LOCK, - eventIds: [EVT_DOOR_LOCK_ALARM, EVT_LOCK_USER_CHANGE], + aliases: ["doorLockAlarm", "lockUserChange"], handler: handleLockAlarms, supplements: { persistentData: ["alarmCount"], }, }, - // Wildcard + // Wildcard (cluster-wide alias) lockEventCatchAll: { - clusterId: CL_DOOR_LOCK, - eventId: "*", + aliases: ["doorLockAny"], handler: handleLockEventCatchAll, }, }, @@ -1812,17 +1802,15 @@ SbmdDriver({ handler: handleGetCredentialStatusResponse, }, - // Multiple commands — explicit form + // Multiple commands via aliases userCommands: { - clusterId: CL_DOOR_LOCK, - commandIds: [CMD_GET_USER_RESP, CMD_SET_CREDENTIAL_RESP], + aliases: ["getUserResp", "setCredentialResp"], handler: handleUserCommandResponses, }, - // Wildcard + // Wildcard (cluster-wide alias) lockCommandCatchAll: { - clusterId: CL_DOOR_LOCK, - commandId: "*", + aliases: ["doorLockAny"], handler: handleLockCommandCatchAll, }, }, diff --git a/openspec/changes/archive/2026-07-13-webrtc-endpoint-and-camera-stream-command/.openspec.yaml b/openspec/changes/archive/2026-07-13-webrtc-endpoint-and-camera-stream-command/.openspec.yaml new file mode 100644 index 00000000..38f76288 --- /dev/null +++ b/openspec/changes/archive/2026-07-13-webrtc-endpoint-and-camera-stream-command/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-22 diff --git a/openspec/changes/archive/2026-07-13-webrtc-endpoint-and-camera-stream-command/design.md b/openspec/changes/archive/2026-07-13-webrtc-endpoint-and-camera-stream-command/design.md new file mode 100644 index 00000000..8b1f4d5d --- /dev/null +++ b/openspec/changes/archive/2026-07-13-webrtc-endpoint-and-camera-stream-command/design.md @@ -0,0 +1,157 @@ +## Context + +BartonCore's camera SBMD driver (`camera.sbmd.js`) implements the abstract session lifecycle endpoint (`ep/camera`) with `createSession`, `stream`, `destroySession`, and `sessionStatus` resources. When a client executes `stream`, the driver emits a `sessionStatus` event with `nextAction: "/devices//ep/webrtc/r/offerSdp"` — but this endpoint does not yet exist. The signaling exchange cannot proceed. + +The Matter SDK build already provides generated headers for `WebRTCTransportProvider` (0x0553) and `WebRTCTransportRequestor` (0x0554) clusters. The SBMD runtime supports `commandHandlers` for incoming Matter commands, and `device.sendCommand()` for outgoing commands. The infrastructure is ready — we need to wire it together. + +The reference app currently has generic `execResource`/`readResource` commands but no camera-specific workflow. Testing a camera stream requires 6+ manual commands with event monitoring. A dedicated command with integrated media rendering is needed for development and demonstration. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Current Architecture │ +│ │ +│ Client (ref app) Barton (camera.sbmd.js) Camera │ +│ ═══════════════ ═══════════════════════ ══════ │ +│ │ +│ er(createSession) ──────► executeCreateSession() │ +│ ◄── response: "1" (allocates session) │ +│ │ +│ er(stream, "1") ────────► executeStream() │ +│ ◄── sessionStatus event (emits setup + nextAction) │ +│ nextAction: .../ep/webrtc/r/offerSdp │ +│ │ +│ er(offerSdp, sdp) ─────► ??? ep/webrtc DOES NOT EXIST │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## Goals / Non-Goals + +**Goals:** +- Implement the `ep/webrtc` endpoint in the camera SBMD with full signaling resource set +- Map Barton resource executes to outgoing Matter commands (WebRTCTransportProvider cluster) +- Map incoming Matter commands (WebRTCTransportRequestor cluster) to Barton resource events +- Build a reference app `cameraStream` command that orchestrates the full flow with GStreamer media rendering +- Structure the webrtc endpoint code for future extraction to a standalone driver + +**Non-Goals:** +- Adding a WebRTC media stack to Barton core (Barton is signaling-only) +- OpenHome or direct camera endpoint implementation +- STUN/TURN server configuration or NAT traversal beyond what ICE provides +- Multi-stream support or bidirectional audio +- Changes to BCoreClient public API +- Dynamic endpoint registration (future improvement — using static ZAP endpoint for now) +- Production-quality error recovery or session timeout management + +## Decisions + +### D1: WebRTC endpoint colocated in camera.sbmd.js + +**Decision**: Add `ep/webrtc` as a second endpoint in `camera.sbmd.js` rather than creating a separate `webrtc.sbmd.js`. + +**Rationale**: SBMD currently has no cross-driver composition mechanism. The session lifecycle in `ep/camera` and the signaling in `ep/webrtc` share transient data (session state, protocol info). Keeping them in one file allows shared access to session state via transient data supplements. The code is structured with clear separation (grouped constants, dedicated handler functions) so extraction is straightforward when SBMD composition becomes available. + +**Alternatives considered**: +- Separate `webrtc.sbmd.js`: Would require a cross-driver data sharing mechanism that doesn't exist. The SBMD runtime matches one driver per device — a second driver for the same device type isn't supported. +- Native C++ driver for webrtc: Defeats the purpose of SBMD. The signaling is pure data transformation (resource values → TLV commands, TLV commands → resource events) which SBMD handles well. + +### D2: Barton as signaling relay — no media involvement + +**Decision**: Barton's webrtc endpoint relays signaling strings (SDP, ICE) between the client and camera via Matter commands. Barton never instantiates a peer connection or receives media. + +**Rationale**: Barton is a protocol-agnostic device management library. The media consumer varies per deployment (mobile app, cloud service, reference app). Embedding a media stack would couple Barton to a specific rendering environment and add large dependencies (libdatachannel/GStreamer) to the core library. + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ Target Architecture │ +│ │ +│ Ref App (WebRTC peer) Barton (signaling relay) Camera │ +│ ═════════════════════ ════════════════════════ ══════ │ +│ GStreamer + webrtcbin camera.sbmd.js Matter │ +│ │ +│ 1. er(createSession) ────► allocate session ────────────────────────── │ +│ 2. er(stream, sid) ──────► emit sessionStatus(setup, webrtc, offerSdp) │ +│ 3. [create local PC] │ +│ [generate SDP offer] │ +│ 4. er(offerSdp, sdp) ───► sendCommand(0x0553, ProvideOffer, {sdp}) ──► │ +│ ◄── Offer cmd (0x0554, {sdp_answer}) ───── │ +│ 5. ◄── remoteSdp event ─── updateResource(webrtc, remoteSdp, answer) │ +│ [set remote SDP] │ +│ 6. er(offerIce, [...]) ──► sendCommand(0x0553, ProvideICE, {cands}) ──► │ +│ ◄── ICECandidates cmd (0x0554, {cands}) ── │ +│ 7. ◄── remoteIce event ── updateResource(webrtc, remoteIce, cands) │ +│ [add remote ICE] │ +│ 8. [media flows P2P] ◄═══════════════════════════════════════════════► │ +│ 9. er(destroySession) ──► sendCommand(0x0553, EndSession) ───────────► │ +│ │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +**Thread safety**: All SBMD handler invocations run under `MQuickJsRuntime::GetMutex()`. The `device.sendCommand()` result terminal marshals to the Matter event loop. Incoming commands from Matter arrive via `g_main_context_invoke` before dispatching to SBMD. No additional synchronization needed. + +### D2b: WebRTCTransportRequestor cluster added to ZAP endpoint + +**Decision**: Add the `WebRTCTransportRequestor` cluster (0x0554) as a server cluster on Barton's existing endpoint in `barton-library.matter` and `barton-library.zap`. Handle its commands (Offer, Answer, ICECandidates, End) via the existing `CommandHandlerInterfaceRegistry` mechanism. + +**Rationale**: When Barton sends `ProvideOffer` to the camera, it includes an `originatingEndpointID`. The camera sends signaling commands (Offer, ICECandidates, End) back to that endpoint. For the camera to accept this endpoint as a valid target, Barton must advertise cluster 0x0554 in its Descriptor cluster's server list. Without this, cameras may reject the target or fail to route the command. + +**Alternatives considered**: +- Dynamic endpoint registration at runtime (like Matter SDK's `WebRTCTransportRequestorManager`): Cleaner long-term, but Barton has no existing dynamic endpoint infrastructure. Adding it is a separate effort. +- No ZAP change, rely on `CommandHandlerInterface` wildcard: The wildcard intercepts commands regardless of target endpoint, but the camera may refuse to send to an endpoint that doesn't advertise the cluster. Unreliable. + +### D3: Reference app uses GStreamer webrtcbin — no Matter or libdatachannel dependency + +**Decision**: The reference app uses GStreamer's `webrtcbin` element as its WebRTC peer. It does NOT use Matter's `WebRTCClient` class or link libdatachannel directly. + +**Rationale**: The reference app is a Barton client — it talks exclusively through `BCoreClient` APIs. Using Matter's WebRTC classes would violate the abstraction boundary. GStreamer `webrtcbin` provides a complete WebRTC stack (SDP generation, ICE, DTLS/SRTP, media decode) in one element, and the reference app already lives in GLib/GObject land. No bridging or separate UDP forwarding needed. + +**GStreamer pipeline structure**: +``` +webrtcbin name=webrtc + → decodebin → videoconvert → autovideosink (display mode) + → decodebin → x264enc → mp4mux → filesink (file mode) +``` + +### D4: Command naming — `cameraStream` / `cs` + +**Decision**: The reference app command is named `cameraStream` (short: `cs`), not generic `stream`. + +**Rationale**: "stream" is too ambiguous — Barton may stream data/media from non-camera devices in the future. `cameraStream` clearly indicates camera video streaming. Follows existing reference app naming: `discoverStart`/`dstart`, `printDevice`/`pd`, `readResource`/`rr`. + +**Usage**: +``` +cameraStream [--file ] +``` +Default: display output. If `--file` specified: record to file. If display unavailable and no `--file`: error with guidance. + +### D5: Signaling flow maps to Matter WebRTC clusters + +**Decision**: Map Barton resource operations to specific Matter cluster commands: + +| Barton operation | Direction | Matter cluster | Command | ID | +|---|---|---|---|---| +| execute `offerSdp` | Barton → Camera | WebRTCTransportProvider (0x0553) | ProvideOffer | 0x02 | +| execute `offerIceCandidates` | Barton → Camera | WebRTCTransportProvider (0x0553) | ProvideICECandidates | 0x05 | +| event `remoteSdp` | Camera → Barton | WebRTCTransportRequestor (0x0554) | Offer | 0x00 | +| event `remoteIceCandidates` | Camera → Barton | WebRTCTransportRequestor (0x0554) | ICECandidates | 0x02 | +| execute `destroySession` | Barton → Camera | WebRTCTransportProvider (0x0553) | EndSession | 0x06 | + +**Rationale**: These mappings follow from the Matter 1.5 camera specification. `SolicitOffer` (0x00) is used when the controller wants the camera to generate an offer — but in our flow, the client (reference app) generates the offer and passes it through Barton, so `ProvideOffer` is the primary path. + +### D6: SDP and ICE payloads are opaque strings in Barton resources + +**Decision**: SDP offers/answers are stored as plain string resource values. ICE candidates are JSON-encoded arrays of candidate strings. Barton does not parse, validate, or transform these payloads. + +**Rationale**: Barton is a relay. SDP and ICE are negotiated between the actual WebRTC peers (client and camera). Parsing them would add fragile protocol-version-specific logic to the service layer with no benefit. The SBMD handler simply packages the string into TLV for the Matter command and unpacks TLV back to a string for the event. + +## Risks / Trade-offs + +- **[TLV encoding complexity in SBMD]** → The SBMD `device.sendCommand()` requires TLV-encoded payloads. SDP strings and ICE candidate lists must be serialized to Matter TLV format from JavaScript. Mitigation: Use `Sbmd.tlv` helpers already available in the runtime (base64 TLV encoding via `sbmd-tlv.js`). +- **[GStreamer availability on target platforms]** → The `cameraStream` command requires GStreamer + webrtcbin. Not all deployments will have this. Mitigation: Gate behind `BCORE_CAMERA_STREAM` CMake flag; command prints clear error if GStreamer unavailable at runtime. +- **[Session correlation between endpoints]** → The webrtc endpoint needs to know which session is active to correlate signaling. Mitigation: The session is stored in transient data; webrtc execute handlers read session state via supplements. +- **[No SolicitOffer support initially]** → Some cameras may expect the controller to call `SolicitOffer` first. The initial implementation uses `ProvideOffer` (client generates offer). Mitigation: Can add `SolicitOffer` path later as an alternative flow triggered by a flag or automatic detection. + +## Open Questions + +1. **Should `cameraStream` support a `--stun` flag for specifying a STUN server URL?** webrtcbin supports this via the `stun-server` property. Likely useful for testing across network boundaries but not needed for local dev. +2. **How should the reference app handle `sessionStatus: "error"` events during streaming?** Options: immediate teardown with error message, or retry logic. Leaning toward simple teardown for MVP. diff --git a/openspec/changes/archive/2026-07-13-webrtc-endpoint-and-camera-stream-command/proposal.md b/openspec/changes/archive/2026-07-13-webrtc-endpoint-and-camera-stream-command/proposal.md new file mode 100644 index 00000000..633fe99c --- /dev/null +++ b/openspec/changes/archive/2026-07-13-webrtc-endpoint-and-camera-stream-command/proposal.md @@ -0,0 +1,36 @@ +## Why + +The camera SBMD driver defines an abstract session lifecycle (createSession, stream, destroySession, sessionStatus) but has no protocol-specific endpoint to carry WebRTC signaling. Clients currently have no way to exchange SDP offers/answers or ICE candidates through Barton's resource model. Additionally, the reference app has no camera-specific command — testing camera streams requires manual multi-step `execResource` calls with no media rendering. + +## What Changes + +- **WebRTC endpoint in camera SBMD**: Add an `ep/webrtc` endpoint (profile: `"webrtc"`) to `camera.sbmd.js` with four resources — `offerSdp` [execute], `remoteSdp` [events], `offerIceCandidates` [execute], `remoteIceCandidates` [events]. Execute handlers send Matter commands to the camera's `WebRTCTransportProvider` cluster (0x0553). SBMD command handlers receive incoming signaling from the camera's `WebRTCTransportRequestor` cluster (0x0554) and emit events on the protocol endpoint resources. +- **Reference app `cameraStream` command**: A convenience orchestrator (`cameraStream` / `cs`) that automates the full camera streaming flow — session creation, WebRTC signaling via Barton's resource API, and media rendering via a GStreamer `webrtcbin` pipeline. Supports display output (autovideosink) and file recording (filesink). Barton remains a signaling relay; the reference app is the WebRTC peer and media consumer. +- **Separability by design**: The webrtc endpoint code is structured within `camera.sbmd.js` so it can be extracted to a standalone SBMD driver in the future without breaking the contract. + +## Non-goals + +- Media stack in Barton core — Barton relays signaling only, never receives media +- OpenHome or direct camera endpoint implementation (separate future work) +- STUN/TURN server integration +- Multi-stream or bidirectional audio support +- Changes to the public C API (BCoreClient already supports executeResource and event subscriptions) + +## Capabilities + +### New Capabilities +- `webrtc-signaling-endpoint`: The WebRTC protocol-specific endpoint (ep/webrtc) with resources for SDP and ICE exchange, SBMD command handlers for incoming Matter signaling, and execute handlers for outgoing Matter commands. Designed as a reusable pattern for any device type that uses WebRTC transport. +- `camera-stream-reference-command`: The reference app `cameraStream` command that orchestrates session lifecycle, WebRTC signaling through Barton, and media rendering via GStreamer webrtcbin. Demonstrates end-to-end camera streaming without direct Matter SDK or WebRTC library coupling in the client. + +### Modified Capabilities +_(none — no existing spec-level requirements are changing)_ + +## Impact + +- **SBMD driver**: `core/deviceDrivers/matter/sbmd/specs/camera.sbmd.js` — new endpoint, resources, constants, command handlers, execute handlers +- **Reference app**: `reference/src/` — new `cameraCategory.c/.h` for the `cameraStream` command, GStreamer pipeline management, event subscription handling +- **Reference app build**: `reference/CMakeLists.txt` — link against GStreamer (gstreamer-1.0, gstreamer-webrtc-1.0, gstreamer-sdp-1.0) +- **Docker image**: `gstreamer1.0-tools` and `libgstreamer-plugins-bad1.0-dev` added (version 2.12) +- **CMake flags**: Gated behind `BCORE_MATTER` (webrtc endpoint is Matter-only); reference app GStreamer support gated behind a new `BCORE_CAMERA_STREAM` flag +- **ZAP changes**: Add `WebRTCTransportRequestor` cluster (0x0554) as a server cluster on Barton's endpoint in `barton-library.matter` / `barton-library.zap`. The camera needs to send signaling commands (Offer, Answer, ICECandidates, End) back to Barton — this requires Barton to advertise the cluster so the camera knows where to target those commands. +- **Matter SDK headers used**: `WebRTCTransportProvider/CommandIds.h`, `WebRTCTransportRequestor/CommandIds.h` for cluster/command ID constants (already in build/matter-install) diff --git a/openspec/changes/archive/2026-07-13-webrtc-endpoint-and-camera-stream-command/specs/camera-stream-reference-command/spec.md b/openspec/changes/archive/2026-07-13-webrtc-endpoint-and-camera-stream-command/specs/camera-stream-reference-command/spec.md new file mode 100644 index 00000000..f85ac1e1 --- /dev/null +++ b/openspec/changes/archive/2026-07-13-webrtc-endpoint-and-camera-stream-command/specs/camera-stream-reference-command/spec.md @@ -0,0 +1,117 @@ +## ADDED Requirements + +### Requirement: cameraStream command exists in reference app + +The reference app SHALL provide a command named `cameraStream` with short alias `cs` in a dedicated camera command category. The command SHALL accept a device ID as a required argument and an optional `--file ` flag for recording. + +#### Scenario: Command appears in help +- **WHEN** a user types `help` in the reference app +- **THEN** the camera category SHALL list `cameraStream` with usage: ` [--file ]` + +#### Scenario: Command with short alias +- **WHEN** a user types `cs ` +- **THEN** the command SHALL execute identically to `cameraStream ` + +### Requirement: cameraStream orchestrates full session lifecycle + +The `cameraStream` command SHALL orchestrate the complete camera streaming flow through Barton's resource API: +1. Execute `createSession` on the device's `ep/camera` endpoint +2. Execute `stream` with the returned sessionId +3. Wait for `sessionStatus` event with status `"setup"` and extract `nextAction` +4. Create a local GStreamer webrtcbin peer connection +5. Generate a local SDP offer from webrtcbin +6. Execute `offerSdp` on the device's `ep/webrtc` endpoint with the local SDP +7. Wait for `remoteSdp` event and set the remote description on webrtcbin +8. Exchange ICE candidates (local → `offerIceCandidates`, remote ← `remoteIceCandidates` events) +9. Wait for media to flow (peer connection state: connected) +10. On user interrupt (Ctrl+C or `q`): execute `destroySession` and tear down the pipeline + +#### Scenario: Successful camera stream to display +- **WHEN** user executes `cameraStream ` and the camera responds to signaling +- **THEN** the reference app SHALL display video output via GStreamer autovideosink and print status messages for each step + +#### Scenario: Successful camera stream to file +- **WHEN** user executes `cameraStream --file recording.mp4` +- **THEN** the reference app SHALL record the video stream to the specified file path + +#### Scenario: User stops the stream +- **WHEN** user presses Ctrl+C or types `q` during an active stream +- **THEN** the reference app SHALL execute `destroySession`, stop the GStreamer pipeline, and return to the command prompt + +### Requirement: cameraStream uses only BCoreClient API for signaling + +The `cameraStream` command SHALL interact with Barton exclusively through `BCoreClient` APIs (`b_core_client_execute_resource`, event subscriptions). It SHALL NOT use Matter SDK APIs, link against Matter libraries, or reference Matter-specific types. + +#### Scenario: No Matter SDK dependency +- **WHEN** the reference app is compiled +- **THEN** the camera stream module SHALL compile without any Matter SDK headers in its include path + +### Requirement: cameraStream uses GStreamer webrtcbin for media + +The `cameraStream` command SHALL use GStreamer's `webrtcbin` element as its local WebRTC peer connection. webrtcbin handles SDP generation, ICE gathering, DTLS/SRTP negotiation, and media decoding. + +#### Scenario: GStreamer pipeline for display +- **WHEN** `cameraStream` is invoked without `--file` +- **THEN** a GStreamer pipeline SHALL be created with `webrtcbin` connected to `decodebin` and `autovideosink` + +#### Scenario: GStreamer pipeline for file recording +- **WHEN** `cameraStream` is invoked with `--file ` +- **THEN** a GStreamer pipeline SHALL be created with `webrtcbin` connected to appropriate muxing and `filesink` elements + +#### Scenario: GStreamer not available +- **WHEN** GStreamer libraries are not found at runtime +- **THEN** the command SHALL print an error message explaining that GStreamer with webrtcbin is required and exit gracefully + +### Requirement: cameraStream subscribes to Barton events + +The `cameraStream` command SHALL subscribe to resource events on the device to receive signaling data asynchronously. Specifically: +- `sessionStatus` events on `ep/camera` (for session state transitions) +- `remoteSdp` events on `ep/webrtc` (for the camera's SDP answer) +- `remoteIceCandidates` events on `ep/webrtc` (for the camera's ICE candidates) + +#### Scenario: Remote SDP delivered via event +- **WHEN** the camera responds with an SDP answer +- **THEN** the reference app SHALL receive it as a `remoteSdp` event and feed it to webrtcbin as the remote description + +#### Scenario: Remote ICE candidates delivered via events +- **WHEN** the camera sends ICE candidates +- **THEN** the reference app SHALL receive them as `remoteIceCandidates` events and add each candidate to webrtcbin + +### Requirement: cameraStream reports progress to user + +The command SHALL emit human-readable progress messages to stdout at each stage of the flow: +- Session created (sessionId) +- Streaming initiated (protocol, nextAction) +- SDP offer sent +- SDP answer received +- ICE candidates exchanged +- Media flowing / connected +- Stream ended (reason) + +#### Scenario: Progress output during successful stream +- **WHEN** `cameraStream` completes signaling and media begins flowing +- **THEN** the user SHALL see step-by-step status messages indicating progress through the flow + +### Requirement: cameraStream handles errors gracefully + +The command SHALL handle failures at any stage (session creation failure, signaling timeout, peer connection failure) by printing an error message, cleaning up any partial state (destroying the session if created), and returning to the command prompt. + +#### Scenario: Device does not support camera streaming +- **WHEN** `cameraStream` is executed on a device without a `camera` endpoint +- **THEN** the command SHALL print an error and exit without crashing + +#### Scenario: Signaling timeout +- **WHEN** the camera does not respond to signaling within a reasonable timeout +- **THEN** the command SHALL print a timeout error, destroy the session, and exit + +### Requirement: Camera command category is gated by CMake flag + +The camera stream command and its GStreamer dependencies SHALL be gated behind a `BCORE_CAMERA_STREAM` CMake option (default OFF). When disabled, the reference app builds without GStreamer dependencies and without the camera category. + +#### Scenario: Build without camera stream support +- **WHEN** `BCORE_CAMERA_STREAM=OFF` (default) +- **THEN** the reference app SHALL build successfully without GStreamer development libraries + +#### Scenario: Build with camera stream support +- **WHEN** `BCORE_CAMERA_STREAM=ON` +- **THEN** the reference app SHALL link against gstreamer-1.0, gstreamer-webrtc-1.0, gstreamer-sdp-1.0 and include the camera category diff --git a/openspec/changes/archive/2026-07-13-webrtc-endpoint-and-camera-stream-command/specs/webrtc-signaling-endpoint/spec.md b/openspec/changes/archive/2026-07-13-webrtc-endpoint-and-camera-stream-command/specs/webrtc-signaling-endpoint/spec.md new file mode 100644 index 00000000..93265168 --- /dev/null +++ b/openspec/changes/archive/2026-07-13-webrtc-endpoint-and-camera-stream-command/specs/webrtc-signaling-endpoint/spec.md @@ -0,0 +1,107 @@ +## ADDED Requirements + +### Requirement: WebRTC endpoint declares signaling resources + +The camera SBMD driver SHALL declare an endpoint with id `"webrtc"` and profile `"webrtc"` containing four resources: + +| Resource | Type | Modes | Purpose | +|----------|------|-------|---------| +| `offerSdp` | `function` | execute | Client sends local SDP offer | +| `remoteSdp` | `string` | [] (events only) | Delivers remote SDP answer to client | +| `offerIceCandidates` | `function` | execute | Client sends local ICE candidates | +| `remoteIceCandidates` | `string` | [] (events only) | Delivers remote ICE candidates to client | + +The endpoint SHALL be declared within the same `camera.sbmd.js` file as the `ep/camera` endpoint. + +#### Scenario: Endpoint appears on commissioned camera device +- **WHEN** a Matter camera device (deviceType 0x0142) with WebRTCTransportProvider cluster (0x0553) is commissioned +- **THEN** the device SHALL have an endpoint with id `"webrtc"`, profile `"webrtc"`, and all four resources registered + +#### Scenario: Event-only resources are not readable +- **WHEN** a client attempts to read `remoteSdp` or `remoteIceCandidates` +- **THEN** the read SHALL fail or return no value (modes list is empty — no read mode) + +### Requirement: offerSdp execute sends ProvideOffer to camera + +The `offerSdp` execute handler SHALL send a `ProvideOffer` command (ID 0x02) to the camera's `WebRTCTransportProvider` cluster (0x0553). The execute input is the client's SDP offer string. The handler SHALL package the SDP into the command's TLV payload along with the active session's `webRTCSessionID`. + +#### Scenario: Client provides SDP offer +- **WHEN** a client executes `offerSdp` with a valid SDP string as input +- **THEN** the SBMD handler SHALL send a `ProvideOffer` command to the camera with the SDP in the `sdp` field and the Matter-side webRTCSessionID in the `webRTCSessionID` field + +#### Scenario: No active session +- **WHEN** a client executes `offerSdp` but no session is in `streaming` state +- **THEN** the handler SHALL return an error result + +### Requirement: offerIceCandidates execute sends ProvideICECandidates to camera + +The `offerIceCandidates` execute handler SHALL send a `ProvideICECandidates` command (ID 0x05) to the camera's `WebRTCTransportProvider` cluster (0x0553). The execute input is a JSON-encoded array of ICE candidate strings. + +#### Scenario: Client provides ICE candidates +- **WHEN** a client executes `offerIceCandidates` with a JSON array of ICE candidate strings +- **THEN** the SBMD handler SHALL send a `ProvideICECandidates` command to the camera with the candidates in the `ICECandidates` field + +### Requirement: Incoming Offer command emits remoteSdp event + +The SBMD driver SHALL register a command handler for the `Offer` command (ID 0x00) on the `WebRTCTransportRequestor` cluster (0x0554). When received, the handler SHALL extract the SDP string and emit it as an event on the `remoteSdp` resource of the `webrtc` endpoint. + +#### Scenario: Camera sends SDP answer +- **WHEN** the camera sends an `Offer` command (cluster 0x0554, command 0x00) containing an SDP string +- **THEN** the SBMD handler SHALL call `updateResource('webrtc', 'remoteSdp', sdpString)` to emit an event to subscribed clients + +### Requirement: Incoming ICECandidates command emits remoteIceCandidates event + +The SBMD driver SHALL register a command handler for the `ICECandidates` command (ID 0x02) on the `WebRTCTransportRequestor` cluster (0x0554). When received, the handler SHALL extract the candidate list and emit it as a JSON-encoded array on the `remoteIceCandidates` resource. + +#### Scenario: Camera sends ICE candidates +- **WHEN** the camera sends an `ICECandidates` command (cluster 0x0554, command 0x02) containing ICE candidates +- **THEN** the SBMD handler SHALL call `updateResource('webrtc', 'remoteIceCandidates', jsonCandidates)` to emit an event to subscribed clients + +### Requirement: Incoming End command emits sessionStatus error + +The SBMD driver SHALL register a command handler for the `End` command (ID 0x03) on the `WebRTCTransportRequestor` cluster (0x0554). When received, the handler SHALL emit a `sessionStatus` event with value `"error"` and metadata containing the session ID and error reason. + +#### Scenario: Camera ends session +- **WHEN** the camera sends an `End` command with a reason code +- **THEN** the SBMD handler SHALL emit a `sessionStatus` event with value `"error"` and metadata `{ "sessionId": "", "error": "" }` + +### Requirement: destroySession sends EndSession to camera + +When the camera session endpoint's `destroySession` is executed for a session that has progressed to WebRTC signaling, the handler SHALL send an `EndSession` command (ID 0x06) to the camera's `WebRTCTransportProvider` cluster (0x0553) before cleaning up local session state. + +#### Scenario: Client destroys active streaming session +- **WHEN** a client executes `destroySession` for a session in `streaming` state +- **THEN** the handler SHALL send `EndSession` to the camera AND remove the session from transient data + +#### Scenario: Client destroys session that never started streaming +- **WHEN** a client executes `destroySession` for a session in `created` state (never executed `stream`) +- **THEN** the handler SHALL only remove the session from transient data (no Matter command needed) + +### Requirement: WebRTC constants use correct Matter cluster and command IDs + +The SBMD driver SHALL define constants for all WebRTC cluster and command identifiers: + +| Constant | Value | Description | +|----------|-------|-------------| +| CL_WEBRTC_TRANSPORT_PROVIDER | 0x0553 | Camera's provider cluster | +| CL_WEBRTC_TRANSPORT_REQUESTOR | 0x0554 | Barton's requestor cluster | +| CMD_PROVIDE_OFFER | 0x02 | Send SDP offer to camera | +| CMD_PROVIDE_ANSWER | 0x04 | Send SDP answer to camera | +| CMD_PROVIDE_ICE | 0x05 | Send ICE candidates to camera | +| CMD_END_SESSION | 0x06 | End a WebRTC session | +| CMD_OFFER | 0x00 | Incoming offer from camera | +| CMD_ANSWER | 0x01 | Incoming answer from camera | +| CMD_ICE_CANDIDATES | 0x02 | Incoming ICE from camera | +| CMD_END | 0x03 | Incoming end from camera | + +#### Scenario: Constants match Matter specification +- **WHEN** the SBMD driver is loaded +- **THEN** all cluster and command ID constants SHALL match the values defined in the Matter 1.5 WebRTC Transport cluster specification + +### Requirement: WebRTC endpoint is separable by design + +The webrtc endpoint resources, constants, and handler functions SHALL be grouped together and access session state only through transient data supplements. No direct coupling between camera endpoint handlers and webrtc endpoint handlers beyond shared transient data keys. + +#### Scenario: Code organization supports extraction +- **WHEN** the webrtc endpoint code is reviewed +- **THEN** all webrtc-specific constants, resources, and handlers SHALL be identifiable as a cohesive group that could be moved to a separate file with only transient data key sharing as the interface diff --git a/openspec/changes/archive/2026-07-13-webrtc-endpoint-and-camera-stream-command/tasks.md b/openspec/changes/archive/2026-07-13-webrtc-endpoint-and-camera-stream-command/tasks.md new file mode 100644 index 00000000..910af08e --- /dev/null +++ b/openspec/changes/archive/2026-07-13-webrtc-endpoint-and-camera-stream-command/tasks.md @@ -0,0 +1,54 @@ +## 1. ZAP — Add WebRTCTransportRequestor Cluster + +- [x] 1.1 Add `WebRTCTransportRequestor` cluster definition (0x0554) with commands (Offer, Answer, ICECandidates, End) to `barton-library.matter` +- [x] 1.2 Add the cluster as a server on Barton's endpoint in the ZAP endpoint configuration section of `barton-library.matter`, handle commands (Offer, Answer, ICECandidates, End) +- [x] 1.3 Regenerate ZAP artifacts (`barton-library.zap` if needed) and verify the Matter build compiles with the new cluster + +## 2. SBMD WebRTC Endpoint — Constants and Resource Declarations + +- [x] 2.1 Add WebRTC cluster and command ID constants to camera.sbmd.js (CL_WEBRTC_TRANSPORT_PROVIDER, CL_WEBRTC_TRANSPORT_REQUESTOR, CMD_PROVIDE_OFFER, CMD_PROVIDE_ICE, CMD_END_SESSION, CMD_OFFER, CMD_ICE_CANDIDATES, CMD_END, etc.) +- [x] 2.2 Declare the `webrtc` endpoint in the `endpoints` block with profile `"webrtc"`, resources: `offerSdp` (function/execute), `remoteSdp` (string, modes:[]), `offerIceCandidates` (function/execute), `remoteIceCandidates` (string, modes:[]) +- [x] 2.3 Add `CL_WEBRTC_TRANSPORT_REQUESTOR` to the `featureClusters` array in the matter config (enables incoming command handler registration) + +## 3. SBMD WebRTC Endpoint — Execute Handlers + +- [x] 3.1 Implement `executeOfferSdp` handler: validate active session in transient data, extract SDP from input, build TLV payload for ProvideOffer command (webRTCSessionID + sdp + streamUsage + originatingEndpointID), return `device.sendCommand(CL_WEBRTC_TRANSPORT_PROVIDER, CMD_PROVIDE_OFFER, tlv)` +- [x] 3.2 Implement `executeOfferIceCandidates` handler: parse JSON array of ICE candidate strings from input, build TLV payload for ProvideICECandidates command (webRTCSessionID + ICECandidates array), return `device.sendCommand(CL_WEBRTC_TRANSPORT_PROVIDER, CMD_PROVIDE_ICE, tlv)` +- [x] 3.3 Update `executeDestroySession` to send EndSession command (CMD_END_SESSION) to the camera when session state is `streaming` before removing session from transient data + +## 4. SBMD WebRTC Endpoint — Command Handlers (Incoming from Camera) + +- [x] 4.1 Add `commandHandlers` block to the SbmdDriver registration with aliases for cluster 0x0554 commands (Offer, ICECandidates, End) +- [x] 4.2 Implement `handleIncomingOffer` command handler: decode TLV to extract SDP string, call `updateResource('webrtc', 'remoteSdp', sdp)`, store the Matter webRTCSessionID in transient data for correlation +- [x] 4.3 Implement `handleIncomingIceCandidates` command handler: decode TLV to extract ICE candidate list, JSON-encode, call `updateResource('webrtc', 'remoteIceCandidates', jsonCandidates)` +- [x] 4.4 Implement `handleIncomingEnd` command handler: extract reason code, emit `sessionStatus` error event with metadata `{sessionId, error}`, clean up session state + +## 5. SBMD WebRTC Endpoint — Unit Testing + +- [x] 5.1 Write unit tests for `executeOfferSdp`: valid session produces sendCommand result, missing session returns error, missing input returns error +- [x] 5.2 Write unit tests for `executeOfferIceCandidates`: valid JSON array produces sendCommand, invalid JSON returns error +- [x] 5.3 Write unit tests for incoming command handlers: verify updateResource calls with correct endpoint/resource/value for Offer, ICECandidates, and End commands +- [x] 5.4 Write unit test for `executeDestroySession` with streaming session: verify EndSession command is sent before cleanup + +## 6. Reference App — Build System and Category Setup + +- [x] 6.1 Add `BCORE_CAMERA_STREAM` CMake option (default OFF) to `reference/CMakeLists.txt` +- [x] 6.2 When `BCORE_CAMERA_STREAM=ON`: find GStreamer packages (gstreamer-1.0, gstreamer-webrtc-1.0, gstreamer-sdp-1.0), add to link dependencies, define `HAVE_CAMERA_STREAM` compile definition +- [x] 6.3 Create `cameraCategory.h` / `cameraCategory.c` with `buildCameraCategory()` returning a Category with `cameraStream` / `cs` command +- [x] 6.4 Register camera category in the reference app's main category list (gated by `#ifdef HAVE_CAMERA_STREAM`) + +## 7. Reference App — GStreamer WebRTC Pipeline + +- [x] 7.1 Create `cameraStreamPipeline.h` / `cameraStreamPipeline.c` — GStreamer pipeline management: create pipeline with webrtcbin, extract local SDP offer, set remote SDP, add ICE candidates, connect to autovideosink or filesink based on mode +- [x] 7.2 Implement SDP offer extraction: connect to webrtcbin's `on-negotiation-needed` signal, call `create-offer` action, extract SDP string from the local description +- [x] 7.3 Implement remote SDP handling: parse SDP answer string into GstWebRTCSessionDescription, call `set-remote-description` on webrtcbin +- [x] 7.4 Implement ICE candidate handling: connect to `on-ice-candidate` signal for local candidates, provide `add-ice-candidate` for remote candidates +- [x] 7.5 Implement pipeline teardown: stop pipeline, free resources, handle GStreamer state changes + +## 8. Reference App — cameraStream Command Orchestration + +- [x] 8.1 Implement `cameraStreamFunc`: parse args (deviceId, optional --file), verify device has camera endpoint, call createSession, call stream, subscribe to events +- [x] 8.2 Implement event handling loop: wait for sessionStatus(setup) → create pipeline → extract SDP → execute offerSdp → wait for remoteSdp event → set remote → exchange ICE → wait for connection +- [x] 8.3 Implement Ctrl+C / `q` handling: signal handler sets teardown flag, calls destroySession, stops GStreamer pipeline, returns to prompt +- [x] 8.4 Implement progress output: print human-readable status at each stage (session created, SDP sent, answer received, ICE exchanged, media flowing, stream ended) +- [x] 8.5 Implement error handling: timeout on signaling steps, graceful cleanup on failure, clear error messages diff --git a/openspec/changes/archive/2026-07-16-camera-session-status-redesign/.openspec.yaml b/openspec/changes/archive/2026-07-16-camera-session-status-redesign/.openspec.yaml new file mode 100644 index 00000000..b119b635 --- /dev/null +++ b/openspec/changes/archive/2026-07-16-camera-session-status-redesign/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-13 diff --git a/openspec/changes/archive/2026-07-16-camera-session-status-redesign/design.md b/openspec/changes/archive/2026-07-16-camera-session-status-redesign/design.md new file mode 100644 index 00000000..1b72ce40 --- /dev/null +++ b/openspec/changes/archive/2026-07-16-camera-session-status-redesign/design.md @@ -0,0 +1,122 @@ +## Context + +The camera SBMD driver (`core/deviceDrivers/matter/sbmd/specs/camera.sbmd.js`) exposes an abstract session endpoint (`ep/camera`) plus a protocol endpoint (`ep/webrtc`). The abstract endpoint was originally designed around a `sessionStatus` event resource acting as a protocol-agnostic "traffic controller" that would emit `setup`/`done`/`error` with `nextAction` metadata to walk a protocol-blind client through the flow (see archived `2026-05-04-camera-architecture-redesign`). + +That vision did not survive implementation, and testing exposed two independent problems: + +1. **`sessionStatus` is vestigial.** The reference client ignores `nextAction` and hard-codes the WebRTC flow; `done` is never emitted; only `error` (camera `End`) has a live consumer. The abstraction is also leaky — a client must carry protocol-specific media code (webrtcbin for WebRTC, a URL player for direct), so it inevitably knows the signaling sequence and gains nothing from server-pushed next-steps. + +2. **Asynchronous failures are silently dropped.** The SBMD command plumbing has three delivery channels with very different reliability: + - Synchronous execute `.error()` → surfaces to the client as a failed `executeResource` (reliable). + - `requestCommand` `onError`/timeout continuations → run and return `.error()`, but the originating execute already returned optimistically, so the result terminal has **no client channel** and is logged/dropped. + - `sendCommand` (fire-and-forget) → **no `onError` at all**; async failures are invisible. + + Because `sessionStatus="error"` is only written on an incoming camera `End`, every other async failure (malformed SDP rejected by camera, VideoStreamAllocate/ProvideOffer rejected, camera unreachable during ICE, no ICE candidates) produces an unbounded black-screen hang. + +Additionally, the device service suppresses `resourceUpdated` events when a cached resource's value is unchanged (`core/src/deviceService.c`), and `sessionStatus` is registered cached — so even the paths that do write it can be silently coalesced (e.g. a persisted `"setup"` produces zero events on a re-stream). + +``` + Current Proposed + ───────────────────────────────── ───────────────────────────────── + ep/camera ep/camera + r/createSession (execute) r/createSession (execute) + r/stream (execute) ── emits ──► r/stream (execute) ── returns {protocol, entryPoint} + r/sessionStatus (event) ◄─ traffic r/takePicture (execute) + r/takePicture (execute) controller r/destroySession (execute) + r/destroySession (execute) ep/webrtc + ep/webrtc r/offerSdp (execute) + r/offerSdp (execute) r/remoteSdp (event) + r/remoteSdp (event) r/offerIceCandidates(execute) + r/offerIceCandidates(execute) r/remoteIceCandidates(event) + r/remoteIceCandidates(event) r/webrtcError (event, NEVER-cached) ◄─ all async + failures + End +``` + +## Goals / Non-Goals + +**Goals:** +- Remove `sessionStatus` from `ep/camera` and relocate its one real job (async teardown/error signaling) to the protocol endpoint. +- Give every asynchronous signaling failure a reliable delivery channel to the client. +- Make the failure/teardown event immune to no-change event suppression. +- Give the reference client a bounded, reported outcome for a failed ICE/connectivity exchange. +- Keep the abstract-endpoint contract protocol-agnostic so a future non-WebRTC protocol reuses it unchanged. + +**Non-Goals:** +- Adding a WebRTC media stack to Barton core (Barton remains signaling-only). +- Implementing a second protocol endpoint (`ep/direct`, `ep/openhome`) — the design must *accommodate* one, not build it. +- Changing the `BCoreClient` public GObject API or the resource/event signal surface. +- Reworking the device-service change-detection logic itself (we opt out per-resource via caching policy rather than changing core behavior). +- SolicitOffer / camera-initiated-offer flows. + +## Decisions + +### D1: `stream` execute returns `{protocol, entryPoint}` instead of emitting `sessionStatus(setup)` + +**Decision**: `executeStream` returns a JSON object `{ "protocol": "webrtc", "entryPoint": "//ep/webrtc/r/offerSdp" }` as its synchronous execute result. No `setup` event is emitted. + +**Rationale**: The client already invokes `stream`; the "what protocol / where next" answer belongs in that call's return value, delivered on the reliable synchronous channel. This preserves the protocol-agnostic property (a client can discover the protocol without hard-coding it) while deleting the unused event machinery. A future `ep/direct` driver returns `{protocol:"direct", entryPoint:".../getMediaUrl"}` identically. + +**Alternatives considered**: +- Keep `sessionStatus(setup)` as an event — rejected: it is the machinery we found unused and suppression-prone. +- Put discovery on `createSession` — rejected: protocol is a property of *streaming*, and `stream` is where a session commits to a protocol. + +### D2: Single `webrtcError` async event on `ep/webrtc`, non-cached via a new `volatile` SBMD mode + +**Decision**: Add one event-only resource `webrtcError` to `ep/webrtc` carrying a small value (`ended` / `failed`) plus metadata `{ reason, detail }`. To make it emit unconditionally, add a new declarable resource mode `volatile` to the SBMD runtime that maps the resource to `CACHING_POLICY_NEVER`; declare `webrtcError` with `modes: ['volatile']`. + +**Rationale**: Colocating in-session termination/error signaling with the protocol endpoint keeps protocol concerns off the abstract endpoint. `CACHING_POLICY_NEVER` makes `updateResource` bypass the `strcmp` change-check, so a value that equals the stored one (e.g. a second session's `failed` after the previous session's `failed` persisted) still emits — the exact footgun that silently dropped repeated `sessionStatus` writes. + +SBMD v4 does not currently expose caching policy: `SpecBasedMatterDeviceDriver::DoRegisterDriverResources` derives it solely from `resource.read.has_value()` (`CACHING_POLICY_NEVER` for resources with a read handler, else `CACHING_POLICY_ALWAYS`). Rather than overload a read handler as a caching side-channel, we add a first-class, declarative opt-out: a `volatile` mode. The change is small and additive — extend the schema enum, accept `volatile` in `ConvertModesToBitmask` (no mode bit), and treat `read || volatile` as `CACHING_POLICY_NEVER`. The device-service change-detection logic is untouched. `RESOURCE_MODE_EMIT_EVENTS` is already on by default (only `noEvents` opts out). + +**Alternatives considered**: +- Register `NEVER` with no core change — rejected: not expressible; caching policy has no declarable field in SBMD v4. +- Give `webrtcError` a `read` handler to force `NEVER` — rejected: obscure (read handler purely as a caching side-channel) and would be the first shipped spec to use `read`. +- Reset `webrtcError` to a neutral value each session so the terminal value always differs — rejected: relies on discipline and still suppresses a duplicate terminal value within a session; a declarative mode is more robust and reusable for future protocol endpoints. +- Encode a nonce in the value — rejected: value-format hack the client must parse. +- Change `deviceService.c` to always emit — rejected: broad blast radius; per-resource opt-out is safer. + +### D3: Fan every async failure path into `webrtcError` + +**Decision**: Emit `webrtcError` from `handleVideoStreamAllocateError`, `handleProvideOfferError`, the `requestCommand` overall-deadline timeout continuation, and `handleIncomingEnd`, each with a distinguishing `reason`. + +**Rationale**: These are precisely the paths whose `.error()` results are currently dropped (the async `requestCommand` continuations) or that previously used `sessionStatus` (`End`). Emitting a resource event is the one continuation action that *does* reach the client, because it flows through the normal event pipeline rather than the (absent) execute return. + +**Known limitation**: `sendCommand`-based outbound failures (ProvideICECandidates, EndSession) still have no JS-visible error callback in the runtime. This design does not add one; the client-side connectivity timeout (D4) is the backstop that catches an ICE exchange that never completes for any reason, including a dropped outbound candidate. + +### D4: Client-side connectivity/ICE timeout in `cameraWebrtcClient` + +**Decision**: The reference WebRTC client watches `webrtcbin`'s `ice-connection-state` (and/or `connection-state`) and starts a bounded timer once signaling completes. If the connection does not reach `connected`/`completed` within the window (or transitions to `failed`), it invokes the existing `onWebrtcClosed` teardown with a failure reason. + +**Rationale**: Actual media/ICE connectivity is observable only by the WebRTC peer, never by the Matter signaling relay. The driver fundamentally cannot detect "ICE never connected," so the timeout must live in the client. Routing it through the existing `onWebrtcClosed` path reuses the graceful teardown (EndSession to the camera) already proven for window-close/Ctrl+C. + +**Alternatives considered**: +- Rely solely on `webrtcError` from the driver — rejected: the driver can't see connectivity failures where the camera never sends `End`. +- A fixed `g_usleep` guard — rejected: state-driven detection is both faster on success and correct on failure. + +### D5: Reference client consumes `webrtcError`; drop `sessionStatus` subscription + +**Decision**: `cameraDeviceSession` subscribes to `ep/webrtc/r/webrtcError` and maps `ended`/`failed` to its existing `onError` callback (teardown + reported reason). The `sessionStatus` URI handling is removed. `stream`'s return value is parsed for `protocol`/`entryPoint`. + +**Rationale**: One switch arm moves from the abstract endpoint URI to the protocol endpoint URI; the client's teardown behavior is unchanged. Reading `entryPoint` from the `stream` result (rather than assuming the webrtc URI) keeps the client honest about protocol-agnosticism even while WebRTC is the only implementation. + +## Risks / Trade-offs + +- **[`sendCommand` failures remain invisible to the driver]** → Mitigation: the D4 client connectivity timeout is the catch-all; any exchange that fails to establish — including a lost outbound ICE candidate — resolves to a bounded, reported teardown. +- **[Removing `sessionStatus` breaks any out-of-tree client that read it]** → Mitigation: we own the clients; the only in-tree consumers are the reference app and one unit test, both updated here. This is an internal signaling contract, not the public GObject API. +- **[`stream` return value becomes a semi-structured contract]** → Mitigation: specify the JSON shape (`protocol`, `entryPoint`) in the `camera-session-lifecycle` spec so future protocol drivers conform. +- **[Timeout window tuning]** → A too-short window aborts slow-but-valid connections (the camera's 4s keyframe interval already pushes first media to ~10s); too long delays failure reporting. Mitigation: make the window a named constant with margin over observed worst-case, and reset it on first media. +- **[Thread safety]** → `webrtcError` is emitted from SBMD handler continuations, which run under `MQuickJsRuntime::GetMutex()` and marshal to the device-service event pipeline exactly like `remoteSdp`/`remoteIceCandidates` today — no new synchronization. The client timeout fires on the GStreamer/`webrtcbin` thread and must request teardown via the same cross-thread `onWebrtcClosed` mechanism already used for EOS/error, not act directly. + +## Migration Plan + +1. Land the SBMD spec changes (resource set + handlers) and validate with the SBMD schema (`validate-sbmd`). +2. Update `SbmdCameraWebrtcTest.cpp` assertions (`sessionStatus` → `webrtcError`; `stream` return shape) and confirm the C++ unit suite is green. +3. Update the reference app (`cameraDeviceSession`, `cameraWebrtcClient`, `cameraCategory`). +4. Manually verify: happy path still streams; force each failure (bad SDP, kill camera mid-ICE, suppress ICE) and confirm a bounded teardown with a reported reason. +5. Rollback is a straight revert — no persisted schema/data migration; transient session data keys are unchanged. + +## Open Questions + +1. Exact `webrtcError` value vocabulary — minimal (`ended`/`failed`) with reason in metadata, versus a richer enum. Leaning minimal + `reason` metadata for protocol-agnosticism. +2. Connectivity timeout duration and whether it should be overridable via a `cameraStream` flag for high-latency networks. +3. Whether `takePicture` (currently unimplemented) should also return an entry-point/`{protocol}` shape for consistency, or stay a direct execute. diff --git a/openspec/changes/archive/2026-07-16-camera-session-status-redesign/proposal.md b/openspec/changes/archive/2026-07-16-camera-session-status-redesign/proposal.md new file mode 100644 index 00000000..d3730ad2 --- /dev/null +++ b/openspec/changes/archive/2026-07-16-camera-session-status-redesign/proposal.md @@ -0,0 +1,30 @@ +## Why + +Investigation of the camera driver's `sessionStatus` resource showed it is effectively an appendage: its `setup`/`nextAction` metadata is ignored by the only client (the reference app hard-codes protocol knowledge), its `done` value is never emitted, and its lone live use — signaling that the camera ended the session — is a protocol-specific event misplaced on the abstract endpoint. Worse, a spec-path audit of the WebRTC/Matter flow found that **almost every asynchronous failure is silently dropped**: `requestCommand` `onError` results (VideoStreamAllocate rejected, ProvideOffer rejected) and `requestCommand` timeouts return `.error()` that has no delivery channel to the client, and `sendCommand` (outbound ICE, EndSession) is fire-and-forget with no error path at all. The result is that malformed SDP, a camera that goes unreachable during ICE, or a client that sends no ICE candidates all manifest as an unbounded black-screen hang with no error surfaced. + +## What Changes + +- **BREAKING** (internal signaling contract): Remove the `sessionStatus` resource from the abstract `ep/camera` endpoint. Protocol and entry-point discovery move onto the return value of the `stream` execute (`{ protocol, entryPoint }`), which the client already calls — eliminating the server-pushed "traffic controller" the client never used. +- Add a new declarable SBMD resource mode `volatile` that maps a resource to `CACHING_POLICY_NEVER`, then add a single asynchronous error/termination event resource on the protocol endpoint (`ep/webrtc`, `webrtcError`) declared `modes: ['volatile']` so it emits **unconditionally**, never subject to the device-service no-change suppression that silently dropped repeated status writes. The device-service change-detection logic itself is left untouched. +- Feed that event from **every** currently-dropped failure path: `handleVideoStreamAllocateError`, `handleProvideOfferError`, the `requestCommand` overall-deadline timeout, and the incoming camera `End` command — subsuming the old `sessionStatus="error"` behavior and closing the silent-failure gaps. +- The reference app consumes the new `ep/webrtc` state event (in place of `sessionStatus`) and adds a client-side connectivity/ICE timeout driven by `webrtcbin`'s `ice-connection-state`/`connection-state`, surfacing failures through the existing graceful-teardown path instead of hanging on a black window. + +## Capabilities + +### New Capabilities +- `camera-session-lifecycle`: The protocol-agnostic abstract camera endpoint contract (`createSession`, `stream`, `takePicture`, `destroySession`) with **no** `sessionStatus` resource. The `stream` execute returns the active protocol and its entry-point URI; all in-session state, error, and teardown signaling lives on the protocol endpoint, not the abstract one. + +### Modified Capabilities +- `sbmd-v4-runtime`: Add a declarable resource mode `volatile` that registers a resource with `CACHING_POLICY_NEVER`, so its `updateResource` calls emit events unconditionally (bypassing no-change suppression) without a read handler. +- `webrtc-signaling-endpoint`: Replace the "Incoming End command emits `sessionStatus` error" requirement with an `ep/webrtc` asynchronous error/termination event (`webrtcError`); require that all async signaling failures (stream-allocate error, provide-offer error, command timeout, camera `End`) emit that event; require the event resource to be declared `volatile` so no emission is suppressed. +- `camera-stream-reference-command`: The reference command consumes the `ep/webrtc` state event rather than `sessionStatus`, and enforces a connectivity/ICE timeout so a failed exchange results in a bounded, reported teardown instead of an indefinite hang. This capability's spec is also reconciled with the as-built reference command: an `--out ` destination (record to `file://` or serve over HTTP), a decode-free passthrough pipeline (`rtph264depay → h264parse → mp4mux → appsink`) that serves fragmented MP4 to a browser via Media Source Extensions instead of a local display window, host-only ICE, and the `gstreamer-app-1.0`/`gio-2.0` build dependencies. + +## Impact + +- **SBMD runtime**: `core/deviceDrivers/matter/sbmd/schema/sbmd-spec-schema-v4.0.json` (add `volatile` to the modes enum) and `core/deviceDrivers/matter/sbmd/SpecBasedMatterDeviceDriver.cpp` (accept `volatile` in `ConvertModesToBitmask`; treat `read || volatile` as `CACHING_POLICY_NEVER` in `DoRegisterDriverResources`). +- **Drivers**: `core/deviceDrivers/matter/sbmd/specs/camera.sbmd.js` — remove `sessionStatus` resource + its two write sites; change `executeStream` to return `{protocol, entryPoint}`; add the `ep/webrtc` `webrtcError` (`volatile`) resource and emit it from the four failure paths. +- **Reference app**: `reference/src/cameraDeviceSession.c` (stop subscribing to `sessionStatus`, subscribe to the new `ep/webrtc` state event), `reference/src/cameraWebrtcClient.c/.h` (watch `ice-connection-state`/`connection-state`, drive `onWebrtcClosed` on failure/timeout), `reference/src/cameraCategory.c` (surface the failure reason). +- **Tests**: `core/test/src/SbmdCameraWebrtcTest.cpp` — the assertion expecting an `updateResource` for `sessionStatus` (and the End→sessionStatus test) must move to the new `ep/webrtc` state event. +- **Specs**: new `camera-session-lifecycle` spec; delta modifications to `webrtc-signaling-endpoint` and `camera-stream-reference-command`. +- **No public GObject API change**; `BCoreClient` resource/event surface is unchanged (only resource URIs on the camera device change). +- **CMake flags**: `BCORE_REFERENCE_CAMERA_SUPPORT` (reference command) unchanged. diff --git a/openspec/changes/archive/2026-07-16-camera-session-status-redesign/specs/camera-session-lifecycle/spec.md b/openspec/changes/archive/2026-07-16-camera-session-status-redesign/specs/camera-session-lifecycle/spec.md new file mode 100644 index 00000000..29cc66d2 --- /dev/null +++ b/openspec/changes/archive/2026-07-16-camera-session-status-redesign/specs/camera-session-lifecycle/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: Abstract camera endpoint provides protocol-agnostic session lifecycle + +The camera SBMD driver SHALL declare an endpoint with id `"camera"` and profile `"camera"` exposing the session lifecycle as execute resources only: `createSession`, `stream`, `takePicture`, and `destroySession`. The abstract endpoint SHALL NOT declare a `sessionStatus` resource, and SHALL NOT carry any protocol-specific signaling or in-session state. All in-session state, error, and teardown signaling SHALL live on the protocol-specific endpoint (e.g. `ep/webrtc`). + +#### Scenario: Camera endpoint exposes only lifecycle executes +- **WHEN** a Matter camera device (deviceType 0x0142) is commissioned +- **THEN** the device SHALL have an endpoint with id `"camera"` exposing `createSession`, `stream`, `takePicture`, and `destroySession` as execute resources AND SHALL NOT expose a `sessionStatus` resource + +#### Scenario: No protocol coupling on the abstract endpoint +- **WHEN** the `ep/camera` endpoint is inspected +- **THEN** none of its resources SHALL reference a specific streaming protocol; protocol identity is carried only in the `stream` execute result and on the protocol endpoint + +### Requirement: createSession allocates a session and returns its identifier + +The `createSession` execute handler SHALL allocate a new session, persist it in transient data, and return the new `sessionId` synchronously as the execute result so the client holds a correlation identifier before invoking any further resource. + +#### Scenario: Client creates a session +- **WHEN** a client executes `createSession` +- **THEN** the handler SHALL return a non-empty `sessionId` string AND record the session in transient data with an initial state + +#### Scenario: Corrupt session data is reset +- **WHEN** `createSession` is executed and the stored session data cannot be parsed +- **THEN** the handler SHALL reset the session store and return an error result + +### Requirement: stream execute returns the active protocol and entry point + +The `stream` execute handler SHALL mark the identified session as streaming and return, as its synchronous execute result, a JSON object identifying the active protocol and the entry-point resource URI the client must use next: `{ "protocol": "", "entryPoint": "//ep//r/" }`. The handler SHALL NOT emit a separate event to convey the next action. + +#### Scenario: Stream returns protocol and entry point for a WebRTC camera +- **WHEN** a client executes `stream` with a valid `sessionId` on a Matter WebRTC camera +- **THEN** the handler SHALL return `{ "protocol": "webrtc", "entryPoint": "//ep/webrtc/r/offerSdp" }` AND mark the session `streaming` + +#### Scenario: Stream on unknown session +- **WHEN** a client executes `stream` with a `sessionId` that does not exist +- **THEN** the handler SHALL return an error result and SHALL NOT mark any session streaming + +### Requirement: destroySession releases session state + +The `destroySession` execute handler SHALL remove the identified session from transient data and trigger any protocol-specific teardown required for a session that reached streaming. + +#### Scenario: Client destroys a session +- **WHEN** a client executes `destroySession` with a valid `sessionId` +- **THEN** the handler SHALL remove the session from transient data + +### Requirement: Client discovers next steps without server-pushed status events + +A client SHALL be able to drive the full session flow using only execute results and protocol-endpoint event subscriptions, without reading or subscribing to any status resource on the abstract endpoint. + +#### Scenario: Reference client drives flow without sessionStatus +- **WHEN** the reference app runs a camera stream +- **THEN** it SHALL obtain the protocol and entry point from the `stream` execute result and subscribe to protocol-endpoint events, and SHALL NOT subscribe to any `sessionStatus` resource diff --git a/openspec/changes/archive/2026-07-16-camera-session-status-redesign/specs/camera-stream-reference-command/spec.md b/openspec/changes/archive/2026-07-16-camera-session-status-redesign/specs/camera-stream-reference-command/spec.md new file mode 100644 index 00000000..46da04b0 --- /dev/null +++ b/openspec/changes/archive/2026-07-16-camera-session-status-redesign/specs/camera-stream-reference-command/spec.md @@ -0,0 +1,129 @@ +## MODIFIED Requirements + +### Requirement: cameraStream subscribes to Barton events + +The `cameraStream` command SHALL subscribe to resource events on the device to receive signaling data asynchronously. Specifically: +- `remoteSdp` events on `ep/webrtc` (for the camera's SDP answer) +- `remoteIceCandidates` events on `ep/webrtc` (for the camera's ICE candidates) +- `webrtcError` events on `ep/webrtc` (for asynchronous session termination and errors) + +The command SHALL NOT subscribe to any `sessionStatus` resource on `ep/camera`. It SHALL obtain the active protocol and entry-point URI from the `stream` execute result. + +#### Scenario: Remote SDP delivered via event +- **WHEN** the camera responds with an SDP answer +- **THEN** the reference app SHALL receive it as a `remoteSdp` event and feed it to webrtcbin as the remote description + +#### Scenario: Remote ICE candidates delivered via events +- **WHEN** the camera sends ICE candidates +- **THEN** the reference app SHALL receive them as `remoteIceCandidates` events and add each candidate to webrtcbin + +#### Scenario: Session error delivered via webrtcError event +- **WHEN** the driver emits a `webrtcError` event with an ended or failed value +- **THEN** the reference app SHALL treat it as a session-terminated signal and begin graceful teardown, reporting the reason from the event metadata + +### Requirement: cameraStream reports progress to user + +The command SHALL emit human-readable progress messages to stdout at each stage of the flow: +- Session created (sessionId) +- Streaming initiated (protocol, entryPoint) +- SDP offer sent +- SDP answer received +- ICE candidates exchanged +- Media flowing / connected +- Stream ended (reason) + +#### Scenario: Progress output during successful stream +- **WHEN** `cameraStream` completes signaling and media begins flowing +- **THEN** the user SHALL see step-by-step status messages indicating progress through the flow + +### Requirement: cameraStream handles errors gracefully + +The command SHALL handle failures at any stage (session creation failure, signaling timeout, asynchronous `webrtcError` failure, and connectivity/ICE failure) by printing an error message, cleaning up any partial state (destroying the session if created), and returning to the command prompt. In particular, because Matter signaling cannot observe media-plane connectivity, the command SHALL enforce a client-side connectivity timeout: if the WebRTC peer connection does not reach a connected state within a bounded window after signaling completes — or transitions to a failed state — the command SHALL tear down and report the failure rather than wait indefinitely. + +#### Scenario: Device does not support camera streaming +- **WHEN** `cameraStream` is executed on a device without a `camera` endpoint +- **THEN** the command SHALL print an error and exit without crashing + +#### Scenario: Signaling timeout +- **WHEN** the camera does not respond to signaling within a reasonable timeout +- **THEN** the command SHALL print a timeout error, destroy the session, and exit + +#### Scenario: Connectivity never established +- **WHEN** signaling completes but the WebRTC peer connection does not reach a connected state within the connectivity timeout window +- **THEN** the command SHALL print a connectivity-failure message, destroy the session, and exit rather than hang on a blank window + +#### Scenario: Peer connection fails +- **WHEN** the WebRTC peer connection transitions to a failed state during or after ICE exchange +- **THEN** the command SHALL print a failure message, destroy the session, and exit + +### Requirement: cameraStream command exists in reference app + +The reference app SHALL provide a command named `cameraStream` with short alias `cs` in a dedicated camera command category. The command SHALL accept a device ID as a required argument and an optional `--out ` flag that selects the media destination. + +#### Scenario: Command appears in help +- **WHEN** a user types `help` in the reference app +- **THEN** the camera category SHALL list `cameraStream` with usage: ` [--out ]` + +#### Scenario: Command with short alias +- **WHEN** a user types `cs ` +- **THEN** the command SHALL execute identically to `cameraStream ` + +#### Scenario: Output URI selects the media destination +- **WHEN** the command is invoked with `--out file://` +- **THEN** the stream SHALL be recorded to that file path +- **WHEN** the command is invoked with `--out [:]` (optionally prefixed with `http://`), or without `--out` +- **THEN** the stream SHALL be served over HTTP for a browser to play, defaulting to a loopback host and port when `--out` is omitted + +### Requirement: cameraStream orchestrates full session lifecycle + +The `cameraStream` command SHALL orchestrate the complete camera streaming flow through Barton's resource API: +1. Execute `createSession` on the device's `ep/camera` endpoint +2. Execute `stream` and obtain the active protocol and entry-point URI from its `{ protocol, entryPoint }` result +3. Create a local GStreamer `webrtcbin` peer connection using host candidates only (no STUN/TURN) +4. Generate a local SDP offer from webrtcbin +5. Execute `offerSdp` on the device's `ep/webrtc` endpoint with the local SDP +6. Wait for a `remoteSdp` event and set the remote description on webrtcbin +7. Exchange ICE candidates (local → `offerIceCandidates`, remote ← `remoteIceCandidates` events) +8. Wait for the peer connection to reach the connected state, subject to a bounded connectivity timeout +9. Route the received media to the destination selected by `--out`: serve it over the built-in HTTP server or record it to a file +10. On user interrupt (Ctrl+C or `q`) or a `webrtcError` event: execute `destroySession` and tear down the pipeline + +#### Scenario: Successful camera stream served to a browser +- **WHEN** a user executes `cameraStream ` (or with an `http://` `--out`) and the camera responds to signaling +- **THEN** the reference app SHALL serve the live stream over its built-in HTTP server for a browser to play and print status messages for each step, without opening a local display window + +#### Scenario: Successful camera stream to file +- **WHEN** a user executes `cameraStream --out file://recording.mp4` +- **THEN** the reference app SHALL record the video stream to the specified file path + +#### Scenario: User stops the stream +- **WHEN** a user presses Ctrl+C or types `q` during an active stream +- **THEN** the reference app SHALL execute `destroySession`, stop the GStreamer pipeline, and return to the command prompt + +### Requirement: cameraStream uses GStreamer webrtcbin for media + +The `cameraStream` command SHALL use GStreamer's `webrtcbin` element as its local WebRTC peer connection (host candidates only). The received H.264 SHALL be handled by a passthrough pipeline that neither decodes nor renders locally: `rtph264depay → h264parse → mp4mux` (fragmented, streamable) `→ appsink`. The muxed fragmented-MP4 buffers SHALL be delivered either to the built-in HTTP media server or to a file, according to `--out`. + +#### Scenario: Serve mode pipeline +- **WHEN** `cameraStream` runs in serve mode (the default, or an `http://` `--out`) +- **THEN** the muxed fragmented-MP4 buffers SHALL be pushed to the built-in HTTP media server, which serves them to a browser that decodes and plays them via Media Source Extensions + +#### Scenario: Record mode pipeline +- **WHEN** `cameraStream` is invoked with `--out file://` +- **THEN** the muxed fragmented-MP4 buffers SHALL be written to the file at that path + +#### Scenario: GStreamer not available +- **WHEN** GStreamer libraries (with `webrtcbin`) are not available at runtime +- **THEN** the command SHALL print an error explaining the requirement and exit gracefully + +### Requirement: Camera command category is gated by CMake flag + +The camera stream command and its GStreamer dependencies SHALL be gated behind a `BCORE_REFERENCE_CAMERA_SUPPORT` CMake option (default OFF). When disabled, the reference app builds without GStreamer dependencies and without the camera category. + +#### Scenario: Build without camera stream support +- **WHEN** `BCORE_REFERENCE_CAMERA_SUPPORT=OFF` (default) +- **THEN** the reference app SHALL build successfully without GStreamer development libraries + +#### Scenario: Build with camera stream support +- **WHEN** `BCORE_REFERENCE_CAMERA_SUPPORT=ON` +- **THEN** the reference app SHALL link against gstreamer-1.0, gstreamer-webrtc-1.0, gstreamer-sdp-1.0, gstreamer-app-1.0, and gio-2.0, and include the camera category diff --git a/openspec/changes/archive/2026-07-16-camera-session-status-redesign/specs/sbmd-v4-runtime/spec.md b/openspec/changes/archive/2026-07-16-camera-session-status-redesign/specs/sbmd-v4-runtime/spec.md new file mode 100644 index 00000000..bf04b8f2 --- /dev/null +++ b/openspec/changes/archive/2026-07-16-camera-session-status-redesign/specs/sbmd-v4-runtime/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: volatile resource mode disables value caching + +The SBMD runtime SHALL support a resource mode `volatile`. A resource declared with the `volatile` mode SHALL be registered with `CACHING_POLICY_NEVER`, causing `updateResource` to emit a `resourceUpdated` event on every call (for a resource that emits events) regardless of whether the new value equals the currently stored value. The `volatile` mode SHALL NOT, by itself, add read, write, or execute access, and SHALL be accepted by mode-to-bitmask conversion without error. A resource is registered `CACHING_POLICY_NEVER` when it declares a read handler OR declares the `volatile` mode; otherwise it is registered `CACHING_POLICY_ALWAYS`. + +#### Scenario: Volatile resource emits on unchanged value +- **WHEN** a resource declared with `modes: ['volatile']` is updated twice with the same value +- **THEN** the runtime SHALL deliver two `resourceUpdated` events (no value-change suppression) + +#### Scenario: Volatile mode is accepted by the schema and runtime +- **WHEN** a driver declares a resource with `volatile` in its modes array +- **THEN** the spec SHALL validate against the SBMD schema AND the resource SHALL register successfully without adding read, write, or execute modes + +#### Scenario: Non-volatile resource without a read handler remains cached +- **WHEN** an event-only resource is declared without a read handler and without the `volatile` mode +- **THEN** the runtime SHALL register it with `CACHING_POLICY_ALWAYS` and suppress `resourceUpdated` events whose value is unchanged diff --git a/openspec/changes/archive/2026-07-16-camera-session-status-redesign/specs/webrtc-signaling-endpoint/spec.md b/openspec/changes/archive/2026-07-16-camera-session-status-redesign/specs/webrtc-signaling-endpoint/spec.md new file mode 100644 index 00000000..3c3d2cee --- /dev/null +++ b/openspec/changes/archive/2026-07-16-camera-session-status-redesign/specs/webrtc-signaling-endpoint/spec.md @@ -0,0 +1,65 @@ +## MODIFIED Requirements + +### Requirement: WebRTC endpoint declares signaling resources + +The camera SBMD driver SHALL declare an endpoint with id `"webrtc"` and profile `"webrtc"` containing five resources: + +| Resource | Type | Modes | Purpose | +|----------|------|-------|---------| +| `offerSdp` | `function` | execute | Client sends local SDP offer | +| `remoteSdp` | `string` | [] (events only) | Delivers remote SDP answer to client | +| `offerIceCandidates` | `function` | execute | Client sends local ICE candidates | +| `remoteIceCandidates` | `string` | [] (events only) | Delivers remote ICE candidates to client | +| `webrtcError` | `string` | [volatile] (events only) | Delivers asynchronous session termination/error to client | + +The endpoint SHALL be declared within the same `camera.sbmd.js` file as the `ep/camera` endpoint. The `webrtcError` resource SHALL be declared with the `volatile` mode so that its events are emitted unconditionally (non-cached), independent of the previously emitted value. + +#### Scenario: Endpoint appears on commissioned camera device +- **WHEN** a Matter camera device (deviceType 0x0142) with WebRTCTransportProvider cluster (0x0553) is commissioned +- **THEN** the device SHALL have an endpoint with id `"webrtc"`, profile `"webrtc"`, and all five resources registered + +#### Scenario: Event-only resources are not readable +- **WHEN** a client attempts to read `remoteSdp`, `remoteIceCandidates`, or `webrtcError` +- **THEN** the read SHALL fail or return no value (modes list is empty — no read mode) + +## ADDED Requirements + +### Requirement: webrtcError resource emits every event unconditionally + +The `webrtcError` resource SHALL be declared with the `volatile` mode, which registers it with `CACHING_POLICY_NEVER` so that `updateResource` bypasses value-change detection and each emission delivers a `resourceUpdated` event to subscribers even when consecutive values are identical (including across sessions where a prior value persists). + +#### Scenario: Repeated identical values still emit +- **WHEN** the driver emits `webrtcError` twice in succession with the same value +- **THEN** the client SHALL receive two distinct `resourceUpdated` events (no no-change suppression) + +### Requirement: Incoming End command emits webrtcError event + +The SBMD driver SHALL register a command handler for the `End` command (ID 0x03) on the `WebRTCTransportRequestor` cluster (0x0554). When received, the handler SHALL clean up the associated session and emit a `webrtcError` event on the `webrtc` endpoint with a value indicating the session ended and metadata carrying the reason. + +#### Scenario: Camera ends session +- **WHEN** the camera sends an `End` command with a reason code +- **THEN** the SBMD handler SHALL call `updateResource('webrtc', 'webrtcError', , { "reason": "", "detail": "" })` AND remove the associated session from transient data + +### Requirement: Asynchronous signaling failures emit webrtcError event + +Each asynchronous WebRTC signaling failure that occurs after the originating execute has returned SHALL emit a `webrtcError` event so the client is notified rather than left to time out. This SHALL cover at least: a `VideoStreamAllocate` error, a `ProvideOffer` error, and a `requestCommand` overall-deadline timeout in the offer flow. + +#### Scenario: VideoStreamAllocate rejected by camera +- **WHEN** the camera rejects the `VideoStreamAllocate` command during the `offerSdp` flow +- **THEN** the SBMD handler SHALL emit a `webrtcError` event with a failure value and metadata describing the allocate error + +#### Scenario: ProvideOffer rejected by camera +- **WHEN** the camera rejects the `ProvideOffer` command during the `offerSdp` flow +- **THEN** the SBMD handler SHALL emit a `webrtcError` event with a failure value and metadata describing the provide-offer error + +#### Scenario: Offer-flow command times out +- **WHEN** a `requestCommand` in the `offerSdp` flow exceeds its overall deadline +- **THEN** the SBMD handler SHALL emit a `webrtcError` event with a failure value and a timeout reason + +## REMOVED Requirements + +### Requirement: Incoming End command emits sessionStatus error + +**Reason**: The `sessionStatus` resource is removed from the abstract `ep/camera` endpoint. Session teardown and error signaling for the WebRTC protocol now live on the protocol endpoint as the `webrtcError` event (see "Incoming End command emits webrtcError event"). + +**Migration**: Clients that subscribed to `ep/camera/r/sessionStatus` for the `"error"` value SHALL instead subscribe to `ep/webrtc/r/webrtcError` and treat its ended/failed values as the session-terminated signal. diff --git a/openspec/changes/archive/2026-07-16-camera-session-status-redesign/tasks.md b/openspec/changes/archive/2026-07-16-camera-session-status-redesign/tasks.md new file mode 100644 index 00000000..91c6aa44 --- /dev/null +++ b/openspec/changes/archive/2026-07-16-camera-session-status-redesign/tasks.md @@ -0,0 +1,40 @@ +## 0. SBMD runtime — volatile (non-cached) resource mode + +- [x] 0.1 Add `volatile` to the modes enum in `core/deviceDrivers/matter/sbmd/schema/sbmd-spec-schema-v4.0.json` with a description +- [x] 0.2 Accept `volatile` in `ConvertModesToBitmask` (no mode bit) in `SpecBasedMatterDeviceDriver.cpp` +- [x] 0.3 In `DoRegisterDriverResources`, set `CACHING_POLICY_NEVER` when a resource has a read handler OR declares the `volatile` mode +- [x] 0.4 Validate the `volatile` mode: camera-device registration accepts `modes: ['volatile']` (the `webrtc` endpoint + `webrtcError` resource register with no "Unsupported resource mode" error), confirmed via E2E commissioning. No standalone caching-policy unit test was added — there is no existing device-registration/cachingPolicy test harness and it is disproportionate for the 3-line derivation change; the unconditional-emit behavior is exercised by the E2E path. + +## 1. Camera SBMD driver — remove sessionStatus + +- [x] 1.1 Remove the `sessionStatus` resource declaration from the `ep/camera` endpoint in `camera.sbmd.js` +- [x] 1.2 Change `executeStream` to return `{ protocol, entryPoint }` as its success result and delete its `sessionStatus`/`STATUS_SETUP` `updateResource` call and `nextAction` metadata +- [x] 1.3 Remove the now-unused `STATUS_SETUP`/`STATUS_DONE`/`STATUS_ERROR` constants and update the header comment block to describe the entry-point-return contract (no traffic-controller language) + +## 2. Camera SBMD driver — add webrtcError event + +- [x] 2.1 Declare a `webrtcError` event-only resource on the `ep/webrtc` endpoint with `modes: ['volatile']` so `updateResource` bypasses value-change suppression +- [x] 2.2 Add a shared helper that emits `webrtcError` with a value + `{ reason, detail }` metadata +- [x] 2.3 Rewrite `handleIncomingEnd` to emit `webrtcError` (ended) instead of `sessionStatus` error, preserving session cleanup +- [x] 2.4 Emit `webrtcError` (failed) from `handleVideoStreamAllocateError` with the allocate error detail +- [x] 2.5 Emit `webrtcError` (failed) from `handleProvideOfferError` with the provide-offer error detail +- [x] 2.6 Emit `webrtcError` (failed) from the offer-flow `requestCommand` timeout path (allocate + provide-offer `timeoutMs` continuations) with a timeout reason + +## 3. Reference app — consume webrtcError and drop sessionStatus + +- [x] 3.1 In `cameraDeviceSession.c`, remove the `sessionStatus` URI subscription/handling and subscribe to `ep/webrtc/r/webrtcError`, mapping ended/failed to the existing `onError` teardown with the metadata reason +- [x] 3.2 Parse the `stream` execute result for `{ protocol, entryPoint }` and use `entryPoint` instead of a hard-coded webrtc URI where the flow begins +- [x] 3.3 Update `cameraCategory.c` progress output to report `protocol`/`entryPoint` and to surface the `webrtcError` failure reason on teardown + +## 4. Reference app — connectivity/ICE timeout + +- [x] 4.1 In `cameraWebrtcClient.c`, watch `webrtcbin` `ice-connection-state` (and/or `connection-state`); on `failed`, request teardown via the existing `onWebrtcClosed` cross-thread path with a failure reason +- [x] 4.2 Start a bounded connectivity timer when signaling completes; if not `connected`/`completed` before it expires, request teardown via `onWebrtcClosed`; reset/cancel the timer on first media +- [x] 4.3 Expose the timeout window as a named constant with margin over the observed worst-case first-media latency + +## 5. Tests and validation + +- [x] 5.1 Update `SbmdCameraWebrtcTest.cpp`: replace the `sessionStatus` `updateResource` assertion with a `webrtcError` assertion, add coverage for the allocate/provide-offer/timeout failure emissions, and assert the `stream` result shape `{ protocol, entryPoint }` +- [x] 5.2 Run `validate-sbmd` on `camera.sbmd.js` and the C++ unit suite; confirm green +- [x] 5.3 Manual end-to-end: happy path streams (found + fixed an `entryPoint` URI bug — driver emitted `/devices//...` but Barton URIs are `//...`); connection reaches CONNECTED, watchdog cancels with no false timeout, no spurious `webrtcError`. Pre-connection ICE-failure watchdog and `webrtcError` emissions (End/allocate/provide-offer/timeout) are covered by unit tests; post-connection media loss is out of scope (unchanged pre-existing behavior). +- [x] 5.4 `clang-format` all touched C/C++ files from the repo root and confirm formatting/hooks pass diff --git a/openspec/changes/archive/2026-08-03-negotiation-role-camera-perspective/.openspec.yaml b/openspec/changes/archive/2026-08-03-negotiation-role-camera-perspective/.openspec.yaml new file mode 100644 index 00000000..e08b5f89 --- /dev/null +++ b/openspec/changes/archive/2026-08-03-negotiation-role-camera-perspective/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-03 diff --git a/openspec/changes/archive/2026-08-03-negotiation-role-camera-perspective/design.md b/openspec/changes/archive/2026-08-03-negotiation-role-camera-perspective/design.md new file mode 100644 index 00000000..68da73c8 --- /dev/null +++ b/openspec/changes/archive/2026-08-03-negotiation-role-camera-perspective/design.md @@ -0,0 +1,79 @@ +## Context + +`ep/webrtc` is the camera's Matter data model, surfaced through the SBMD driver `core/deviceDrivers/matter/sbmd/specs/camera.sbmd.js`. Its `negotiationRole` read resource currently answers the question *"what role must the client take?"* (`offerer`/`answerer`). That is the wrong subject: a resource on the camera's endpoint should describe the camera. The reference app (the only in-tree consumer) reads the value and maps `answerer = (role == "answerer")` directly onto its `webrtcbin` configuration. + +Internally the driver overloads the same value twice: +- `pickNegotiationRole()` maps the camera's advertised `WebRTCTransportProvider.AcceptedCommandList` to a client-role string. +- `readNegotiationRole()` returns that string to the client. +- `executeLocalSdp()` *also* calls `pickNegotiationRole()` and branches on `role === ROLE_ANSWERER` to choose the SolicitOffer vs ProvideOffer Matter flow. + +So the client-role string is entangled with the internal flow selection. Inverting the public contract must not disturb the Matter signaling sequence. + +## Goals / Non-Goals + +**Goals:** +- `negotiationRole` reports the **camera's** role: `offerer` when the camera generates the SDP offer (SolicitOffer flow), `answerer` when the camera answers (ProvideOffer flow), defaulting to `offerer`. +- Keep the Matter signaling flow (`VideoStreamAllocate` → `SolicitOffer`/`ProvideOffer` → `ProvideAnswer` → ICE → events) byte-for-byte unchanged. +- The reference app derives its own role by inverting the camera's role and keeps working end-to-end. +- Specs, driver comments, and tests reflect the camera-perspective contract. + +**Non-Goals:** +- No change to the Matter command sequence, video-stream allocation, event resources, or session lifecycle. +- No change to the resource's type/modes (`read` `string`). +- No new public C/GObject/GIR API. + +## Decisions + +### Decision 1: Express the flow selection as an intrinsic camera predicate, not a client role + +Introduce an internal helper that answers the camera-centric question directly — e.g. `cameraIsOfferer(args)` → `true` when the camera accepts `SolicitOffer` (or when the accepted-command list is unavailable, i.e. the default SolicitOffer flow), `false` when it accepts only `ProvideOffer`. Both consumers derive from it: + +- `readNegotiationRole()` → `cameraIsOfferer ? ROLE_OFFERER : ROLE_ANSWERER`. +- `executeLocalSdp()` branches on `cameraIsOfferer` (true → SolicitOffer flow; false → ProvideOffer flow) — the *same* branch it takes today, just keyed off the intrinsic predicate instead of a client-role string. + +*Alternative considered*: simply invert `pickNegotiationRole()` to return the camera role and flip `executeLocalSdp`'s comparison to `role === ROLE_OFFERER`. Rejected because it keeps overloading a role string for flow control, which is exactly the confusion this change removes. The predicate makes the SolicitOffer↔`offerer` correspondence explicit and self-documenting. + +### Decision 2: Reference app inverts at the single read site + +`cameraCategory.c` reads the role once (`cameraDeviceSessionGetRole`) and computes `answerer`. Change that one mapping from `answerer = (role == "answerer")` to `answerer = (role == "offerer")` — the client answers when the camera offers. `cameraDeviceSession.*` and `cameraWebrtcClient.*` keep operating on the client-side `answerer` boolean, so only the interpretation at the read site changes; the rest of the reference-app pipeline is untouched. Comments that describe the role (`cameraCategory.c`, `cameraDeviceSession.*`, `cameraWebrtcClient.*`) are updated to the camera perspective. + +### Decision 3: Spec deltas as MODIFIED + RENAMED + +The `webrtc-signaling-endpoint` requirement "negotiationRole read reports the client's role" is renamed to "...the camera's role" and its body/scenarios inverted; the resource table row description is updated. `camera-stream-reference-command`'s orchestration requirement gains the invert-at-read-site behavior and a scenario asserting it. + +### Data flow + +``` + Matter camera SBMD driver (camera.sbmd.js) reference app + (WebRTCTransportProvider) ep/webrtc (cameraCategory.c) + ┌───────────────────┐ ┌───────────────────────────────┐ ┌────────────────────┐ + │ AcceptedCommandList│ attr │ cameraIsOfferer(args): │ │ role = GetRole() │ + │ - SolicitOffer │ ───────► │ SolicitOffer / (unavailable)│ │ answerer = │ + │ - ProvideOffer │ supplmt │ → true (camera offerer) │ read │ (role=="offerer")│ + └───────────────────┘ │ ProvideOffer only │ ───────► │ SetAnswerer(webrtc)│ + │ → false (camera answerer) │ "offerer"│ │ + │ readNegotiationRole: │ or │ client role = │ + │ true→'offerer' false→'answerer'│ "answerer"│ opposite of camera│ + │ executeLocalSdp: same branch │ └────────────────────┘ + │ keyed on cameraIsOfferer │ + └───────────────────────────────┘ +``` + +## Risks / Trade-offs + +- **[Breaking contract inversion]** Any independent client relying on the old client-perspective value would break. → Mitigation: the only in-tree consumer (reference app) is updated in the same change; the value is validated end-to-end against a real camera before merge; the change is documented as **BREAKING** and lands on `feature/cameras` before any external client depends on it. +- **[Silent flow regression]** If the flow branch is mis-keyed during the predicate refactor, signaling could pick the wrong Matter command and hang. → Mitigation: the predicate maps 1:1 to today's branch (SolicitOffer↔true); covered by the SBMD camera unit test and an end-to-end reference-app run. +- **[Stale role wording]** Comments/spec prose could keep the old perspective. → Mitigation: audit every `negotiationRole`/`offerer`/`answerer` mention in the driver, reference app, and both specs as part of the tasks. + +## Migration Plan + +No data or schema migration. Coordinated code change: driver + reference app + specs in one change. Rollback = revert the change commit; there is no persisted state keyed on the role value. + +## Backward compatibility / API implications + +- No public C API, GObject signal/property, or GIR surface changes — `negotiationRole` is an SBMD data-model resource string, not part of the GObject API. +- Thread safety: unchanged. Driver handlers run on the existing SBMD execution context; the reference app reads the role on its command thread. No new threads, locks, or main-loop interactions. + +## Open Questions + +- None. (Default when `AcceptedCommandList` is unavailable stays the SolicitOffer flow, now reported as `offerer`.) diff --git a/openspec/changes/archive/2026-08-03-negotiation-role-camera-perspective/proposal.md b/openspec/changes/archive/2026-08-03-negotiation-role-camera-perspective/proposal.md new file mode 100644 index 00000000..b1d7e8a3 --- /dev/null +++ b/openspec/changes/archive/2026-08-03-negotiation-role-camera-perspective/proposal.md @@ -0,0 +1,37 @@ +## Why + +The `webrtc` endpoint's `negotiationRole` resource currently reports the role the *client* must take (`offerer`/`answerer`). But `ep/webrtc` is the camera's Matter data model — a resource on the camera's endpoint should describe the *camera*, not the client consuming it. Reporting the client's role inverts the perspective, leaks a client concern into the device model, and forces every reader to know that the value is "the opposite of what the device does." + +## What Changes + +- **BREAKING**: `negotiationRole` (on `ep/webrtc`) SHALL report the **camera's** WebRTC negotiation role — `offerer` when the camera generates the SDP offer, `answerer` when the camera answers the client's offer — instead of the client's role. +- Invert the driver's mapping from the camera's advertised `AcceptedCommandList` to the reported role: camera accepts `SolicitOffer` → camera is the `offerer`; camera accepts `ProvideOffer` → camera is the `answerer`. The default (SolicitOffer flow) therefore reports `offerer`. +- Decouple the internal SolicitOffer/ProvideOffer flow selection in `localSdp` from the public role value so the Matter signaling sequence is byte-for-byte unchanged; only the reported role string and its interpretation change. +- Update the reference app to derive its own role by inverting the camera's reported role: act as the **answerer** when the camera role is `offerer`, and as the **offerer** when the camera role is `answerer`. +- Update the affected specs, driver comments, and tests to the camera-perspective contract. + +## Capabilities + +### New Capabilities + + + +### Modified Capabilities + +- `webrtc-signaling-endpoint`: the `negotiationRole` requirement changes so the read reports the camera's role (not the client's); the `AcceptedCommandList` → role mapping is inverted (`SolicitOffer` → `offerer`, `ProvideOffer` → `answerer`, default `offerer`). +- `camera-stream-reference-command`: the reference app's orchestration requirement changes so it reads the camera's role and inverts it to select its own WebRTC role. + +## Non-goals + +- No change to the underlying Matter signaling flow: the `VideoStreamAllocate` → `SolicitOffer`/`ProvideOffer` → `ProvideAnswer`/`ProvideICECandidates` sequence, the incoming `Offer`/`Answer`/`ICECandidates`/`End` handlers, and the `remoteSdp`/`remoteIceCandidates`/`webrtcError` event resources are all unchanged. +- No change to the `negotiationRole` resource's type or modes (it remains a `read` `string` on `ep/webrtc`). +- No new streaming protocols, camera features, or session-lifecycle changes. + +## Impact + +- **Core driver** (`core/deviceDrivers/matter/sbmd/specs/camera.sbmd.js`): `pickNegotiationRole` / `readNegotiationRole` and the `localSdp` flow-selection logic; the `ROLE_OFFERER`/`ROLE_ANSWERER` usage and the endpoint/flow comments. +- **Reference app** (`reference/src/cameraCategory.c`, `cameraDeviceSession.c` / `.h`, `cameraWebrtcClient.*` comments): the role interpretation, which becomes `answerer = (cameraRole == "offerer")`. +- **Specs**: `openspec/specs/webrtc-signaling-endpoint/spec.md` and `openspec/specs/camera-stream-reference-command/spec.md`. +- **Tests**: SBMD camera unit test(s) that assert `negotiationRole`, and any Python integration test asserting the role value. +- **CMake flags**: the reference-app portion is gated by `BCORE_REFERENCE_CAMERA_SUPPORT`; the driver portion is always built. +- **Compatibility**: the only in-tree consumer of `negotiationRole` is the reference app, which is updated in lockstep, so no runtime mismatch ships. The contract inversion is nonetheless breaking for any independent client that assumed the old client-perspective semantics. diff --git a/openspec/changes/archive/2026-08-03-negotiation-role-camera-perspective/specs/camera-stream-reference-command/spec.md b/openspec/changes/archive/2026-08-03-negotiation-role-camera-perspective/specs/camera-stream-reference-command/spec.md new file mode 100644 index 00000000..767cff12 --- /dev/null +++ b/openspec/changes/archive/2026-08-03-negotiation-role-camera-perspective/specs/camera-stream-reference-command/spec.md @@ -0,0 +1,34 @@ +## MODIFIED Requirements + +### Requirement: cameraStream orchestrates full session lifecycle + +The `cameraStream` command SHALL orchestrate the complete camera streaming flow through Barton's resource API: +1. Execute `createSession` on the device's `ep/camera` endpoint +2. Execute `stream` and obtain the active protocol and entry-point URI from its `{ protocol, entryPoint }` result +3. Read the `negotiationRole` resource on `ep/webrtc` — which reports the **camera's** role — and adopt the opposite role for itself: act as the `answerer` when the camera is the `offerer`, or as the `offerer` when the camera is the `answerer` +4. Create a local GStreamer `webrtcbin` peer connection using host candidates only (no STUN/TURN), configured for the client's derived role +5. Perform the SDP exchange for the client's derived role via the `localSdp` resource on `ep/webrtc`: + - **Client is the offerer** (camera reported `answerer`): generate a local SDP offer from webrtcbin, execute `localSdp` with it, then wait for a `remoteSdp` event (the camera's answer) and set it as the remote description + - **Client is the answerer** (camera reported `offerer`): execute `localSdp` with empty input to open the flow, wait for a `remoteSdp` event (the camera's offer), set it as the remote description, generate a local SDP answer, and execute `localSdp` with the answer +6. Exchange ICE candidates (local → `localIceCandidates`, remote ← `remoteIceCandidates` events) +7. Wait for the peer connection to reach the connected state, subject to a bounded connectivity timeout +8. Route the received media to the destination selected by `--out`: serve it over the built-in HTTP server or record it to a file +9. On user interrupt (Ctrl+C) or a `webrtcError` event: execute `destroySession` and tear down the pipeline + +#### Scenario: Client adopts the opposite of the camera's role +- **WHEN** the `negotiationRole` read returns `offerer` (the camera is the offerer) +- **THEN** the reference app SHALL configure `webrtcbin` as the `answerer` and answer the camera's offer +- **WHEN** the `negotiationRole` read returns `answerer` (the camera is the answerer) +- **THEN** the reference app SHALL configure `webrtcbin` as the `offerer` and generate the SDP offer + +#### Scenario: Successful camera stream served to a browser +- **WHEN** a user executes `cameraStream ` (or with an `http://` `--out`) and the camera responds to signaling +- **THEN** the reference app SHALL serve the live stream over its built-in HTTP server for a browser to play and print status messages for each step, without opening a local display window + +#### Scenario: Successful camera stream to file +- **WHEN** a user executes `cameraStream --out file://recording.mp4` +- **THEN** the reference app SHALL record the video stream to the specified file path + +#### Scenario: User stops the stream +- **WHEN** a user presses Ctrl+C during an active stream +- **THEN** the reference app SHALL execute `destroySession`, stop the GStreamer pipeline, and return to the command prompt diff --git a/openspec/changes/archive/2026-08-03-negotiation-role-camera-perspective/specs/webrtc-signaling-endpoint/spec.md b/openspec/changes/archive/2026-08-03-negotiation-role-camera-perspective/specs/webrtc-signaling-endpoint/spec.md new file mode 100644 index 00000000..7b542442 --- /dev/null +++ b/openspec/changes/archive/2026-08-03-negotiation-role-camera-perspective/specs/webrtc-signaling-endpoint/spec.md @@ -0,0 +1,45 @@ +## MODIFIED Requirements + +### Requirement: WebRTC endpoint declares signaling resources + +The camera SBMD driver SHALL declare an endpoint with id `"webrtc"` and profile `"webrtc"` containing six resources: + +| Resource | Type | Modes | Purpose | +|----------|------|-------|---------| +| `localSdp` | `function` | execute | Client posts its local SDP (offer or answer) to drive signaling | +| `negotiationRole` | `string` | [read] | Reports the **camera's** negotiation role (`offerer` or `answerer`); the client adopts the opposite role | +| `remoteSdp` | `string` | [] (events only) | Delivers the camera's remote SDP (offer or answer) to client | +| `localIceCandidates` | `function` | execute | Client sends local ICE candidates | +| `remoteIceCandidates` | `string` | [] (events only) | Delivers remote ICE candidates to client | +| `webrtcError` | `string` | [volatile] (events only) | Delivers asynchronous session termination/error to client | + +The endpoint SHALL be declared within the same `camera.sbmd.js` file as the `ep/camera` endpoint. The `webrtcError` resource SHALL be declared with the `volatile` mode so that its events are emitted unconditionally (non-cached), independent of the previously emitted value. + +#### Scenario: Endpoint appears on commissioned camera device +- **WHEN** a Matter camera device (deviceType 0x0142) with WebRTCTransportProvider cluster (0x0553) is commissioned +- **THEN** the device SHALL have an endpoint with id `"webrtc"`, profile `"webrtc"`, and all six resources registered + +#### Scenario: Event-only resources are not readable +- **WHEN** a client attempts to read `remoteSdp`, `remoteIceCandidates`, or `webrtcError` +- **THEN** the read SHALL fail or return no value (modes list is empty — no read mode) + +### Requirement: negotiationRole read reports the camera's role + +The `negotiationRole` read handler SHALL report the **camera's** WebRTC negotiation role — `offerer` when the camera generates the SDP offer, or `answerer` when the camera answers the client's offer — derived from the camera's advertised WebRTCTransportProvider `AcceptedCommandList`. When the camera accepts `SolicitOffer` the role SHALL be `offerer` (the camera generates the offer); otherwise, when the camera accepts `ProvideOffer`, the role SHALL be `answerer` (the camera answers). When the accepted-command list is unavailable, the handler SHALL default to `offerer` (the SolicitOffer flow). The resource describes the camera because `ep/webrtc` is the camera's data model; the consuming client is responsible for adopting the opposite role. The negotiation role is a WebRTC concept and lives on the `webrtc` endpoint, not in the abstract `stream` result. + +#### Scenario: Camera supporting SolicitOffer reports offerer +- **WHEN** a client reads `negotiationRole` and the camera's `AcceptedCommandList` includes `SolicitOffer` +- **THEN** the read SHALL return `offerer` (the camera generates the offer and the client answers) + +#### Scenario: Camera supporting only ProvideOffer reports answerer +- **WHEN** a client reads `negotiationRole` and the camera's `AcceptedCommandList` includes `ProvideOffer` but not `SolicitOffer` +- **THEN** the read SHALL return `answerer` (the camera answers and the client offers) + +#### Scenario: Unavailable accepted-command list defaults to offerer +- **WHEN** a client reads `negotiationRole` and the camera's `AcceptedCommandList` is unavailable +- **THEN** the read SHALL return `offerer` (the default SolicitOffer flow, in which the camera generates the offer) + +## RENAMED Requirements + +- FROM: `### Requirement: negotiationRole read reports the client's role` +- TO: `### Requirement: negotiationRole read reports the camera's role` diff --git a/openspec/changes/archive/2026-08-03-negotiation-role-camera-perspective/tasks.md b/openspec/changes/archive/2026-08-03-negotiation-role-camera-perspective/tasks.md new file mode 100644 index 00000000..0619f4ed --- /dev/null +++ b/openspec/changes/archive/2026-08-03-negotiation-role-camera-perspective/tasks.md @@ -0,0 +1,30 @@ +## 1. Driver contract inversion (camera.sbmd.js) + +- [x] 1.1 Add an intrinsic `cameraIsOfferer(args)` predicate deriving from `providerAcceptedCommands`: `true` when `SolicitOffer` is accepted OR the accepted-command list is unavailable (default SolicitOffer flow), `false` when only `ProvideOffer` is accepted +- [x] 1.2 Update `readNegotiationRole` to report the camera's role via the predicate: `cameraIsOfferer ? ROLE_OFFERER : ROLE_ANSWERER` +- [x] 1.3 Rekey `executeLocalSdp`'s SolicitOffer/ProvideOffer branch on `cameraIsOfferer` and confirm the emitted Matter command sequence is identical to today's (SolicitOffer flow when the camera is the offerer) +- [x] 1.4 Update driver docblocks and `ROLE_OFFERER`/`ROLE_ANSWERER` comments (client-flow section, endpoint description) to the camera-perspective wording +- [x] 1.5 Validate the SBMD spec file (`validate-sbmd`/build-time validation) — no schema regressions + +## 2. Driver tests (unit) + +- [x] 2.1 Update the SBMD camera unit test's `negotiationRole` expectations: `SolicitOffer` → `offerer`, `ProvideOffer`-only → `answerer`, unavailable list → `offerer` +- [x] 2.2 Add/adjust a test asserting `executeLocalSdp` still selects the SolicitOffer flow when the camera is the offerer and ProvideOffer when it is the answerer (flow unchanged) +- [x] 2.3 Run the camera SBMD unit tests via `ctest` and confirm green + +## 3. Reference app interpretation (gated by BCORE_REFERENCE_CAMERA_SUPPORT) + +- [x] 3.1 In `reference/src/cameraCategory.c`, invert the single mapping to `answerer = (g_strcmp0(role, "offerer") == 0)` and update the adjacent comment (client answers when the camera offers) +- [x] 3.2 Update role-describing comments/docs in `reference/src/cameraDeviceSession.c` / `.h` and `reference/src/cameraWebrtcClient.c` / `.h` to the camera perspective (the resource reports the camera's role; the client adopts the opposite) +- [x] 3.3 Build the reference app with `-DBCORE_REFERENCE_CAMERA_SUPPORT=ON` and confirm it compiles and formats clean + +## 4. Spec + docs sync + +- [x] 4.1 Confirm the `webrtc-signaling-endpoint` and `camera-stream-reference-command` delta specs in this change match the implemented behavior; run `openspec validate negotiation-role-camera-perspective --strict` +- [x] 4.2 Grep the driver, reference app, and specs for any remaining "client's role" / stale `offerer`/`answerer` wording and reconcile (canonical `specs/` are corrected by this change's delta on archive; `camera-stream` remoteSdp wording legitimately describes the client's own role) + +## 5. End-to-end verification + +- [x] 5.1 Confirm all camera unit tests pass (must precede integration/manual checks) +- [x] 5.2 Update any Python integration test that asserts the `negotiationRole` value to the camera perspective, and run it (requires Docker) — no integration test asserts `negotiationRole`, so none to update +- [x] 5.3 Manual end-to-end run against a camera (requires Docker + a camera source, e.g. `chip-camera-app` on `/dev/video0`): commission, run `cs `, and confirm the role reads `offerer`, negotiation completes, and media flows to the HTTP server / recording diff --git a/openspec/specs/camera-session-lifecycle/spec.md b/openspec/specs/camera-session-lifecycle/spec.md new file mode 100644 index 00000000..dd6400b6 --- /dev/null +++ b/openspec/specs/camera-session-lifecycle/spec.md @@ -0,0 +1,57 @@ +# camera-session-lifecycle Specification + +## Purpose +The protocol-agnostic abstract camera endpoint contract: the session lifecycle executes (`createSession`, `stream`, `takePicture`, `destroySession`) with no `sessionStatus` resource. The `stream` execute returns the active protocol and its entry-point URI; all in-session state, error, and teardown signaling lives on the protocol-specific endpoint (e.g. `ep/webrtc`), not the abstract one. + +## Requirements +### Requirement: Abstract camera endpoint provides protocol-agnostic session lifecycle + +The camera SBMD driver SHALL declare an endpoint with id `"camera"` and profile `"camera"` exposing the session lifecycle as execute resources only: `createSession`, `stream`, `takePicture`, and `destroySession`. The abstract endpoint SHALL NOT declare a `sessionStatus` resource, and SHALL NOT carry any protocol-specific signaling or in-session state. All in-session state, error, and teardown signaling SHALL live on the protocol-specific endpoint (e.g. `ep/webrtc`). + +#### Scenario: Camera endpoint exposes only lifecycle executes +- **WHEN** a Matter camera device (deviceType 0x0142) is commissioned +- **THEN** the device SHALL have an endpoint with id `"camera"` exposing `createSession`, `stream`, `takePicture`, and `destroySession` as execute resources AND SHALL NOT expose a `sessionStatus` resource + +#### Scenario: No protocol coupling on the abstract endpoint +- **WHEN** the `ep/camera` endpoint is inspected +- **THEN** none of its resources SHALL reference a specific streaming protocol; protocol identity is carried only in the `stream` execute result and on the protocol endpoint + +### Requirement: createSession allocates a session and returns its identifier + +The `createSession` execute handler SHALL allocate a new session, persist it in transient data, and return the new `sessionId` synchronously as the execute result so the client holds a correlation identifier before invoking any further resource. + +#### Scenario: Client creates a session +- **WHEN** a client executes `createSession` +- **THEN** the handler SHALL return a non-empty `sessionId` string AND record the session in transient data with an initial state + +#### Scenario: Corrupt session data is reset +- **WHEN** `createSession` is executed and the stored session data cannot be parsed +- **THEN** the handler SHALL reset the session store and return an error result + +### Requirement: stream execute returns the active protocol and entry point + +The `stream` execute handler SHALL mark the identified session as streaming and return, as its synchronous execute result, a JSON object identifying the active protocol and the entry-point resource URI the client must use next: `{ "protocol": "", "entryPoint": "//ep//r/" }`. The handler SHALL NOT emit a separate event to convey the next action. + +#### Scenario: Stream returns protocol and entry point for a WebRTC camera +- **WHEN** a client executes `stream` with a valid `sessionId` on a Matter WebRTC camera +- **THEN** the handler SHALL return `{ "protocol": "webrtc", "entryPoint": "//ep/webrtc/r/localSdp" }` AND mark the session `streaming` + +#### Scenario: Stream on unknown session +- **WHEN** a client executes `stream` with a `sessionId` that does not exist +- **THEN** the handler SHALL return an error result and SHALL NOT mark any session streaming + +### Requirement: destroySession releases session state + +The `destroySession` execute handler SHALL remove the identified session from transient data and trigger any protocol-specific teardown required for a session that reached streaming. + +#### Scenario: Client destroys a session +- **WHEN** a client executes `destroySession` with a valid `sessionId` +- **THEN** the handler SHALL remove the session from transient data + +### Requirement: Client discovers next steps without server-pushed status events + +A client SHALL be able to drive the full session flow using only execute results and protocol-endpoint event subscriptions, without reading or subscribing to any status resource on the abstract endpoint. + +#### Scenario: Reference client drives flow without sessionStatus +- **WHEN** the reference app runs a camera stream +- **THEN** it SHALL obtain the protocol and entry point from the `stream` execute result and subscribe to protocol-endpoint events, and SHALL NOT subscribe to any `sessionStatus` resource diff --git a/openspec/specs/camera-stream-reference-command/spec.md b/openspec/specs/camera-stream-reference-command/spec.md new file mode 100644 index 00000000..56f8ee74 --- /dev/null +++ b/openspec/specs/camera-stream-reference-command/spec.md @@ -0,0 +1,148 @@ +# camera-stream-reference-command Specification + +## Purpose +The reference app's `cameraStream` (`cs`) command drives a Matter camera through Barton's resource API — creating a session, performing the WebRTC signaling handshake, and acting as the in-container WebRTC peer — then routes the received media to a destination selected by `--out` (record to a file or serve over HTTP to a browser). It uses only `BCoreClient` APIs and is gated behind a CMake option. +## Requirements +### Requirement: cameraStream command exists in reference app + +The reference app SHALL provide a command named `cameraStream` with short alias `cs` in a dedicated camera command category. The command SHALL accept a device ID as a required argument and an optional `--out ` flag that selects the media destination. + +#### Scenario: Command appears in help +- **WHEN** a user types `help` in the reference app +- **THEN** the camera category SHALL list `cameraStream` with usage: ` [--out ]` + +#### Scenario: Command with short alias +- **WHEN** a user types `cs ` +- **THEN** the command SHALL execute identically to `cameraStream ` + +#### Scenario: Output URI selects the media destination +- **WHEN** the command is invoked with `--out file://` +- **THEN** the stream SHALL be recorded to that file path +- **WHEN** the command is invoked with `--out [:]` (optionally prefixed with `http://`), or without `--out` +- **THEN** the stream SHALL be served over HTTP for a browser to play, defaulting to a loopback host and port when `--out` is omitted + +### Requirement: cameraStream orchestrates full session lifecycle + +The `cameraStream` command SHALL orchestrate the complete camera streaming flow through Barton's resource API: +1. Execute `createSession` on the device's `ep/camera` endpoint +2. Execute `stream` and obtain the active protocol and entry-point URI from its `{ protocol, entryPoint }` result +3. Read the `negotiationRole` resource on `ep/webrtc` — which reports the **camera's** role — and adopt the opposite role for itself: act as the `answerer` when the camera is the `offerer`, or as the `offerer` when the camera is the `answerer` +4. Create a local GStreamer `webrtcbin` peer connection using host candidates only (no STUN/TURN), configured for the client's derived role +5. Perform the SDP exchange for the client's derived role via the `localSdp` resource on `ep/webrtc`: + - **Client is the offerer** (camera reported `answerer`): generate a local SDP offer from webrtcbin, execute `localSdp` with it, then wait for a `remoteSdp` event (the camera's answer) and set it as the remote description + - **Client is the answerer** (camera reported `offerer`): execute `localSdp` with empty input to open the flow, wait for a `remoteSdp` event (the camera's offer), set it as the remote description, generate a local SDP answer, and execute `localSdp` with the answer +6. Exchange ICE candidates (local → `localIceCandidates`, remote ← `remoteIceCandidates` events) +7. Wait for the peer connection to reach the connected state, subject to a bounded connectivity timeout +8. Route the received media to the destination selected by `--out`: serve it over the built-in HTTP server or record it to a file +9. On user interrupt (Ctrl+C) or a `webrtcError` event: execute `destroySession` and tear down the pipeline + +#### Scenario: Client adopts the opposite of the camera's role +- **WHEN** the `negotiationRole` read returns `offerer` (the camera is the offerer) +- **THEN** the reference app SHALL configure `webrtcbin` as the `answerer` and answer the camera's offer +- **WHEN** the `negotiationRole` read returns `answerer` (the camera is the answerer) +- **THEN** the reference app SHALL configure `webrtcbin` as the `offerer` and generate the SDP offer + +#### Scenario: Successful camera stream served to a browser +- **WHEN** a user executes `cameraStream ` (or with an `http://` `--out`) and the camera responds to signaling +- **THEN** the reference app SHALL serve the live stream over its built-in HTTP server for a browser to play and print status messages for each step, without opening a local display window + +#### Scenario: Successful camera stream to file +- **WHEN** a user executes `cameraStream --out file://recording.mp4` +- **THEN** the reference app SHALL record the video stream to the specified file path + +#### Scenario: User stops the stream +- **WHEN** a user presses Ctrl+C during an active stream +- **THEN** the reference app SHALL execute `destroySession`, stop the GStreamer pipeline, and return to the command prompt + +### Requirement: cameraStream uses only BCoreClient API for signaling + +The `cameraStream` command SHALL interact with Barton exclusively through `BCoreClient` APIs (`b_core_client_execute_resource`, event subscriptions). It SHALL NOT use Matter SDK APIs, link against Matter libraries, or reference Matter-specific types. + +#### Scenario: No Matter SDK dependency +- **WHEN** the reference app is compiled +- **THEN** the camera stream module SHALL compile without any Matter SDK headers in its include path + +### Requirement: cameraStream uses GStreamer webrtcbin for media + +The `cameraStream` command SHALL use GStreamer's `webrtcbin` element as its local WebRTC peer connection (host candidates only). The received H.264 SHALL be handled by a passthrough pipeline that neither decodes nor renders locally: `rtph264depay → h264parse → h264timestamper → capsfilter → mp4mux` (fragmented, streamable) `→ appsink`. The `h264timestamper` reconstructs the PTS/DTS the camera's RTP buffers lack (so `mp4mux` does not abort on a missing PTS), and the `capsfilter` forces AVC / `alignment=au` output so `mp4mux` can negotiate. The muxed fragmented-MP4 buffers SHALL be delivered either to the built-in HTTP media server or to a file, according to `--out`. + +#### Scenario: Serve mode pipeline +- **WHEN** `cameraStream` runs in serve mode (the default, or an `http://` `--out`) +- **THEN** the muxed fragmented-MP4 buffers SHALL be pushed to the built-in HTTP media server, which serves them to a browser that decodes and plays them via Media Source Extensions + +#### Scenario: Record mode pipeline +- **WHEN** `cameraStream` is invoked with `--out file://` +- **THEN** the muxed fragmented-MP4 buffers SHALL be written to the file at that path + +#### Scenario: GStreamer not available +- **WHEN** GStreamer libraries (with `webrtcbin`) are not available at runtime +- **THEN** the command SHALL print an error explaining the requirement and exit gracefully + +### Requirement: cameraStream subscribes to Barton events + +The `cameraStream` command SHALL subscribe to resource events on the device to receive signaling data asynchronously. Specifically: +- `remoteSdp` events on `ep/webrtc` (for the camera's remote SDP — an answer when the client is the offerer, or an offer when the client is the answerer) +- `remoteIceCandidates` events on `ep/webrtc` (for the camera's ICE candidates) +- `webrtcError` events on `ep/webrtc` (for asynchronous session termination and errors) + +The command SHALL NOT subscribe to any `sessionStatus` resource on `ep/camera`. It SHALL obtain the active protocol and entry-point URI from the `stream` execute result, and its negotiation role by reading the `negotiationRole` resource on `ep/webrtc`. + +#### Scenario: Remote SDP delivered via event +- **WHEN** the camera provides its remote SDP (an answer when the client offered, or an offer when the client is the answerer) +- **THEN** the reference app SHALL receive it as a `remoteSdp` event and feed it to webrtcbin as the remote description + +#### Scenario: Remote ICE candidates delivered via events +- **WHEN** the camera sends ICE candidates +- **THEN** the reference app SHALL receive them as `remoteIceCandidates` events and add each candidate to webrtcbin + +#### Scenario: Session error delivered via webrtcError event +- **WHEN** the driver emits a `webrtcError` event with an ended or failed value +- **THEN** the reference app SHALL treat it as a session-terminated signal and begin graceful teardown, reporting the reason from the event metadata + +### Requirement: cameraStream reports progress to user + +The command SHALL emit human-readable progress messages to stdout at each stage of the flow: +- Session created (sessionId) +- Streaming initiated (protocol, entryPoint) +- Local SDP sent (the offer, or the answer to the camera's offer) +- Remote SDP received (the camera's answer or offer) +- ICE candidates exchanged +- Media flowing / connected +- Stream ended (reason) + +#### Scenario: Progress output during successful stream +- **WHEN** `cameraStream` completes signaling and media begins flowing +- **THEN** the user SHALL see step-by-step status messages indicating progress through the flow + +### Requirement: cameraStream handles errors gracefully + +The command SHALL handle failures at any stage (session creation failure, signaling timeout, asynchronous `webrtcError` failure, and connectivity/ICE failure) by printing an error message, cleaning up any partial state (destroying the session if created), and returning to the command prompt. In particular, because Matter signaling cannot observe media-plane connectivity, the command SHALL enforce a client-side connectivity timeout: if the WebRTC peer connection does not reach a connected state within a bounded window after signaling completes — or transitions to a failed state — the command SHALL tear down and report the failure rather than wait indefinitely. + +#### Scenario: Device does not support camera streaming +- **WHEN** `cameraStream` is executed on a device without a `camera` endpoint +- **THEN** the command SHALL print an error and exit without crashing + +#### Scenario: Signaling timeout +- **WHEN** the camera does not respond to signaling within a reasonable timeout +- **THEN** the command SHALL print a timeout error, destroy the session, and exit + +#### Scenario: Connectivity never established +- **WHEN** signaling completes but the WebRTC peer connection does not reach a connected state within the connectivity timeout window +- **THEN** the command SHALL print a connectivity-failure message, destroy the session, and exit rather than hang on a blank window + +#### Scenario: Peer connection fails +- **WHEN** the WebRTC peer connection transitions to a failed state during or after ICE exchange +- **THEN** the command SHALL print a failure message, destroy the session, and exit + +### Requirement: Camera command category is gated by CMake flag + +The camera stream command and its GStreamer dependencies SHALL be gated behind a `BCORE_REFERENCE_CAMERA_SUPPORT` CMake option (default OFF). When disabled, the reference app builds without GStreamer dependencies and without the camera category. + +#### Scenario: Build without camera stream support +- **WHEN** `BCORE_REFERENCE_CAMERA_SUPPORT=OFF` (default) +- **THEN** the reference app SHALL build successfully without GStreamer development libraries + +#### Scenario: Build with camera stream support +- **WHEN** `BCORE_REFERENCE_CAMERA_SUPPORT=ON` +- **THEN** the reference app SHALL link against gstreamer-1.0, gstreamer-webrtc-1.0, gstreamer-sdp-1.0, gstreamer-app-1.0, and gio-2.0, and include the camera category + diff --git a/openspec/specs/sbmd-v4-runtime/spec.md b/openspec/specs/sbmd-v4-runtime/spec.md index 4f80b964..10ab1617 100644 --- a/openspec/specs/sbmd-v4-runtime/spec.md +++ b/openspec/specs/sbmd-v4-runtime/spec.md @@ -152,3 +152,19 @@ Aliases declared in the `aliases` section SHALL be resolved to cluster+ID pairs #### Scenario: Event alias prerequisite check - **WHEN** a resource has `prerequisites: ["lockOperation"]` and `lockOperation` is an event alias with `clusterId: 0x0101` - **THEN** the prerequisite is satisfied if cluster 0x0101 is present in the device's data cache + +### Requirement: volatile resource mode disables value caching + +The SBMD runtime SHALL support a resource mode `volatile`. A resource declared with the `volatile` mode SHALL be registered with `CACHING_POLICY_NEVER`, causing `updateResource` to emit a `resourceUpdated` event on every call (for a resource that emits events) regardless of whether the new value equals the currently stored value. The `volatile` mode SHALL NOT, by itself, add read, write, or execute access, and SHALL be accepted by mode-to-bitmask conversion without error. A resource is registered `CACHING_POLICY_NEVER` when it declares a read handler OR declares the `volatile` mode; otherwise it is registered `CACHING_POLICY_ALWAYS`. + +#### Scenario: Volatile resource emits on unchanged value +- **WHEN** a resource declared with `modes: ['volatile']` is updated twice with the same value +- **THEN** the runtime SHALL deliver two `resourceUpdated` events (no value-change suppression) + +#### Scenario: Volatile mode is accepted by the schema and runtime +- **WHEN** a driver declares a resource with `volatile` in its modes array +- **THEN** the spec SHALL validate against the SBMD schema AND the resource SHALL register successfully without adding read, write, or execute modes + +#### Scenario: Non-volatile resource without a read handler remains cached +- **WHEN** an event-only resource is declared without a read handler and without the `volatile` mode +- **THEN** the runtime SHALL register it with `CACHING_POLICY_ALWAYS` and suppress `resourceUpdated` events whose value is unchanged diff --git a/openspec/specs/webrtc-signaling-endpoint/spec.md b/openspec/specs/webrtc-signaling-endpoint/spec.md new file mode 100644 index 00000000..9b4c599d --- /dev/null +++ b/openspec/specs/webrtc-signaling-endpoint/spec.md @@ -0,0 +1,176 @@ +# webrtc-signaling-endpoint Specification + +## Purpose +The camera SBMD driver's WebRTC protocol endpoint (`ep/webrtc`): the signaling resources (`localSdp`, `remoteSdp`, `localIceCandidates`, `remoteIceCandidates`) that relay SDP and ICE between a Barton client and a Matter camera's WebRTC Transport clusters, the `negotiationRole` resource that tells the client whether it is the offerer or answerer, plus the `webrtcError` event that surfaces asynchronous session termination and failures. The endpoint supports both Matter WebRTC negotiation flows — client-offers (`ProvideOffer`) and camera-offers (`SolicitOffer` + `ProvideAnswer`) — behind a single client-facing contract. All in-session state and error signaling for the WebRTC protocol lives here rather than on the abstract camera endpoint. +## Requirements +### Requirement: WebRTC endpoint declares signaling resources + +The camera SBMD driver SHALL declare an endpoint with id `"webrtc"` and profile `"webrtc"` containing six resources: + +| Resource | Type | Modes | Purpose | +|----------|------|-------|---------| +| `localSdp` | `function` | execute | Client posts its local SDP (offer or answer) to drive signaling | +| `negotiationRole` | `string` | [read] | Reports the **camera's** negotiation role (`offerer` or `answerer`); the client adopts the opposite role | +| `remoteSdp` | `string` | [] (events only) | Delivers the camera's remote SDP (offer or answer) to client | +| `localIceCandidates` | `function` | execute | Client sends local ICE candidates | +| `remoteIceCandidates` | `string` | [] (events only) | Delivers remote ICE candidates to client | +| `webrtcError` | `string` | [volatile] (events only) | Delivers asynchronous session termination/error to client | + +The endpoint SHALL be declared within the same `camera.sbmd.js` file as the `ep/camera` endpoint. The `webrtcError` resource SHALL be declared with the `volatile` mode so that its events are emitted unconditionally (non-cached), independent of the previously emitted value. + +#### Scenario: Endpoint appears on commissioned camera device +- **WHEN** a Matter camera device (deviceType 0x0142) with WebRTCTransportProvider cluster (0x0553) is commissioned +- **THEN** the device SHALL have an endpoint with id `"webrtc"`, profile `"webrtc"`, and all six resources registered + +#### Scenario: Event-only resources are not readable +- **WHEN** a client attempts to read `remoteSdp`, `remoteIceCandidates`, or `webrtcError` +- **THEN** the read SHALL fail or return no value (modes list is empty — no read mode) + +### Requirement: localSdp execute drives role-appropriate signaling + +The `localSdp` execute handler SHALL determine the client's negotiation role from the camera's advertised WebRTCTransportProvider commands (its `AcceptedCommandList`) and drive the corresponding Matter signaling. In every case that requires it, the handler SHALL first allocate a video stream via `VideoStreamAllocate` (cluster 0x0551, command 0x03) before the WebRTC-provider command, and SHALL pass the requestor's `originatingEndpointID` (the endpoint hosting the `WebRTCTransportRequestor` cluster) so the camera knows where to send its commands. + +- **Offerer flow** (camera accepts `ProvideOffer`): the execute input is the client's SDP offer. The handler SHALL allocate a video stream and then send a `ProvideOffer` command (ID 0x02) to the camera's `WebRTCTransportProvider` cluster (0x0553), carrying the SDP and the allocated `videoStreamID`. +- **Answerer flow** (camera accepts `SolicitOffer`): while no camera `webRTCSessionID` has been recorded yet, an execute (with empty input) SHALL allocate a video stream and then send a `SolicitOffer` command (ID 0x00) so the camera generates the offer. Once the camera's offer has arrived (its `webRTCSessionID` recorded), a subsequent execute SHALL carry the client's SDP answer and send a `ProvideAnswer` command (ID 0x04) with the SDP and the recorded `webRTCSessionID`. + +#### Scenario: Offerer posts an SDP offer +- **WHEN** the camera accepts `ProvideOffer` and a client executes `localSdp` with a valid SDP offer +- **THEN** the handler SHALL allocate a video stream and send a `ProvideOffer` command to the camera with the SDP and the allocated `videoStreamID` + +#### Scenario: Answerer opens the flow +- **WHEN** the camera accepts `SolicitOffer` and a client executes `localSdp` with empty input before any camera offer has arrived +- **THEN** the handler SHALL allocate a video stream and send a `SolicitOffer` command so the camera generates the offer + +#### Scenario: Answerer posts its SDP answer +- **WHEN** the camera has offered (its `webRTCSessionID` is recorded) and a client executes `localSdp` with an SDP answer +- **THEN** the handler SHALL send a `ProvideAnswer` command to the camera with the SDP and the recorded `webRTCSessionID` + +#### Scenario: No active session +- **WHEN** a client executes `localSdp` but no session is in `streaming` state +- **THEN** the handler SHALL return an error result + +### Requirement: localIceCandidates execute sends ProvideICECandidates to camera + +The `localIceCandidates` execute handler SHALL send a `ProvideICECandidates` command (ID 0x05) to the camera's `WebRTCTransportProvider` cluster (0x0553). The execute input is a JSON-encoded array of ICE candidate strings. + +#### Scenario: Client provides ICE candidates +- **WHEN** a client executes `localIceCandidates` with a JSON array of ICE candidate strings +- **THEN** the SBMD handler SHALL send a `ProvideICECandidates` command to the camera with the candidates in the `ICECandidates` field + +### Requirement: Incoming Offer and Answer commands emit remoteSdp event + +The SBMD driver SHALL register command handlers for both the `Offer` command (ID 0x00) and the `Answer` command (ID 0x01) on the `WebRTCTransportRequestor` cluster (0x0554). When either is received, the handler SHALL extract the SDP string, record the command's `webRTCSessionID` on the active session, and emit the SDP as an event on the `remoteSdp` resource of the `webrtc` endpoint. The `Offer` command carries the camera's offer (SolicitOffer flow); the `Answer` command carries the camera's answer (ProvideOffer flow). + +#### Scenario: Camera sends its offer (SolicitOffer flow) +- **WHEN** the camera sends an `Offer` command (cluster 0x0554, command 0x00) containing an SDP string +- **THEN** the SBMD handler SHALL record the `webRTCSessionID` AND call `updateResource('webrtc', 'remoteSdp', sdpString)` to emit an event to subscribed clients + +#### Scenario: Camera sends its answer (ProvideOffer flow) +- **WHEN** the camera sends an `Answer` command (cluster 0x0554, command 0x01) containing an SDP string +- **THEN** the SBMD handler SHALL record the `webRTCSessionID` AND call `updateResource('webrtc', 'remoteSdp', sdpString)` to emit an event to subscribed clients + +### Requirement: Incoming ICECandidates command emits remoteIceCandidates event + +The SBMD driver SHALL register a command handler for the `ICECandidates` command (ID 0x02) on the `WebRTCTransportRequestor` cluster (0x0554). When received, the handler SHALL extract the candidate list and emit it as a JSON-encoded array on the `remoteIceCandidates` resource. + +#### Scenario: Camera sends ICE candidates +- **WHEN** the camera sends an `ICECandidates` command (cluster 0x0554, command 0x02) containing ICE candidates +- **THEN** the SBMD handler SHALL call `updateResource('webrtc', 'remoteIceCandidates', jsonCandidates)` to emit an event to subscribed clients + +### Requirement: webrtcError resource emits every event unconditionally + +The `webrtcError` resource SHALL be declared with the `volatile` mode, which registers it with `CACHING_POLICY_NEVER` so that `updateResource` bypasses value-change detection and each emission delivers a `resourceUpdated` event to subscribers even when consecutive values are identical (including across sessions where a prior value persists). + +#### Scenario: Repeated identical values still emit +- **WHEN** the driver emits `webrtcError` twice in succession with the same value +- **THEN** the client SHALL receive two distinct `resourceUpdated` events (no no-change suppression) + +### Requirement: Incoming End command emits webrtcError event + +The SBMD driver SHALL register a command handler for the `End` command (ID 0x03) on the `WebRTCTransportRequestor` cluster (0x0554). When received, the handler SHALL clean up the associated session and emit a `webrtcError` event on the `webrtc` endpoint with a value indicating the session ended and metadata carrying the reason. + +#### Scenario: Camera ends session +- **WHEN** the camera sends an `End` command with a reason code +- **THEN** the SBMD handler SHALL call `updateResource('webrtc', 'webrtcError', , { "reason": "", "detail": "" })` AND remove the associated session from transient data + +### Requirement: Asynchronous signaling failures emit webrtcError event + +Each asynchronous WebRTC signaling failure that occurs after the originating execute has returned SHALL emit a `webrtcError` event so the client is notified rather than left to time out. This SHALL cover at least: a `VideoStreamAllocate` error, a `ProvideOffer` error (offerer flow), a `SolicitOffer` error (answerer flow), and a `requestCommand` overall-deadline timeout in the signaling flow. + +#### Scenario: VideoStreamAllocate rejected by camera +- **WHEN** the camera rejects the `VideoStreamAllocate` command during the `localSdp` flow +- **THEN** the SBMD handler SHALL emit a `webrtcError` event with a failure value and metadata describing the allocate error + +#### Scenario: ProvideOffer rejected by camera +- **WHEN** the camera rejects the `ProvideOffer` command during the offerer (`ProvideOffer`) flow +- **THEN** the SBMD handler SHALL emit a `webrtcError` event with a failure value and metadata describing the provide-offer error + +#### Scenario: SolicitOffer rejected by camera +- **WHEN** the camera rejects the `SolicitOffer` command during the answerer (`SolicitOffer`) flow +- **THEN** the SBMD handler SHALL emit a `webrtcError` event with a failure value and metadata describing the solicit-offer error + +#### Scenario: Signaling command times out +- **WHEN** a `requestCommand` in the `localSdp` flow exceeds its overall deadline +- **THEN** the SBMD handler SHALL emit a `webrtcError` event with a failure value and a timeout reason + +### Requirement: destroySession sends EndSession to camera + +When the camera session endpoint's `destroySession` is executed for a session that has progressed to WebRTC signaling, the handler SHALL send an `EndSession` command (ID 0x06) to the camera's `WebRTCTransportProvider` cluster (0x0553) before cleaning up local session state. + +#### Scenario: Client destroys active streaming session +- **WHEN** a client executes `destroySession` for a session in `streaming` state +- **THEN** the handler SHALL send `EndSession` to the camera AND remove the session from transient data + +#### Scenario: Client destroys session that never started streaming +- **WHEN** a client executes `destroySession` for a session in `created` state (never executed `stream`) +- **THEN** the handler SHALL only remove the session from transient data (no Matter command needed) + +### Requirement: WebRTC constants use correct Matter cluster and command IDs + +The SBMD driver SHALL define constants for all WebRTC cluster and command identifiers: + +| Constant | Value | Description | +|----------|-------|-------------| +| CL_WEBRTC_TRANSPORT_PROVIDER | 0x0553 | Camera's provider cluster | +| CL_WEBRTC_TRANSPORT_REQUESTOR | 0x0554 | Barton's requestor cluster | +| CL_CAMERA_AV_STREAM_MGMT | 0x0551 | Camera A/V stream management (video stream allocation) | +| CMD_VIDEO_STREAM_ALLOCATE | 0x03 | Allocate a video stream before offer/solicit | +| CMD_SOLICIT_OFFER | 0x00 | Ask the camera to generate the offer (answerer flow) | +| CMD_SOLICIT_OFFER_RESP | 0x01 | Camera's response to SolicitOffer | +| CMD_PROVIDE_OFFER | 0x02 | Send SDP offer to camera (offerer flow) | +| CMD_PROVIDE_ANSWER | 0x04 | Send SDP answer to camera (answerer flow) | +| CMD_PROVIDE_ICE | 0x05 | Send ICE candidates to camera | +| CMD_END_SESSION | 0x06 | End a WebRTC session | +| CMD_OFFER | 0x00 | Incoming offer from camera | +| CMD_ANSWER | 0x01 | Incoming answer from camera | +| CMD_ICE_CANDIDATES | 0x02 | Incoming ICE from camera | +| CMD_END | 0x03 | Incoming end from camera | + +#### Scenario: Constants match Matter specification +- **WHEN** the SBMD driver is loaded +- **THEN** all cluster and command ID constants SHALL match the values defined in the Matter 1.5 WebRTC Transport cluster specification + +### Requirement: WebRTC endpoint is separable by design + +The webrtc endpoint resources, constants, and handler functions SHALL be grouped together and access session state only through transient data supplements. No direct coupling between camera endpoint handlers and webrtc endpoint handlers beyond shared transient data keys. + +#### Scenario: Code organization supports extraction +- **WHEN** the webrtc endpoint code is reviewed +- **THEN** all webrtc-specific constants, resources, and handlers SHALL be identifiable as a cohesive group that could be moved to a separate file with only transient data key sharing as the interface + +### Requirement: negotiationRole read reports the camera's role + +The `negotiationRole` read handler SHALL report the **camera's** WebRTC negotiation role — `offerer` when the camera generates the SDP offer, or `answerer` when the camera answers the client's offer — derived from the camera's advertised WebRTCTransportProvider `AcceptedCommandList`. When the camera accepts `SolicitOffer` the role SHALL be `offerer` (the camera generates the offer); otherwise, when the camera accepts `ProvideOffer`, the role SHALL be `answerer` (the camera answers). When the accepted-command list is unavailable, the handler SHALL default to `offerer` (the SolicitOffer flow). The resource describes the camera because `ep/webrtc` is the camera's data model; the consuming client is responsible for adopting the opposite role. The negotiation role is a WebRTC concept and lives on the `webrtc` endpoint, not in the abstract `stream` result. + +#### Scenario: Camera supporting SolicitOffer reports offerer +- **WHEN** a client reads `negotiationRole` and the camera's `AcceptedCommandList` includes `SolicitOffer` +- **THEN** the read SHALL return `offerer` (the camera generates the offer and the client answers) + +#### Scenario: Camera supporting only ProvideOffer reports answerer +- **WHEN** a client reads `negotiationRole` and the camera's `AcceptedCommandList` includes `ProvideOffer` but not `SolicitOffer` +- **THEN** the read SHALL return `answerer` (the camera answers and the client offers) + +#### Scenario: Unavailable accepted-command list defaults to offerer +- **WHEN** a client reads `negotiationRole` and the camera's `AcceptedCommandList` is unavailable +- **THEN** the read SHALL return `offerer` (the default SolicitOffer flow, in which the camera generates the offer) + diff --git a/reference/CMakeLists.txt b/reference/CMakeLists.txt index 20a89c5c..f4ec7ced 100644 --- a/reference/CMakeLists.txt +++ b/reference/CMakeLists.txt @@ -27,12 +27,28 @@ project(barton-core-reference) include(BCoreConfigureGLib) +option(BCORE_REFERENCE_CAMERA_SUPPORT "Enable the reference app camera stream command with GStreamer WebRTC support" OFF) + file(GLOB_RECURSE B_CORE_REFERENCE_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/*.c) +if (NOT BCORE_REFERENCE_CAMERA_SUPPORT) + list(FILTER B_CORE_REFERENCE_SOURCES EXCLUDE REGEX "cameraCategory\\.c$|cameraDeviceSession\\.c$|cameraWebrtcClient\\.c$|cameraMediaServer\\.c$") +endif() + add_executable(barton-core-reference ${B_CORE_REFERENCE_SOURCES}) # add library dependencies for this binary target_link_options(barton-core-reference PRIVATE -L ${CMAKE_BINARY_DIR}/matter-install/lib) target_link_libraries(barton-core-reference BartonCore linenoise) +if (BCORE_REFERENCE_CAMERA_SUPPORT) + find_package(PkgConfig REQUIRED) + # gio-2.0 provides GSocketService for the HTTP media server that streams to the browser. + pkg_check_modules(GST REQUIRED gstreamer-1.0 gstreamer-webrtc-1.0 gstreamer-sdp-1.0 gstreamer-app-1.0) + pkg_check_modules(GIO REQUIRED gio-2.0) + target_include_directories(barton-core-reference PRIVATE ${GST_INCLUDE_DIRS} ${GIO_INCLUDE_DIRS}) + target_link_libraries(barton-core-reference ${GST_LIBRARIES} ${GIO_LIBRARIES}) + target_compile_definitions(barton-core-reference PRIVATE BCORE_REFERENCE_CAMERA_SUPPORT) +endif() + bcore_configure_glib() diff --git a/reference/src/barton-core-reference-app.c b/reference/src/barton-core-reference-app.c index cc3902e8..d4c28701 100644 --- a/reference/src/barton-core-reference-app.c +++ b/reference/src/barton-core-reference-app.c @@ -33,6 +33,9 @@ #include "eventHandler.h" #include "matterCategory.h" #include "threadCategory.h" +#ifdef BCORE_REFERENCE_CAMERA_SUPPORT +#include "cameraCategory.h" +#endif #include "provider/barton-core-property-provider.h" #include "reference-network-credentials-provider.h" #include @@ -79,6 +82,9 @@ static void buildCategories() categories = g_list_append(categories, buildCoreCategory()); categories = g_list_append(categories, buildMatterCategory()); categories = g_list_append(categories, buildThreadCategory()); +#ifdef BCORE_REFERENCE_CAMERA_SUPPORT + categories = g_list_append(categories, buildCameraCategory()); +#endif } static void destroyCategories() diff --git a/reference/src/barton-core-reference-io.c b/reference/src/barton-core-reference-io.c index 94c1d187..b013d8aa 100644 --- a/reference/src/barton-core-reference-io.c +++ b/reference/src/barton-core-reference-io.c @@ -65,6 +65,16 @@ typedef struct ioAsyncContext const GMainLoop *mainLoop; bool showingPrompt; // Used for state when not using linenoise GTimer *promptTimer; // Used for state when not using linenoise + + // The log/command output pipe is drained by a dedicated thread running its own + // GMainContext/GMainLoop so output keeps draining even while the main loop is blocked + // inside a synchronous command. termLock serializes every terminal write and all + // linenoise state shared between the main thread and the drain thread. + GMainContext *logContext; + GMainLoop *logLoop; + GThread *logThread; + GMutex termLock; + bool editActive; // true while linenoise is actively editing at the prompt } ioAsyncContext; static void ioShutdown(void) @@ -122,18 +132,39 @@ static void outputLineReady(GObject *inputStream, GAsyncResult *res, ioAsyncCont g_autofree gchar *line = g_data_input_stream_read_line_finish_utf8(dataInputStream, res, &size, &error); if (line != NULL) { - // Got some output so to print so take care of sending it out to the terminal while also manipulating the prompt - // so as to not interfere + // Got some output to print so take care of sending it out to the terminal while also manipulating the prompt + // so as to not interfere. This runs on the logging-drain thread, so termLock serializes terminal access with + // the main thread's linenoise editing. + g_mutex_lock(&data->termLock); if (data->useLinenoise) { - linenoiseHide(&data->ls); - } + // Clear the prompt line before printing so log output never lands on top of it. Only do this + // when the main thread is actually editing at the prompt; while a synchronous command is running + // linenoise editing has been stopped (terminal is in cooked mode) and we just print the line. + if (data->editActive) + { + linenoiseHide(&data->ls); + } - if (data->useLinenoise) - { printf("%s\n", line); - linenoiseShow(&data->ls); + + // Redrawing the prompt after every single line makes a fast burst of log output unreadable -- the + // "barton-core> " prompt flashes between every line. Only redraw it when there is typed input to + // preserve, or when the output has gone quiet (no more lines waiting to be drained), so a quiescent + // prompt still reappears once the logs stop. + if (data->editActive) + { + GInputStream *baseInputStream = + g_filter_input_stream_get_base_stream(G_FILTER_INPUT_STREAM(dataInputStream)); + gboolean moreOutputWaiting = + g_pollable_input_stream_is_readable(G_POLLABLE_INPUT_STREAM(baseInputStream)); + + if (data->ls.len > 0 || !moreOutputWaiting) + { + linenoiseShow(&data->ls); + } + } } else { @@ -155,12 +186,20 @@ static void outputLineReady(GObject *inputStream, GAsyncResult *res, ioAsyncCont g_timer_start(data->promptTimer); } } + + // The reemitted log lines must be flushed explicitly. When stdout is not an interactive TTY + // (redirected to a file or pipe) it is fully buffered, so without this flush log output is + // delayed or lost until the buffer fills. + fflush(stdout); + + g_mutex_unlock(&data->termLock); + g_data_input_stream_read_line_async( dataInputStream, G_PRIORITY_DEFAULT, NULL, (GAsyncReadyCallback) outputLineReady, data); } - else + else if (error != NULL) { - fprintf(stderr, "Error reading line: %s", error->message); + fprintf(stderr, "Error reading line: %s\n", error->message); } } @@ -171,11 +210,20 @@ static gboolean linenoiseInputReady(GObject *pollableStream, ioAsyncContext *dat // Sanity check to ensure there is actually something to read if (g_pollable_input_stream_is_readable(G_POLLABLE_INPUT_STREAM(pollableStream))) { - // Check if we have a full line or if we are still waiting for more input + // Check if we have a full line or if we are still waiting for more input. linenoiseEditFeed echoes input + // to the terminal, so it is serialized with the logging-drain thread via termLock. + g_mutex_lock(&data->termLock); char *line = linenoiseEditFeed(&data->ls); + g_mutex_unlock(&data->termLock); + if (line != linenoiseEditMore) { + g_mutex_lock(&data->termLock); linenoiseEditStop(&data->ls); + // Editing has stopped while we dispatch the command, so the drain thread must not touch the prompt. + data->editActive = false; + g_mutex_unlock(&data->termLock); + if (line == NULL) { // Ctrl-C/Ctrl-D @@ -187,6 +235,8 @@ static gboolean linenoiseInputReady(GObject *pollableStream, ioAsyncContext *dat // new line ensures the prompt is at the left margin. emitOutput("\n"); } + // The command callback is intentionally invoked WITHOUT termLock held: it may block for a long time and + // its output flows back through the pipe, which the drain thread must be free to print. if (!data->callback(line, data->userData)) { // If the callback returns false it means we are done and should exit @@ -194,7 +244,10 @@ static gboolean linenoiseInputReady(GObject *pollableStream, ioAsyncContext *dat } if (retVal != G_SOURCE_REMOVE) { + g_mutex_lock(&data->termLock); linenoiseStartRead(data); + data->editActive = true; + g_mutex_unlock(&data->termLock); } else { @@ -242,11 +295,11 @@ static void inputReady(GObject *inputStream, GAsyncResult *res, ioAsyncContext * { if (err != NULL) { - fprintf(stderr, "Error reading line: %s", err->message); + fprintf(stderr, "Error reading line: %s\n", err->message); } else { - fprintf(stderr, "Error reading line"); + fprintf(stderr, "Error reading line\n"); } g_main_loop_quit((GMainLoop *) data->mainLoop); @@ -255,6 +308,7 @@ static void inputReady(GObject *inputStream, GAsyncResult *res, ioAsyncContext * static gboolean checkDisplayPrompt(ioAsyncContext *data) { + g_mutex_lock(&data->termLock); if (!data->showingPrompt && g_timer_elapsed(data->promptTimer, NULL) > .5) { printf(PROMPT "%s", data->buf); @@ -262,6 +316,7 @@ static gboolean checkDisplayPrompt(ioAsyncContext *data) g_timer_stop(data->promptTimer); data->showingPrompt = true; } + g_mutex_unlock(&data->termLock); return G_SOURCE_CONTINUE; } @@ -273,9 +328,47 @@ static void destroyAsyncContext(ioAsyncContext *data) { g_timer_destroy(data->promptTimer); } + if (data->logLoop != NULL) + { + g_main_loop_unref(data->logLoop); + } + if (data->logContext != NULL) + { + g_main_context_unref(data->logContext); + } + g_mutex_clear(&data->termLock); } } +// Entry point for the dedicated logging-drain thread. It runs its own GMainContext/GMainLoop so the output pipe keeps +// being drained no matter what the main thread is doing, which prevents logging threads from stalling on a full pipe +// while a synchronous command blocks the main loop. +static gpointer loggingThreadFunc(gpointer userData) +{ + ioAsyncContext *data = (ioAsyncContext *) userData; + + g_main_context_push_thread_default(data->logContext); + + mutexLock(&mtx); + int localOutputReceivePipe = outputReceivePipe; + mutexUnlock(&mtx); + + // Created on this thread (with logContext thread-default) so the read source and every re-armed read attach to the + // logging context rather than the main one. + g_autoptr(GDataInputStream) outputStream = + g_data_input_stream_new(g_unix_input_stream_new(localOutputReceivePipe, FALSE)); + g_data_input_stream_read_line_async( + outputStream, G_PRIORITY_DEFAULT, NULL, (GAsyncReadyCallback) outputLineReady, data); + + g_main_loop_run(data->logLoop); + + g_main_context_pop_thread_default(data->logContext); + + g_atomic_rc_box_release_full(data, (GDestroyNotify) destroyAsyncContext); + + return NULL; +} + void barton_core_reference_io_process(bool useLinenoise, processLineCallback callback, void *userData) { pthread_once(&init, ioSetup); @@ -290,21 +383,19 @@ void barton_core_reference_io_process(bool useLinenoise, processLineCallback cal asyncContext->callback = callback; asyncContext->userData = userData; asyncContext->mainLoop = loop; - mutexLock(&mtx); - int localOutputReceivePipe = outputReceivePipe; - mutexUnlock(&mtx); + g_mutex_init(&asyncContext->termLock); - // Setup reading from the output fd - g_autoptr(GDataInputStream) outputStream = - g_data_input_stream_new(g_unix_input_stream_new(localOutputReceivePipe, FALSE)); - g_data_input_stream_read_line_async( - outputStream, G_PRIORITY_DEFAULT, NULL, (GAsyncReadyCallback) outputLineReady, asyncContext); + // The log/command output pipe is drained on its own GMainContext/GMainLoop, run by a dedicated thread (started + // below). Because that loop is scheduled independently of this (main) loop, output keeps draining even while the + // main loop is blocked inside a synchronous command, so logging threads never stall on a full pipe. + asyncContext->logContext = g_main_context_new(); + asyncContext->logLoop = g_main_loop_new(asyncContext->logContext, FALSE); // Setup reading from stdin g_autoptr(GInputStream) inputStream = NULL; if (useLinenoise && linenoiseStartRead(asyncContext)) { - linenoiseStartRead(asyncContext); + asyncContext->editActive = true; inputStream = g_unix_input_stream_new(asyncContext->ls.ifd, FALSE); GSource *source = g_pollable_input_stream_create_source(G_POLLABLE_INPUT_STREAM(inputStream), NULL); g_source_set_name(source, "linenoise"); @@ -338,8 +429,16 @@ void barton_core_reference_io_process(bool useLinenoise, processLineCallback cal g_source_set_callback(source, (GSourceFunc) checkDisplayPrompt, asyncContext, NULL); } + // Start the dedicated logging-drain thread. It takes its own reference on the shared context. + asyncContext->logThread = g_thread_new("logging-io", loggingThreadFunc, g_atomic_rc_box_acquire(asyncContext)); + // We will block until one of the async callbacks tells us to quit because the user chose to exit g_main_loop_run(loop); + + // Stop the logging-drain loop and wait for its thread before tearing down shared state + g_main_loop_quit(asyncContext->logLoop); + g_thread_join(asyncContext->logThread); + g_main_loop_unref(loop); g_atomic_rc_box_release_full(asyncContext, (GDestroyNotify) destroyAsyncContext); @@ -353,6 +452,35 @@ static void emitToPipe(int fd, const gchar *str) } } +// Prefix each non-empty line of an emitted message with a logger-style +// "YYYY-MM-DD HH:MM:SS.mmm : " timestamp so emitted output interleaves consistently with the +// structured log lines that share this pipe. Empty lines (e.g. a bare prompt-reset "\n") are +// passed through unstamped. Caller owns the returned string. +static gchar *emitApplyTimestamps(const gchar *msg) +{ + g_autoptr(GDateTime) now = g_date_time_new_now_local(); + g_autofree gchar *base = g_date_time_format(now, "%Y-%m-%d %H:%M:%S"); + g_autofree gchar *prefix = g_strdup_printf("%s.%03d : ", base, g_date_time_get_microsecond(now) / 1000); + g_auto(GStrv) lines = g_strsplit(msg, "\n", -1); + GString *out = g_string_new(NULL); + + for (gsize i = 0; lines[i] != NULL; i++) + { + if (lines[i][0] != '\0') + { + g_string_append(out, prefix); + g_string_append(out, lines[i]); + } + + if (lines[i + 1] != NULL) + { + g_string_append_c(out, '\n'); + } + } + + return g_string_free(out, FALSE); +} + void emitOutput(const char *format, ...) { va_list args; @@ -362,8 +490,9 @@ void emitOutput(const char *format, ...) { // Could create another pipe pair and seperate logging and output. This would be useful as it could give the // option of silencing the logging output. + g_autofree gchar *stamped = emitApplyTimestamps(str); int localSendPipe = getDebugLoggerFileDescriptor(); - emitToPipe(localSendPipe, str); + emitToPipe(localSendPipe, stamped); } va_end(args); } @@ -376,8 +505,9 @@ void emitError(const char *format, ...) if (str) { // Could create another pipe pair and have it go to stderr + g_autofree gchar *stamped = emitApplyTimestamps(str); int localSendPipe = getDebugLoggerFileDescriptor(); - emitToPipe(localSendPipe, str); + emitToPipe(localSendPipe, stamped); } va_end(args); } diff --git a/reference/src/cameraCategory.c b/reference/src/cameraCategory.c new file mode 100644 index 00000000..8d968311 --- /dev/null +++ b/reference/src/cameraCategory.c @@ -0,0 +1,937 @@ +//------------------------------ tabstop = 4 ---------------------------------- +// +// If not stated otherwise in this file or this component's LICENSE file the +// following copyright and licenses apply: +// +// Copyright 2026 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// +//------------------------------ tabstop = 4 ---------------------------------- + +// +// Coordinator for the camera stream (cs) command. Handles the reference-app +// user interface and drives the streaming state machine, delegating the actual +// work to these modules: +// - cameraDeviceSession: camera flows over the Barton client interface +// - cameraWebrtcClient: the WebRTC client backed by GStreamer (muxes to file / fMP4) +// - cameraMediaServer: HTTP server that streams the fragmented MP4 to a browser +// +// This module owns only the cross-thread synchronization (the modules invoke +// callbacks from their own threads) and the ordering of the signaling steps. +// + +#include "cameraCategory.h" +#include "barton-core-client.h" +#include "barton-core-reference-io.h" +#include "cameraDeviceSession.h" +#include "cameraMediaServer.h" +#include "cameraWebrtcClient.h" +#include +#include +#include + +// CAMERA_DEFAULT_SERVE_HOST / CAMERA_DEFAULT_SERVE_PORT are defined in cameraMediaServer.h so the +// command layer and the media server share the same defaults. + +// The SIGINT handler may only touch an async-signal-safe flag, so the blocking wait loops poll +// it on this interval to observe Ctrl+C promptly without the handler taking locks. +#define CAMERA_SIGINT_POLL_INTERVAL_USEC (200 * G_TIME_SPAN_MILLISECOND) + +// ============================================================================ +// Shared state between threads +// ============================================================================ + +typedef struct +{ + GMutex mutex; + GCond cond; + + // Set by the WebRTC client callbacks + gchar *localSdpOffer; + gboolean offerReady; + GQueue localIceCandidates; // candidate strings buffered until trickle sending is enabled + + // Set by the device-session callbacks + gchar *remoteSdp; + GQueue remoteIceCandidates; // JSON array strings buffered until inbound trickle feeding is enabled + gboolean remoteSdpReady; + gboolean sessionEnded; + gchar *errorMessage; + + // Control + gboolean teardown; + + // Trickle ICE (outbound): once the camera session is established, local candidates are sent + // to the camera immediately as they are gathered; until then they are buffered above. + CameraDeviceSession *session; + gboolean iceSendEnabled; + + // Trickle ICE (inbound): once the remote SDP is set, the camera's candidates are fed to the + // WebRTC client as they arrive; until then they are buffered above. + CameraWebrtcClient *webrtc; + gboolean remoteIceFeedEnabled; + + // Serve mode: the in-container pipeline muxes fragmented MP4 and this HTTP server streams + // it to the browser. NULL when recording to a file. + CameraMediaServer *mediaServer; + + // Record mode: the muxed fragmented MP4 is written here. NULL when serving over HTTP. + FILE *outFile; +} CameraStreamState; + +// Set by the SIGINT handler, which must stay async-signal-safe. The blocking wait loops poll +// this flag rather than relying on the handler to take locks or signal condition variables. +static volatile sig_atomic_t sigintRequested = 0; + +static void sendLocalIceCandidate(CameraDeviceSession *session, const gchar *candidate); +static void feedRemoteIceCandidates(CameraWebrtcClient *client, const gchar *jsonCandidates); +static void onMediaBuffer(const guint8 *data, gsize size, gboolean isHeader, gpointer userData); +static void onViewerConnected(gpointer userData); +static gboolean parseOutputUri(const gchar *uri, gchar **filePathOut, gchar **serveHostOut, guint16 *servePortOut); + +// ============================================================================ +// SIGINT handler +// ============================================================================ + +static void sigintHandler(int sig) +{ + (void) sig; + + // Only touch an async-signal-safe flag here. Taking the GLib mutex or signaling the + // condition variable from a signal handler is undefined behavior; the wait loops poll this + // flag instead (see waitForCondition). + sigintRequested = 1; +} + +// ============================================================================ +// WebRTC client callbacks +// ============================================================================ + +static void onLocalOfferReady(const gchar *sdp, gpointer userData) +{ + CameraStreamState *state = (CameraStreamState *) userData; + g_mutex_lock(&state->mutex); + g_free(state->localSdpOffer); + state->localSdpOffer = g_strdup(sdp); + state->offerReady = TRUE; + g_cond_signal(&state->cond); + g_mutex_unlock(&state->mutex); +} + +static void onLocalIceCandidate(guint mlineIndex, const gchar *candidate, gpointer userData) +{ + (void) mlineIndex; + CameraStreamState *state = (CameraStreamState *) userData; + + g_mutex_lock(&state->mutex); + gboolean sendNow = state->iceSendEnabled; + + if (!sendNow) + { + // The camera session isn't established yet; buffer until trickle sending is enabled. + g_queue_push_tail(&state->localIceCandidates, g_strdup(candidate)); + g_cond_signal(&state->cond); + } + + CameraDeviceSession *session = state->session; + g_mutex_unlock(&state->mutex); + + // Trickle: send this candidate to the camera immediately as its own message. + if (sendNow) + { + sendLocalIceCandidate(session, candidate); + } +} + +// Invoked when the WebRTC client stops on its own (pipeline error or EOS). +// Requests the same graceful teardown as Ctrl+C so the session is ended on the +// camera (EndSession) and the stream is deallocated rather than leaked. +static void onWebrtcClosed(gpointer userData) +{ + CameraStreamState *state = (CameraStreamState *) userData; + g_mutex_lock(&state->mutex); + state->teardown = TRUE; + g_cond_signal(&state->cond); + g_mutex_unlock(&state->mutex); +} + +// ============================================================================ +// Device-session callbacks +// ============================================================================ + +static void onRemoteSdp(const gchar *sdp, gpointer userData) +{ + CameraStreamState *state = (CameraStreamState *) userData; + g_mutex_lock(&state->mutex); + g_free(state->remoteSdp); + state->remoteSdp = g_strdup(sdp); + state->remoteSdpReady = TRUE; + g_cond_signal(&state->cond); + g_mutex_unlock(&state->mutex); +} + +static void onRemoteIce(const gchar *jsonCandidates, gpointer userData) +{ + CameraStreamState *state = (CameraStreamState *) userData; + + g_mutex_lock(&state->mutex); + gboolean feedNow = state->remoteIceFeedEnabled; + CameraWebrtcClient *webrtc = state->webrtc; + + if (!feedNow) + { + // The remote SDP isn't set yet; buffer until inbound trickle feeding is enabled. + g_queue_push_tail(&state->remoteIceCandidates, g_strdup(jsonCandidates)); + g_cond_signal(&state->cond); + } + + g_mutex_unlock(&state->mutex); + + // Trickle: feed these candidates to the WebRTC client the moment they arrive. + if (feedNow) + { + feedRemoteIceCandidates(webrtc, jsonCandidates); + } +} + +static void onSessionError(const gchar *message, gpointer userData) +{ + CameraStreamState *state = (CameraStreamState *) userData; + g_mutex_lock(&state->mutex); + g_free(state->errorMessage); + state->errorMessage = g_strdup(message); + state->sessionEnded = TRUE; + g_cond_signal(&state->cond); + g_mutex_unlock(&state->mutex); +} + +// ============================================================================ +// Helpers +// ============================================================================ + +static gboolean waitForCondition(CameraStreamState *state, gboolean *flag, gint timeoutSeconds) +{ + gint64 deadline = g_get_monotonic_time() + (gint64) timeoutSeconds * G_USEC_PER_SEC; + g_mutex_lock(&state->mutex); + + while (!*flag && !state->teardown && !state->sessionEnded) + { + if (sigintRequested) + { + state->teardown = TRUE; + + break; + } + + gint64 now = g_get_monotonic_time(); + + if (now >= deadline) + { + g_mutex_unlock(&state->mutex); + + return FALSE; + } + + // Wake periodically to poll the async-signal-safe SIGINT flag, since the signal handler + // cannot safely take the mutex or signal the condition variable. + gint64 wakeup = now + CAMERA_SIGINT_POLL_INTERVAL_USEC; + + if (wakeup > deadline) + { + wakeup = deadline; + } + + g_cond_wait_until(&state->cond, &state->mutex, wakeup); + } + + gboolean result = *flag; + g_mutex_unlock(&state->mutex); + + return result; +} + +// Block until Ctrl+C (teardown) or the camera ends the session. + +// Wrap a single ICE candidate in the one-element JSON array the camera expects, so each +// candidate can be trickled to the camera as its own message. +static gchar *buildSingleIceCandidateJson(const gchar *candidate) +{ + scoped_cJSON *array = cJSON_CreateArray(); + cJSON_AddItemToArray(array, cJSON_CreateString(candidate)); + + // cJSON prints into a malloc'd buffer; copy it into a glib buffer (freed with + // cJSON_free) so the caller can free the result with g_free. + char *raw = cJSON_PrintUnformatted(array); + gchar *result = g_strdup(raw); + cJSON_free(raw); + + return result; +} + +// Send one local ICE candidate to the camera as its own offerIceCandidates message. +static void sendLocalIceCandidate(CameraDeviceSession *session, const gchar *candidate) +{ + g_autofree gchar *json = buildSingleIceCandidateJson(candidate); + cameraDeviceSessionSendIceCandidates(session, json); +} + +// Send any candidates that were gathered before trickle sending was enabled, one message each. +static void flushBufferedLocalIce(CameraDeviceSession *session, CameraStreamState *state) +{ + for (;;) + { + g_mutex_lock(&state->mutex); + gchar *candidate = g_queue_pop_head(&state->localIceCandidates); + g_mutex_unlock(&state->mutex); + + if (candidate == NULL) + { + break; + } + + g_autofree gchar *owned = candidate; + sendLocalIceCandidate(session, owned); + } +} + +// Parse the camera's remote ICE JSON array and feed each candidate to the client. +static void feedRemoteIceCandidates(CameraWebrtcClient *client, const gchar *jsonCandidates) +{ + scoped_cJSON *array = cJSON_Parse(jsonCandidates); + + if (!cJSON_IsArray(array)) + { + return; + } + + cJSON *item = NULL; + cJSON_ArrayForEach(item, array) + { + if (!cJSON_IsString(item)) + { + continue; + } + + // GStreamer's webrtcbin expects the bare "candidate:..." value; the camera + // sends candidates with the SDP "a=" attribute prefix. + const gchar *value = item->valuestring; + + if (g_str_has_prefix(value, "a=")) + { + value += 2; + } + + // Skip the empty end-of-candidates marker the camera includes. + if (*value != '\0') + { + cameraWebrtcClientAddIceCandidate(client, 0, value); + } + } +} + +// Feed any remote candidates that arrived before inbound trickle feeding was enabled, then the +// callback delivers the rest live for the remainder of the session. +static void flushBufferedRemoteIce(CameraWebrtcClient *client, CameraStreamState *state) +{ + for (;;) + { + g_mutex_lock(&state->mutex); + gchar *json = g_queue_pop_head(&state->remoteIceCandidates); + g_mutex_unlock(&state->mutex); + + if (json == NULL) + { + break; + } + + g_autofree gchar *owned = json; + feedRemoteIceCandidates(client, owned); + } +} + +static void cleanupState(CameraStreamState *state) +{ + g_free(state->localSdpOffer); + g_free(state->remoteSdp); + g_free(state->errorMessage); + + while (!g_queue_is_empty(&state->localIceCandidates)) + { + g_free(g_queue_pop_head(&state->localIceCandidates)); + } + + while (!g_queue_is_empty(&state->remoteIceCandidates)) + { + g_free(g_queue_pop_head(&state->remoteIceCandidates)); + } + + if (state->outFile != NULL) + { + fclose(state->outFile); + state->outFile = NULL; + } + + g_mutex_clear(&state->mutex); + g_cond_clear(&state->cond); +} + +// Pretty-print the negotiated stream configuration. Only fields the pipeline can report are +// shown; this is a passthrough (no-decode) pipeline, so resolution and frame rate are not +// derived here and are omitted. +static void printNegotiatedConfig(const CameraWebrtcVideoConfig *cfg) +{ + emitOutput("[camera-stream] Negotiated stream configuration:\n"); + + if (cfg->codec[0] != '\0') + { + if (cfg->profileLevelId[0] != '\0') + { + emitOutput("[camera-stream] Codec : %s (profile-level-id %s)\n", cfg->codec, cfg->profileLevelId); + } + else + { + emitOutput("[camera-stream] Codec : %s\n", cfg->codec); + } + } + + if (cfg->bitrateKbps > 0) + { + emitOutput("[camera-stream] Bit rate : %d kbps\n", cfg->bitrateKbps); + } + + if (cfg->payloadType >= 0 && cfg->clockRate > 0) + { + emitOutput("[camera-stream] RTP payload : %d @ %d Hz\n", cfg->payloadType, cfg->clockRate); + } + + if (cfg->width > 0 && cfg->height > 0) + { + emitOutput("[camera-stream] Resolution : %dx%d\n", cfg->width, cfg->height); + } + + if (cfg->framerateNum > 0 && cfg->framerateDen > 0) + { + emitOutput("[camera-stream] Frame rate : %g fps\n", + (double) cfg->framerateNum / (double) cfg->framerateDen); + } +} + +// ============================================================================ +// Command implementation +// ============================================================================ + +// Runs the full signaling sequence for one stream. Each module handle is scope +// bound (g_autoptr), so every early return tears everything down in the right +// order: the WebRTC client first (stopping the appsink), then the media server / +// output file, then the device session (which ends the camera session if it was opened). +static bool runCameraStream(BCoreClient *client, + const gchar *deviceId, + const gchar *filePath, + const gchar *serveHost, + guint16 servePort, + CameraStreamState *state) +{ + // Subscribe to camera events before anything else so no update is missed. + g_autoptr(CameraDeviceSession) session = + cameraDeviceSessionCreate(client, deviceId, onRemoteSdp, onRemoteIce, onSessionError, state); + + if (session == NULL) + { + emitError("[camera-stream] Failed to set up camera session\n"); + + return false; + } + + // Make the session available to the ICE-candidate callback so it can trickle candidates. + g_mutex_lock(&state->mutex); + state->session = session; + g_mutex_unlock(&state->mutex); + + // Step 1: Create session + emitOutput("[camera-stream] Creating session...\n"); + + if (!cameraDeviceSessionOpen(session)) + { + emitError("[camera-stream] Failed to create session\n"); + + return false; + } + + emitOutput("[camera-stream] Session created\n"); + + // Step 2: Start stream + emitOutput("[camera-stream] Starting stream...\n"); + + if (!cameraDeviceSessionStartStream(session)) + { + emitError("[camera-stream] Failed to start stream\n"); + + return false; + } + + const gchar *protocol = cameraDeviceSessionGetProtocol(session); + const gchar *entryPoint = cameraDeviceSessionGetEntryPoint(session); + emitOutput("[camera-stream] Stream started (protocol %s, entry %s), setting up WebRTC...\n", + protocol != NULL ? protocol : "unknown", + entryPoint != NULL ? entryPoint : "unknown"); + + // Step 3: In serve mode, start the HTTP media server before the client so it receives the + // pipeline's first (init-segment) buffers. In record mode, open the output file. Then create + // the WebRTC client, which always muxes the camera's H.264 into fragmented MP4 and delivers + // the buffers to onMediaBuffer. The client is declared after the server so it is destroyed + // first, stopping the pipeline before the server it feeds. + g_autoptr(CameraMediaServer) mediaServer = NULL; + + if (filePath != NULL) + { + state->outFile = fopen(filePath, "wb"); + + if (state->outFile == NULL) + { + emitError("[camera-stream] Failed to open %s for writing\n", filePath); + + return false; + } + + emitOutput("[camera-stream] Recording camera video to %s\n", filePath); + } + else + { + mediaServer = cameraMediaServerCreate(serveHost, servePort); + + if (mediaServer == NULL) + { + emitError("[camera-stream] Failed to start the media server\n"); + + return false; + } + + g_mutex_lock(&state->mutex); + state->mediaServer = mediaServer; + g_mutex_unlock(&state->mutex); + + cameraMediaServerSetOnViewer(mediaServer, onViewerConnected, state); + + emitOutput("[camera-stream] Serving camera video at %s\n", cameraMediaServerGetUrl(mediaServer)); + } + + g_autoptr(CameraWebrtcClient) webrtc = + cameraWebrtcClientCreate(onLocalOfferReady, onLocalIceCandidate, onWebrtcClosed, onMediaBuffer, state); + + if (webrtc == NULL) + { + emitError("[camera-stream] Failed to create WebRTC client\n"); + + return false; + } + + // Determine our negotiation role by inverting the camera's. negotiationRole reports the + // CAMERA's role: when the camera is the 'offerer' (SolicitOffer flow) it provides the offer and + // we answer it; when the camera is the 'answerer' (ProvideOffer flow) we create the offer. Must + // be set before the peer starts negotiating. + const gchar *role = cameraDeviceSessionGetRole(session); + + // The role read can fail (NULL) or return an unexpected value; bail out with a clear error + // rather than silently defaulting to a role that could drive the wrong signaling flow. + if (g_strcmp0(role, "offerer") != 0 && g_strcmp0(role, "answerer") != 0) + { + emitError("[camera-stream] could not determine the camera's negotiation role (got '%s')\n", + role != NULL ? role : "(null)"); + + return false; + } + + gboolean answerer = (g_strcmp0(role, "offerer") == 0); + cameraWebrtcClientSetAnswerer(webrtc, answerer); + + // Step 4: Start client (in offerer mode this triggers negotiation -> creates the SDP offer) + if (!cameraWebrtcClientStart(webrtc)) + { + emitError("[camera-stream] Failed to start WebRTC client\n"); + + return false; + } + + if (answerer) + { + // Answerer (SolicitOffer flow): open signaling by posting an empty local SDP, which tells + // the driver to allocate a stream and ask the camera to generate the offer. Then wait for + // that offer, set it as the remote description (which makes the client create our answer), + // and send the answer back. + emitOutput("[camera-stream] Requesting camera SDP offer...\n"); + + if (!cameraDeviceSessionSendOffer(session, "")) + { + emitError("[camera-stream] Failed to request camera SDP offer\n"); + + return false; + } + + emitOutput("[camera-stream] Waiting for camera SDP offer...\n"); + + if (!waitForCondition(state, &state->remoteSdpReady, 30)) + { + emitError("[camera-stream] Timeout waiting for camera SDP offer\n"); + + return false; + } + + if (state->teardown || state->sessionEnded) + { + return false; + } + + g_autofree gchar *remoteOffer = NULL; + g_mutex_lock(&state->mutex); + remoteOffer = g_strdup(state->remoteSdp); + g_mutex_unlock(&state->mutex); + + emitOutput("[camera-stream] Offer received, creating answer...\n"); + + if (!cameraWebrtcClientSetRemoteSdp(webrtc, remoteOffer)) + { + emitError("[camera-stream] Failed to set remote SDP offer\n"); + + return false; + } + + // The client produces the answer asynchronously (create-answer). + if (!waitForCondition(state, &state->offerReady, 10)) + { + emitError("[camera-stream] Timeout waiting for local SDP answer\n"); + + return false; + } + + if (state->teardown || state->sessionEnded) + { + return false; + } + + g_autofree gchar *localAnswer = NULL; + g_mutex_lock(&state->mutex); + localAnswer = g_strdup(state->localSdpOffer); + g_mutex_unlock(&state->mutex); + + emitOutput("[camera-stream] Sending SDP answer to camera...\n"); + + if (!cameraDeviceSessionSendOffer(session, localAnswer)) + { + emitError("[camera-stream] Failed to send SDP answer\n"); + + return false; + } + } + else + { + // Offerer (ProvideOffer flow): we create the offer, the camera answers. + emitOutput("[camera-stream] Waiting for local SDP offer...\n"); + + if (!waitForCondition(state, &state->offerReady, 10)) + { + emitError("[camera-stream] Timeout waiting for SDP offer\n"); + + return false; + } + + if (state->teardown || state->sessionEnded) + { + return false; + } + + g_autofree gchar *localOffer = NULL; + g_mutex_lock(&state->mutex); + localOffer = g_strdup(state->localSdpOffer); + g_mutex_unlock(&state->mutex); + + emitOutput("[camera-stream] Sending SDP offer to camera...\n"); + + if (!cameraDeviceSessionSendOffer(session, localOffer)) + { + emitError("[camera-stream] Failed to send SDP offer\n"); + + return false; + } + + emitOutput("[camera-stream] SDP offer sent, waiting for answer...\n"); + + if (!waitForCondition(state, &state->remoteSdpReady, 30)) + { + emitError("[camera-stream] Timeout waiting for remote SDP answer\n"); + + return false; + } + + if (state->teardown || state->sessionEnded) + { + return false; + } + + g_autofree gchar *remoteAnswer = NULL; + g_mutex_lock(&state->mutex); + remoteAnswer = g_strdup(state->remoteSdp); + g_mutex_unlock(&state->mutex); + + emitOutput("[camera-stream] Answer received, setting remote SDP...\n"); + + if (!cameraWebrtcClientSetRemoteSdp(webrtc, remoteAnswer)) + { + emitError("[camera-stream] Failed to set remote SDP\n"); + + return false; + } + } + + // Step 9: Enable trickle ICE in both directions now that the camera session is established + // and the remote description is set. Candidates gathered/received before now are flushed + // here; later ones are trickled by the callbacks the moment they arrive, for the life of + // the session. + emitOutput("[camera-stream] Trickling ICE candidates...\n"); + g_mutex_lock(&state->mutex); + state->iceSendEnabled = TRUE; + state->webrtc = webrtc; + state->remoteIceFeedEnabled = TRUE; + g_mutex_unlock(&state->mutex); + + // Outbound: send local candidates to the camera. Inbound: feed the camera's candidates to + // the WebRTC client. Both drain whatever was buffered during signaling; the callbacks then + // keep trickling in each direction until the session ends. + flushBufferedLocalIce(session, state); + flushBufferedRemoteIce(webrtc, state); + + // Print the signaled stream configuration (codec / bitrate / payload). Resolution and + // frame rate are not derived from this passthrough (no-decode) pipeline, so they are omitted. + { + CameraWebrtcVideoConfig videoConfig; + cameraWebrtcClientGetVideoConfig(webrtc, &videoConfig); + printNegotiatedConfig(&videoConfig); + } + + // Step 12: Media flowing - wait for teardown + if (filePath != NULL) + { + emitOutput("[camera-stream] Media flowing, recording to %s. Press Ctrl+C to stop.\n", filePath); + } + else + { + emitOutput("[camera-stream] Media flowing at %s. Press Ctrl+C to stop.\n", + cameraMediaServerGetUrl(mediaServer)); + } + + // Block until the user interrupts (Ctrl+C) or the camera ends the session. waitForCondition + // returns FALSE on timeout, so loop to re-arm it rather than silently stopping a long-lived + // stream once the timeout elapses. + while (!state->teardown && !state->sessionEnded) + { + waitForCondition(state, &state->teardown, 3600); + } + + if (state->sessionEnded) + { + emitOutput("[camera-stream] Session ended: %s\n", + state->errorMessage != NULL ? state->errorMessage : "unknown reason"); + } + else + { + emitOutput("\n[camera-stream] Stopping stream...\n"); + } + + // Sever the cross-thread callbacks before the scope-bound handles tear down. The g_autoptr + // scope exit destroys the WebRTC client first, but the media server and device session outlive + // it briefly and their callbacks (onViewerConnected / onRemoteIce) reach into state->webrtc. + // Stop the media server from calling back and clear the shared pointers under the mutex so any + // late callback becomes a no-op instead of touching the destroyed client. + cameraMediaServerSetOnViewer(mediaServer, NULL, NULL); + + g_mutex_lock(&state->mutex); + state->webrtc = NULL; + state->session = NULL; + state->mediaServer = NULL; + g_mutex_unlock(&state->mutex); + + return true; +} + +// SERVE mode: hand each muxed fragmented-MP4 buffer from the pipeline to the HTTP media server. +static void onMediaBuffer(const guint8 *data, gsize size, gboolean isHeader, gpointer userData) +{ + CameraStreamState *state = (CameraStreamState *) userData; + + g_mutex_lock(&state->mutex); + CameraMediaServer *server = state->mediaServer; + FILE *outFile = state->outFile; + g_mutex_unlock(&state->mutex); + + // Serve mode: fan the fragmented-MP4 buffer out to viewers. Record mode: append it to the + // file. (outFile is only closed in cleanupState, after the pipeline -- and therefore this + // callback -- has stopped, so the pointer stays valid for the write here.) + if (server != NULL) + { + cameraMediaServerPushBuffer(server, data, size, isHeader); + } + + if (outFile != NULL) + { + fwrite(data, 1, size, outFile); + fflush(outFile); + } +} + +// Invoked (on the media server thread) when a new viewer connects. Requests a fresh keyframe so +// the viewer can begin decoding immediately. state->webrtc may still be NULL if a viewer connects +// during setup, before the client is published; in that case the stream-start keyframe covers it. +static void onViewerConnected(gpointer userData) +{ + CameraStreamState *state = (CameraStreamState *) userData; + + // Request the keyframe while holding the mutex so a concurrent teardown -- which clears + // state->webrtc under the same mutex before the client is destroyed -- cannot free the client + // between the NULL check and the call. requestKeyframe only takes the client's keyframe lock, + // so there is no lock-ordering hazard. + g_mutex_lock(&state->mutex); + + if (state->webrtc != NULL) + { + cameraWebrtcClientRequestKeyframe(state->webrtc); + } + + g_mutex_unlock(&state->mutex); +} + +// Parse the --out URI. A "file://" URI records to a file; anything else is treated as an +// HTTP serve target (the "http://" scheme is optional). A NULL uri (no --out) selects the default +// serve target. filePathOut stays NULL for serve mode; the allocated path/host strings are +// returned via the *Out parameters. +static gboolean parseOutputUri(const gchar *uri, gchar **filePathOut, gchar **serveHostOut, guint16 *servePortOut) +{ + if (uri == NULL) + { + return TRUE; // default: serve + } + + if (g_str_has_prefix(uri, "file://")) + { + *filePathOut = g_strdup(uri + strlen("file://")); + + return (*filePathOut)[0] != '\0'; + } + + // Anything that is not a file:// URI is treated as an HTTP serve target. The "http://" + // scheme is optional; the remainder is parsed as [:]. + const gchar *authorityStart = g_str_has_prefix(uri, "http://") ? uri + strlen("http://") : uri; + g_autofree gchar *authority = g_strdup(authorityStart); + gchar *slash = strchr(authority, '/'); + + if (slash != NULL) + { + *slash = '\0'; + } + + gchar *colon = strrchr(authority, ':'); + + if (colon != NULL) + { + *colon = '\0'; + *servePortOut = (guint16) g_ascii_strtoull(colon + 1, NULL, 10); + } + + *serveHostOut = (authority[0] != '\0') ? g_strdup(authority) : g_strdup(CAMERA_DEFAULT_SERVE_HOST); + + return (*servePortOut != 0); +} + +static bool cameraStreamFunc(BCoreClient *client, gint argc, gchar **argv) +{ + const gchar *deviceId = argv[0]; + const gchar *outUri = NULL; + + // Parse optional --out : file:// records, http://: serves. + for (gint i = 1; i < argc; i++) + { + if (g_strcmp0(argv[i], "--out") == 0 && i + 1 < argc) + { + outUri = argv[i + 1]; + i++; + } + } + + g_autofree gchar *filePath = NULL; + g_autofree gchar *serveHost = NULL; + guint16 servePort = CAMERA_DEFAULT_SERVE_PORT; + + if (!parseOutputUri(outUri, &filePath, &serveHost, &servePort)) + { + emitError("Invalid --out URI '%s' (expected file:// or http://:)\n", + outUri != NULL ? outUri : ""); + + return false; + } + + // Verify device exists + g_autoptr(BCoreDevice) device = b_core_client_get_device_by_id(client, deviceId); + + if (device == NULL) + { + emitError("Device '%s' not found\n", deviceId); + + return false; + } + + // Initialize shared state + CameraStreamState state = {0}; + g_mutex_init(&state.mutex); + g_cond_init(&state.cond); + g_queue_init(&state.localIceCandidates); + g_queue_init(&state.remoteIceCandidates); + + // Install signal handler so Ctrl+C requests a graceful teardown. Clear any leftover request + // from a previous run before arming the handler for this one. + struct sigaction oldAction; + struct sigaction newAction = {0}; + newAction.sa_handler = sigintHandler; + sigemptyset(&newAction.sa_mask); + sigintRequested = 0; + sigaction(SIGINT, &newAction, &oldAction); + + bool success = runCameraStream( + client, deviceId, filePath, serveHost != NULL ? serveHost : CAMERA_DEFAULT_SERVE_HOST, servePort, &state); + + // Restore signal handler + sigaction(SIGINT, &oldAction, NULL); + + cleanupState(&state); + + emitOutput("[camera-stream] Done.\n"); + + return success; +} + +Category *buildCameraCategory(void) +{ + Category *cat = categoryCreate("Camera", "Camera streaming commands"); + + Command *command = commandCreate("cameraStream", + "cs", + " [--out ]", + "Stream a camera via WebRTC; serve over HTTP (default http://127.0.0.1:8088) " + "or record with --out file://", + 1, + 3, + cameraStreamFunc); + categoryAddCommand(cat, command); + + return cat; +} diff --git a/reference/src/cameraCategory.h b/reference/src/cameraCategory.h new file mode 100644 index 00000000..b0ac7662 --- /dev/null +++ b/reference/src/cameraCategory.h @@ -0,0 +1,28 @@ +//------------------------------ tabstop = 4 ---------------------------------- +// +// If not stated otherwise in this file or this component's LICENSE file the +// following copyright and licenses apply: +// +// Copyright 2026 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// +//------------------------------ tabstop = 4 ---------------------------------- + +#pragma once + +#include "category.h" + +Category *buildCameraCategory(void); diff --git a/reference/src/cameraDeviceSession.c b/reference/src/cameraDeviceSession.c new file mode 100644 index 00000000..1bf4fdb9 --- /dev/null +++ b/reference/src/cameraDeviceSession.c @@ -0,0 +1,304 @@ +//------------------------------ tabstop = 4 ---------------------------------- +// +// If not stated otherwise in this file or this component's LICENSE file the +// following copyright and licenses apply: +// +// Copyright 2026 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// +//------------------------------ tabstop = 4 ---------------------------------- + +#include "cameraDeviceSession.h" +#include "barton-core-reference-io.h" +#include "barton-core-resource.h" +#include "events/barton-core-resource-updated-event.h" +#include + +struct _CameraDeviceSession +{ + BCoreClient *client; + gchar *deviceId; + + CameraDeviceOnRemoteSdp onRemoteSdp; + CameraDeviceOnRemoteIce onRemoteIce; + CameraDeviceOnSessionError onError; + gpointer userData; + + gulong handlerId; + gchar *sessionId; + gchar *protocol; // from the stream execute result + gchar *entryPoint; // from the stream execute result + gchar *role; // the CAMERA's negotiation role ('offerer' | 'answerer'); populated lazily + // from /ep/webrtc/r/negotiationRole (see cameraDeviceSessionGetRole), not the + // stream execute result. The client adopts the opposite role. + gboolean opened; +}; + +// ============================================================================ +// Resource-updated event handler +// ============================================================================ + +static void onResourceUpdated(BCoreClient *source, BCoreResourceUpdatedEvent *event, gpointer userData) +{ + (void) source; + CameraDeviceSession *self = (CameraDeviceSession *) userData; + + g_autoptr(BCoreResource) resource = NULL; + g_autofree gchar *metadata = NULL; + + g_object_get(G_OBJECT(event), + B_CORE_RESOURCE_UPDATED_EVENT_PROPERTY_NAMES[B_CORE_RESOURCE_UPDATED_EVENT_PROP_RESOURCE], + &resource, + B_CORE_RESOURCE_UPDATED_EVENT_PROPERTY_NAMES[B_CORE_RESOURCE_UPDATED_EVENT_PROP_METADATA], + &metadata, + NULL); + + if (resource == NULL) + { + return; + } + + g_autofree gchar *uri = NULL; + g_autofree gchar *value = NULL; + + g_object_get(resource, + B_CORE_RESOURCE_PROPERTY_NAMES[B_CORE_RESOURCE_PROP_URI], + &uri, + B_CORE_RESOURCE_PROPERTY_NAMES[B_CORE_RESOURCE_PROP_VALUE], + &value, + NULL); + + if (uri == NULL) + { + return; + } + + g_autofree gchar *remoteSdpUri = g_strdup_printf("/%s/ep/webrtc/r/remoteSdp", self->deviceId); + g_autofree gchar *remoteIceUri = g_strdup_printf("/%s/ep/webrtc/r/remoteIceCandidates", self->deviceId); + g_autofree gchar *webrtcErrorUri = g_strdup_printf("/%s/ep/webrtc/r/webrtcError", self->deviceId); + + if (g_strcmp0(uri, remoteSdpUri) == 0 && value != NULL) + { + if (self->onRemoteSdp != NULL) + { + self->onRemoteSdp(value, self->userData); + } + } + else if (g_strcmp0(uri, remoteIceUri) == 0 && value != NULL) + { + if (self->onRemoteIce != NULL) + { + self->onRemoteIce(value, self->userData); + } + } + else if (g_strcmp0(uri, webrtcErrorUri) == 0) + { + // Any webrtcError event (ended/failed) is a session-terminated signal. Surface the + // human-readable detail from the metadata, falling back to the event value. + if (self->onError != NULL) + { + g_autofree gchar *reason = NULL; + + if (metadata != NULL) + { + scoped_cJSON *meta = cJSON_Parse(metadata); + cJSON *detail = cJSON_IsObject(meta) ? cJSON_GetObjectItem(meta, "detail") : NULL; + + if (cJSON_IsString(detail)) + { + reason = g_strdup(detail->valuestring); + } + } + + self->onError(reason != NULL ? reason : (value != NULL ? value : "session ended"), self->userData); + } + } +} + +// ============================================================================ +// Public API +// ============================================================================ + +CameraDeviceSession *cameraDeviceSessionCreate(BCoreClient *client, + const gchar *deviceId, + CameraDeviceOnRemoteSdp onRemoteSdp, + CameraDeviceOnRemoteIce onRemoteIce, + CameraDeviceOnSessionError onError, + gpointer userData) +{ + if (client == NULL || deviceId == NULL) + { + return NULL; + } + + CameraDeviceSession *self = g_new0(CameraDeviceSession, 1); + self->client = client; + self->deviceId = g_strdup(deviceId); + self->onRemoteSdp = onRemoteSdp; + self->onRemoteIce = onRemoteIce; + self->onError = onError; + self->userData = userData; + + self->handlerId = + g_signal_connect(client, B_CORE_CLIENT_SIGNAL_NAME_RESOURCE_UPDATED, G_CALLBACK(onResourceUpdated), self); + + return self; +} + +bool cameraDeviceSessionOpen(CameraDeviceSession *session) +{ + g_return_val_if_fail(session != NULL, false); + + g_autofree gchar *uri = g_strdup_printf("/%s/ep/camera/r/createSession", session->deviceId); + g_free(session->sessionId); + session->sessionId = NULL; + + session->opened = b_core_client_execute_resource(session->client, uri, NULL, &session->sessionId); + + return session->opened; +} + +bool cameraDeviceSessionStartStream(CameraDeviceSession *session) +{ + g_return_val_if_fail(session != NULL, false); + + g_autofree gchar *uri = g_strdup_printf("/%s/ep/camera/r/stream", session->deviceId); + g_autofree gchar *result = NULL; + + if (!b_core_client_execute_resource(session->client, uri, session->sessionId, &result)) + { + emitError("[camera-stream] failed to start stream (execute %s)\n", uri); + + return false; + } + + // The stream execute returns { protocol, entryPoint } identifying the active protocol + // and the resource URI to begin signaling on. The client uses these rather than assuming + // a hard-coded protocol/URI. + if (result != NULL) + { + scoped_cJSON *info = cJSON_Parse(result); + + if (cJSON_IsObject(info)) + { + cJSON *protocol = cJSON_GetObjectItem(info, "protocol"); + cJSON *entryPoint = cJSON_GetObjectItem(info, "entryPoint"); + + if (cJSON_IsString(protocol)) + { + g_free(session->protocol); + session->protocol = g_strdup(protocol->valuestring); + } + + if (cJSON_IsString(entryPoint)) + { + g_free(session->entryPoint); + session->entryPoint = g_strdup(entryPoint->valuestring); + } + } + } + + return true; +} + +const gchar *cameraDeviceSessionGetProtocol(CameraDeviceSession *session) +{ + return session != NULL ? session->protocol : NULL; +} + +const gchar *cameraDeviceSessionGetEntryPoint(CameraDeviceSession *session) +{ + return session != NULL ? session->entryPoint : NULL; +} + +const gchar *cameraDeviceSessionGetRole(CameraDeviceSession *session) +{ + g_return_val_if_fail(session != NULL, NULL); + + // The negotiation role is a WebRTC concept, so it lives on the webrtc endpoint rather than in + // the abstract stream result. The resource reports the CAMERA's role (the client adopts the + // opposite). Read it lazily on first request and cache it for the session. + if (session->role == NULL) + { + g_autofree gchar *uri = g_strdup_printf("/%s/ep/webrtc/r/negotiationRole", session->deviceId); + g_autoptr(GError) err = NULL; + gchar *value = b_core_client_read_resource(session->client, uri, &err); + + if (value != NULL) + { + session->role = value; + } + else + { + emitError("[camera-stream] failed to read negotiation role from %s: %s\n", + uri, + err != NULL ? err->message : "(unknown error)"); + } + } + + return session->role; +} + +bool cameraDeviceSessionSendOffer(CameraDeviceSession *session, const gchar *sdp) +{ + g_return_val_if_fail(session != NULL, false); + + // Prefer the entry point the driver reported from the stream execute; fall back to the + // conventional webrtc offer URI if it was unavailable. + g_autofree gchar *fallback = NULL; + const gchar *uri = session->entryPoint; + + if (uri == NULL) + { + fallback = g_strdup_printf("/%s/ep/webrtc/r/localSdp", session->deviceId); + uri = fallback; + } + + return b_core_client_execute_resource(session->client, uri, sdp, NULL); +} + +bool cameraDeviceSessionSendIceCandidates(CameraDeviceSession *session, const gchar *jsonCandidates) +{ + g_return_val_if_fail(session != NULL, false); + + g_autofree gchar *uri = g_strdup_printf("/%s/ep/webrtc/r/localIceCandidates", session->deviceId); + + return b_core_client_execute_resource(session->client, uri, jsonCandidates, NULL); +} + +void cameraDeviceSessionDestroy(CameraDeviceSession *session) +{ + g_return_if_fail(session != NULL); + + // End the session on the device so it is never leaked if it was opened. + if (session->opened) + { + g_autofree gchar *uri = g_strdup_printf("/%s/ep/camera/r/destroySession", session->deviceId); + b_core_client_execute_resource(session->client, uri, session->sessionId, NULL); + } + + if (session->handlerId != 0) + { + g_signal_handler_disconnect(session->client, session->handlerId); + } + + g_free(session->sessionId); + g_free(session->protocol); + g_free(session->entryPoint); + g_free(session->role); + g_free(session->deviceId); + g_free(session); +} diff --git a/reference/src/cameraDeviceSession.h b/reference/src/cameraDeviceSession.h new file mode 100644 index 00000000..5b03164d --- /dev/null +++ b/reference/src/cameraDeviceSession.h @@ -0,0 +1,155 @@ +//------------------------------ tabstop = 4 ---------------------------------- +// +// If not stated otherwise in this file or this component's LICENSE file the +// following copyright and licenses apply: +// +// Copyright 2026 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// +//------------------------------ tabstop = 4 ---------------------------------- + +/* + * Camera session flows over the Barton client interface for the camera stream + * command. + * + * Owns the camera/webrtc resource URIs for a single device: creating and + * destroying the camera session, starting the stream, relaying the local SDP + * offer and ICE candidates to the camera, and subscribing to resource-updated + * events to deliver the remote SDP answer, remote ICE candidates, and session + * status back to the caller via callbacks. + */ + +#pragma once + +#include "barton-core-client.h" +#include +#include + +typedef struct _CameraDeviceSession CameraDeviceSession; + +/** + * Callback invoked when the camera's remote SDP answer arrives. + * + * @param sdp the remote SDP answer string (caller does not free) + * @param userData opaque user data + */ +typedef void (*CameraDeviceOnRemoteSdp)(const gchar *sdp, gpointer userData); + +/** + * Callback invoked when the camera's remote ICE candidates arrive. + * + * @param jsonCandidates a JSON array string of candidate strings (caller does not free) + * @param userData opaque user data + */ +typedef void (*CameraDeviceOnRemoteIce)(const gchar *jsonCandidates, gpointer userData); + +/** + * Callback invoked when the camera reports the session ended in error. + * + * @param message a human-readable error message (caller does not free) + * @param userData opaque user data + */ +typedef void (*CameraDeviceOnSessionError)(const gchar *message, gpointer userData); + +/** + * Create a camera device session and subscribe to resource-updated events. + * + * @param client the Barton client + * @param deviceId the camera device id + * @param onRemoteSdp callback for the remote SDP answer + * @param onRemoteIce callback for remote ICE candidates + * @param onError callback for a session-ended-in-error status + * @param userData opaque data passed to callbacks + * @return the session, or NULL on error + */ +CameraDeviceSession *cameraDeviceSessionCreate(BCoreClient *client, + const gchar *deviceId, + CameraDeviceOnRemoteSdp onRemoteSdp, + CameraDeviceOnRemoteIce onRemoteIce, + CameraDeviceOnSessionError onError, + gpointer userData); + +/** + * Create the camera session (allocates a session id on the camera). + * + * @param session the session + * @return true on success + */ +bool cameraDeviceSessionOpen(CameraDeviceSession *session); + +/** + * Start the camera stream for the open session. + * + * @param session the session + * @return true on success + */ +bool cameraDeviceSessionStartStream(CameraDeviceSession *session); + +/** + * Get the active protocol reported by the stream execute (e.g. "webrtc"), or NULL if + * the stream has not started or the driver did not report one. The session owns the string. + * + * @param session the session + * @return the protocol identifier, or NULL + */ +const gchar *cameraDeviceSessionGetProtocol(CameraDeviceSession *session); + +/** + * Get the entry-point resource URI reported by the stream execute, or NULL if unavailable. + * The session owns the string. + * + * @param session the session + * @return the entry-point URI, or NULL + */ +const gchar *cameraDeviceSessionGetEntryPoint(CameraDeviceSession *session); + +/** + * Get the CAMERA's negotiation role for the active protocol ("offerer" or "answerer"), or NULL if + * unavailable; the client adopts the opposite role. For WebRTC this is read from the webrtc + * endpoint's negotiationRole resource on first request and cached. The session owns the string. + * + * @param session the session + * @return the role string (the camera's role), or NULL + */ +const gchar *cameraDeviceSessionGetRole(CameraDeviceSession *session); + +/** + * Send the local SDP offer to the camera. + * + * @param session the session + * @param sdp the local SDP offer string + * @return true on success + */ +bool cameraDeviceSessionSendOffer(CameraDeviceSession *session, const gchar *sdp); + +/** + * Send local ICE candidates to the camera. + * + * @param session the session + * @param jsonCandidates a JSON array string of candidate strings + * @return true on success + */ +bool cameraDeviceSessionSendIceCandidates(CameraDeviceSession *session, const gchar *jsonCandidates); + +/** + * Unsubscribe from events and free the session. If the session was successfully + * opened, this first ends it on the device (EndSession) so it is never leaked. + * + * @param session the session (may be NULL) + */ +void cameraDeviceSessionDestroy(CameraDeviceSession *session); + +G_DEFINE_AUTOPTR_CLEANUP_FUNC(CameraDeviceSession, cameraDeviceSessionDestroy) diff --git a/reference/src/cameraMediaServer.c b/reference/src/cameraMediaServer.c new file mode 100644 index 00000000..0bda3aad --- /dev/null +++ b/reference/src/cameraMediaServer.c @@ -0,0 +1,551 @@ +//------------------------------ tabstop = 4 ---------------------------------- +// +// If not stated otherwise in this file or this component's LICENSE file the +// following copyright and licenses apply: +// +// Copyright 2026 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// +//------------------------------ tabstop = 4 ---------------------------------- + +#include "cameraMediaServer.h" +#include "barton-core-reference-io.h" +#include +#include + +// Self-contained player page. Uses Media Source Extensions to play the live fragmented MP4: it +// streams /stream.mp4 with fetch(), appends fragments to a SourceBuffer in 'sequence' mode, trims +// the buffer to stay near the live edge, and seeks/recovers if it falls behind or stalls. This is +// far more robust for a continuously-growing live stream than a plain progressive