Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions docs/websocket-api-schema-changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
17 changes: 17 additions & 0 deletions docs/websockets_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<base64-encoded .ota file contents>",
"file_name": "my-device-v2.ota"
}
}
```

### ICD Management

Manage this controller's Intermittently Connected Device (ICD) Check-In registration with a peer node.
Expand Down
62 changes: 62 additions & 0 deletions packages/dashboard/src/pages/components/node-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
mdiShareVariant,
mdiTrashCan,
mdiUpdate,
mdiUpload,
mdiVideo,
} from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
Expand Down Expand Up @@ -84,6 +85,9 @@ export class NodeDetails extends LitElement {
@state()
private _updateInitiated: boolean = false;

@state()
private _otaUploadInProgress: boolean = false;

@consume({ context: bindingContext })
endpoint!: number;

Expand Down Expand Up @@ -170,6 +174,13 @@ export class NodeDetails extends LitElement {
: html`<md-outlined-button @click=${handleAsync(() => this._searchUpdate())}
>Update<ha-svg-icon slot="icon" .path=${mdiUpdate}></ha-svg-icon
></md-outlined-button>`}
<md-outlined-button
@click=${handleAsync(() => this._uploadOtaFile())}
?disabled=${this._otaUploadInProgress}
>
${this._otaUploadInProgress ? "Uploading…" : "Upload Firmware"}
<ha-svg-icon slot="icon" .path=${mdiUpload}></ha-svg-icon>
</md-outlined-button>
${isCamera
? html`
<md-outlined-button
Expand All @@ -193,6 +204,7 @@ export class NodeDetails extends LitElement {
</div>
</md-list-item>
</md-list>
<input @change=${this._onOtaFileInput} type="file" id="otaFileElem" accept=".ota" style="display:none" />
`;
}

Expand Down Expand Up @@ -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({
Expand Down
12 changes: 10 additions & 2 deletions packages/matter-server/test/IntegrationTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
});
Expand Down Expand Up @@ -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);
});
});
});

Expand Down
5 changes: 5 additions & 0 deletions packages/ws-client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MatterSoftwareVersion> {
// 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",
Expand Down
4 changes: 4 additions & 0 deletions packages/ws-client/src/models/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, never>;
response: CommissionableNodeData[];
Expand Down
35 changes: 35 additions & 0 deletions packages/ws-controller/src/controller/ControllerCommandHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
camelize,
ClientNode,
CommissioningClient,
DclBehavior,
FabricId,
FabricIndex,
IcdClient,
Expand Down Expand Up @@ -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<ArrayBuffer>, fileName?: string): Promise<MatterSoftwareVersion> {
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.
*/
Expand Down
18 changes: 16 additions & 2 deletions packages/ws-controller/src/server/WebSocketControllerHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<ResponseOf<"upload_ota_file">> {
const { data_base64, file_name } = args;
let data: Uint8Array<ArrayBuffer>;
try {
data = Bytes.fromBase64(data_base64) as Uint8Array<ArrayBuffer>;
} 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);
Expand Down
6 changes: 6 additions & 0 deletions packages/ws-controller/src/types/WebSocketMessageTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

/**
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/ws-controller/test/WebSocketCredentialsApiTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
4 changes: 2 additions & 2 deletions python_client/tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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)

Expand Down