diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eb9dc63..8aa3961b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This page shows a detailed overview of the changes between versions without the ## **WORK IN PROGRESS** +- Feature: (lboue) Dashboard supports uploading a local `.ota` firmware file for a node, stored via a new `upload_ota_file` WebSocket command (schema 13) - Fix: Fixes the DST determination - Fix: Command responses and events now expose acronym field names in the Python Matter Server casing (e.g. `videoStreamID`, `groupID`, `PAKEPasscodeVerifier`), matching the generated Python client and Home Assistant; the previous lowercased-acronym keys are still emitted alongside for compatibility diff --git a/docs/websocket-api-schema-changelog.md b/docs/websocket-api-schema-changelog.md index d67a5450..381502ab 100644 --- a/docs/websocket-api-schema-changelog.md +++ b/docs/websocket-api-schema-changelog.md @@ -10,6 +10,20 @@ The server-side constants live in `packages/ws-controller/src/server/WebSocketCo (`SCHEMA_VERSION`, `MIN_SUPPORTED_SCHEMA_VERSION`). Versions predating this document are not listed retroactively; entries start at the first version maintained here. +## Schema 13 + +Minimum supported: 11 (older clients keep working with the pre-13 command shapes). + +### OTA firmware upload + +- **New command `upload_ota_file`** → `{ data_base64: string, file_name?: string }` → `MatterSoftwareVersion`. Stores a + base64-encoded `.ota` firmware image (e.g. from a dashboard file picker) into the server's local OTA image store, + the same store populated by `--otaProviderDir` directory scanning. The returned `MatterSoftwareVersion` reflects + the vendor ID / product ID / software version parsed from the uploaded image's header, with `update_source` always + `"local"`. Once stored, the image is discoverable via `check_node_update` for any node whose vendor/product ID + matches — uploading does not target a specific node. Fails with the OHF-extension error code `OtaUploadError` (101) + on a corrupt/invalid image or if OTA support is disabled on the server. + ## Schema 12 Minimum supported: 11 (older clients keep working with the pre-12 command shapes). diff --git a/docs/websockets_api.md b/docs/websockets_api.md index 6eefec3a..b0f5990d 100644 --- a/docs/websockets_api.md +++ b/docs/websockets_api.md @@ -660,6 +660,23 @@ Entry fields: } ``` +**upload_ota_file** - Store a local `.ota` firmware file in the OTA image store + +The image is stored by vendor ID / product ID / software version parsed from its header, not +tied to the node the upload was initiated from — `check_node_update` will surface it for any +node whose vendor/product matches. + +```json +{ + "message_id": "1", + "command": "upload_ota_file", + "args": { + "data_base64": "", + "file_name": "my-device-v2.ota" + } +} +``` + ### ICD Management Manage this controller's Intermittently Connected Device (ICD) Check-In registration with a peer node. diff --git a/packages/dashboard/src/pages/components/node-details.ts b/packages/dashboard/src/pages/components/node-details.ts index 5f28ec91..8c7d07ad 100644 --- a/packages/dashboard/src/pages/components/node-details.ts +++ b/packages/dashboard/src/pages/components/node-details.ts @@ -21,6 +21,7 @@ import { mdiShareVariant, mdiTrashCan, mdiUpdate, + mdiUpload, mdiVideo, } from "@mdi/js"; import { LitElement, css, html, nothing } from "lit"; @@ -84,6 +85,9 @@ export class NodeDetails extends LitElement { @state() private _updateInitiated: boolean = false; + @state() + private _otaUploadInProgress: boolean = false; + @consume({ context: bindingContext }) endpoint!: number; @@ -170,6 +174,13 @@ export class NodeDetails extends LitElement { : html` this._searchUpdate())} >Update`} + this._uploadOtaFile())} + ?disabled=${this._otaUploadInProgress} + > + ${this._otaUploadInProgress ? "Uploading…" : "Upload Firmware"} + + ${isCamera ? html` + `; } @@ -325,6 +337,56 @@ export class NodeDetails extends LitElement { } } + private async _uploadOtaFile() { + if ( + !(await showPromptDialog({ + title: "Upload OTA firmware file", + text: "Select a local .ota firmware image to store on the server. It will only apply to devices whose vendor and product ID match the image.", + confirmText: "Select file", + })) + ) { + return; + } + const fileElem = this.shadowRoot!.getElementById("otaFileElem") as HTMLInputElement; + fileElem.click(); + } + + private _onOtaFileInput = (event: Event) => { + const fileElem = event.target as HTMLInputElement; + if (fileElem.files!.length > 0) { + const selectedFile = fileElem.files![0]; + const reader = new FileReader(); + reader.readAsDataURL(selectedFile); + reader.onload = async () => { + const dataUrl = reader.result?.toString() ?? ""; + const dataBase64 = dataUrl.slice(dataUrl.indexOf(",") + 1); + try { + this._otaUploadInProgress = true; + const info = await this.client.uploadOtaFile(dataBase64, selectedFile.name); + const nodeVendorId = this.node?.attributes["0/40/2"]; + const nodeProductId = this.node?.attributes["0/40/4"]; + if (nodeVendorId === info.vid && nodeProductId === info.pid) { + await this._searchUpdate(); + } else { + showAlertDialog({ + title: "Firmware stored", + text: `Stored firmware for vendor 0x${info.vid.toString(16)} / product 0x${info.pid.toString(16)}, which does not match this device. It remains available on the server for a matching device.`, + }); + } + } catch (err: any) { + showAlertDialog({ + title: "Failed to upload firmware", + text: err.message, + }); + } finally { + this._otaUploadInProgress = false; + fileElem.value = ""; + } + }; + } + event.preventDefault(); + }; + private async _openCommissioningWindow() { if ( !(await showPromptDialog({ diff --git a/packages/matter-server/test/IntegrationTest.ts b/packages/matter-server/test/IntegrationTest.ts index 1efae3b8..ae9e830e 100644 --- a/packages/matter-server/test/IntegrationTest.ts +++ b/packages/matter-server/test/IntegrationTest.ts @@ -129,7 +129,7 @@ describe("Integration Test", function () { expect(info).to.have.property("fabric_id"); expect(info).to.have.property("compressed_fabric_id"); - expect(info.schema_version).to.equal(12); + expect(info.schema_version).to.equal(13); expect(info.min_supported_schema_version).to.equal(11); expect(info.sdk_version).to.be.a("string").that.includes("matter-server"); expect(info.sdk_version).to.be.a("string").that.includes("matter.js"); @@ -172,7 +172,7 @@ describe("Integration Test", function () { expect(diag).to.have.property("info"); expect(diag).to.have.property("nodes"); expect(diag).to.have.property("events"); - expect(diag.info.schema_version).to.equal(12); + expect(diag.info.schema_version).to.equal(13); expect(diag.nodes).to.be.an("array"); expect(diag.events).to.be.an("array"); }); @@ -284,6 +284,14 @@ describe("Integration Test", function () { expect(error.error_code).to.equal(ServerErrorCode.NodeNotExists); expect(error.details).to.include("999999"); }); + + it("should return OtaUploadError for corrupt OTA upload data", async function () { + const error = await client.sendCommandExpectError("upload_ota_file", { + data_base64: Buffer.from("not a real ota file").toString("base64"), + }); + + expect(error.error_code).to.equal(ServerErrorCode.OtaUploadError); + }); }); }); diff --git a/packages/ws-client/src/client.ts b/packages/ws-client/src/client.ts index ee4ddd48..41aaba84 100644 --- a/packages/ws-client/src/client.ts +++ b/packages/ws-client/src/client.ts @@ -381,6 +381,11 @@ export class MatterClient { await this.sendCommand("update_node", 10, { node_id: nodeId, software_version: softwareVersion }, timeout); } + async uploadOtaFile(dataBase64: string, fileName?: string, timeout?: number): Promise { + // Store a locally-uploaded .ota firmware file in the server's OTA image store. + return await this.sendCommand("upload_ota_file", 13, { data_base64: dataBase64, file_name: fileName }, timeout); + } + async setACLEntry(nodeId: number | bigint, entry: AccessControlEntry[], timeout?: number) { return await this.sendCommand( "set_acl_entry", diff --git a/packages/ws-client/src/models/model.ts b/packages/ws-client/src/models/model.ts index e0505ebb..291feef3 100644 --- a/packages/ws-client/src/models/model.ts +++ b/packages/ws-client/src/models/model.ts @@ -446,6 +446,10 @@ export interface APICommands { requestArgs: { node_id: number | bigint; software_version: number | string }; response: MatterSoftwareVersion | null; }; + upload_ota_file: { + requestArgs: { data_base64: string; file_name?: string }; + response: MatterSoftwareVersion; + }; discover_commissionable_nodes: { requestArgs: Record; response: CommissionableNodeData[]; diff --git a/packages/ws-controller/src/controller/ControllerCommandHandler.ts b/packages/ws-controller/src/controller/ControllerCommandHandler.ts index 272f5e0e..9330c2db 100644 --- a/packages/ws-controller/src/controller/ControllerCommandHandler.ts +++ b/packages/ws-controller/src/controller/ControllerCommandHandler.ts @@ -11,6 +11,7 @@ import { camelize, ClientNode, CommissioningClient, + DclBehavior, FabricId, FabricIndex, IcdClient, @@ -1764,6 +1765,40 @@ export class ControllerCommandHandler { return this.#convertToMatterSoftwareVersion(updateInfo); } + /** + * Store an uploaded OTA firmware image into the local OTA image store. + * @param data Raw bytes of the .ota file + * @param fileName Original file name, used to namespace the local otaUrl + */ + async uploadOtaFile(data: Uint8Array, fileName?: string): Promise { + if (!this.#otaEnabled) { + throw ServerError.otaUploadError("OTA is disabled"); + } + try { + const otaService = await this.#controller.node.act(agent => agent.get(DclBehavior).otaUpdateService); + await otaService.construction; + + const otaUrl = `local://${fileName ?? "upload"}`; + const blob = new Blob([data]); + const updateInfo = await otaService.updateInfoFromStream(blob.stream(), otaUrl); + await otaService.store(blob.stream(), updateInfo, "local"); + + return { + vid: updateInfo.vid, + pid: updateInfo.pid, + software_version: updateInfo.softwareVersion, + software_version_string: updateInfo.softwareVersionString, + min_applicable_software_version: updateInfo.minApplicableSoftwareVersion, + max_applicable_software_version: updateInfo.maxApplicableSoftwareVersion, + release_notes_url: updateInfo.releaseNotesUrl, + update_source: UpdateSource.LOCAL, + }; + } catch (error) { + if (error instanceof ServerError) throw error; + throw ServerError.otaUploadError(`Failed to store OTA image: ${(error as Error).message}`, error as Error); + } + } + /** * Convert SoftwareUpdateInfo to MatterSoftwareVersion format for WebSocket API. */ diff --git a/packages/ws-controller/src/server/WebSocketControllerHandler.ts b/packages/ws-controller/src/server/WebSocketControllerHandler.ts index c4e25b5a..61d22068 100644 --- a/packages/ws-controller/src/server/WebSocketControllerHandler.ts +++ b/packages/ws-controller/src/server/WebSocketControllerHandler.ts @@ -87,14 +87,14 @@ function generateConnectionId(): string { return id.toString(16); } -const SCHEMA_VERSION = 12; +const SCHEMA_VERSION = 13; const MIN_SUPPORTED_SCHEMA_VERSION = 11; // Issuing any of these (schema 12) proves the connection is Thread-aware, so it opts the connection // in to `thread_diagnostics_updated` — even if the request itself errors. See the schema changelog. const THREAD_DIAGNOSTICS_OPT_IN_COMMANDS = new Set(["get_thread_diagnostics", "get_thread_border_routers"]); -const skipMessageContentInLogFor = ["start_listening"]; +const skipMessageContentInLogFor = ["start_listening", "upload_ota_file"]; /** Normalize a requested fabric label: matter.js requires a non-empty label of 1-32 chars. */ function normalizeFabricLabel(label: string | null): string { @@ -754,6 +754,9 @@ export class WebSocketControllerHandler implements WebServerHandler { case "update_node": result = await this.#handleUpdateNode(args); break; + case "upload_ota_file": + result = await this.#handleUploadOtaFile(args); + break; case "server_info": result = await this.#getServerInfo(); break; @@ -1543,6 +1546,17 @@ export class WebSocketControllerHandler implements WebServerHandler { return await this.#commandHandler.updateNode(NodeId(node_id), targetVersion); } + async #handleUploadOtaFile(args: ArgsOf<"upload_ota_file">): Promise> { + const { data_base64, file_name } = args; + let data: Uint8Array; + try { + data = Bytes.fromBase64(data_base64) as Uint8Array; + } catch (error) { + throw ServerError.otaUploadError("Invalid file data", error as Error); + } + return await this.#commandHandler.uploadOtaFile(data, file_name); + } + #collectNodeDetails(nodeId: NodeId): MatterNode { const lastInterviewDate = this.#lastInterviewDates.get(nodeId); return this.#commandHandler.getNodeDetails(nodeId, lastInterviewDate); diff --git a/packages/ws-controller/src/types/WebSocketMessageTypes.ts b/packages/ws-controller/src/types/WebSocketMessageTypes.ts index a110b18a..82edc08e 100644 --- a/packages/ws-controller/src/types/WebSocketMessageTypes.ts +++ b/packages/ws-controller/src/types/WebSocketMessageTypes.ts @@ -80,6 +80,8 @@ export enum ServerErrorCode { UpdateError = 11, /** OHF extension (not python-matter-server): value must equal ICD_MULTI_ADMIN_ERROR_CODE in ws-client model.ts. */ IcdMultiAdmin = 100, + /** OHF extension (not python-matter-server): OTA firmware image upload failed (corrupt file / store failure). */ + OtaUploadError = 101, } /** @@ -144,6 +146,10 @@ export class ServerError extends Error { return new ServerError(ServerErrorCode.UpdateError, message, cause); } + static otaUploadError(message: string, cause?: Error): ServerError { + return new ServerError(ServerErrorCode.OtaUploadError, message, cause); + } + static icdMultiAdmin(adminVendorIds: number[]): ServerError { return new ServerError( ServerErrorCode.IcdMultiAdmin, diff --git a/packages/ws-controller/test/WebSocketCredentialsApiTest.ts b/packages/ws-controller/test/WebSocketCredentialsApiTest.ts index fba994fd..9f28f38d 100644 --- a/packages/ws-controller/test/WebSocketCredentialsApiTest.ts +++ b/packages/ws-controller/test/WebSocketCredentialsApiTest.ts @@ -387,12 +387,12 @@ describe("WebSocket Credentials API", () => { expect(def?.extPanId).to.equal(def?.extPanId?.toUpperCase()); }); - it("server_info reports schema 12 / min 11", async () => { + it("server_info reports schema 13 / min 11", async () => { const info = await h.handle<{ schema_version: number; min_supported_schema_version: number }>( "server_info", {}, ); - expect(info.schema_version).to.equal(12); + expect(info.schema_version).to.equal(13); expect(info.min_supported_schema_version).to.equal(11); }); diff --git a/python_client/tests/test_integration.py b/python_client/tests/test_integration.py index afb60c64..d7b6df35 100644 --- a/python_client/tests/test_integration.py +++ b/python_client/tests/test_integration.py @@ -162,7 +162,7 @@ async def test_02_server_info_command(self, env): assert "fabric_id" in info assert "compressed_fabric_id" in info - assert info["schema_version"] == 12 + assert info["schema_version"] == 13 assert info["min_supported_schema_version"] == 11 assert "matter-server" in info["sdk_version"] assert "matter.js" in info["sdk_version"] @@ -207,7 +207,7 @@ async def test_07_diagnostics(self, env): client: MatterTestClient = env["client"] diag = await client.get_diagnostics() assert diag.info is not None - assert diag.info.schema_version == 12 + assert diag.info.schema_version == 13 assert isinstance(diag.nodes, list) assert isinstance(diag.events, list)