diff --git a/src/data/zwave_js.ts b/src/data/zwave_js.ts index b79590c6bd..6f397b87fc 100644 --- a/src/data/zwave_js.ts +++ b/src/data/zwave_js.ts @@ -85,6 +85,22 @@ enum Protocols { ZWaveLongRange = 1, } +export enum FirmwareUpdateStatus { + Error_Timeout = -1, + Error_Checksum = 0, + Error_TransmissionFailed = 1, + Error_InvalidManufacturerID = 2, + Error_InvalidFirmwareID = 3, + Error_InvalidFirmwareTarget = 4, + Error_InvalidHeaderInformation = 5, + Error_InvalidHeaderFormat = 6, + Error_InsufficientMemory = 7, + Error_InvalidHardwareVersion = 8, + OK_WaitingForActivation = 0xfd, + OK_NoRestart = 0xfe, + OK_RestartPending = 0xff, +} + export interface QRProvisioningInformation { version: QRCodeVersion; securityClasses: SecurityClass[]; @@ -147,7 +163,7 @@ export interface ZWaveJSController { export interface ZWaveJSNodeStatus { node_id: number; ready: boolean; - status: number; + status: NodeStatus; is_secure: boolean | string; is_routing: boolean | null; zwave_plus_version: number | null; @@ -281,6 +297,27 @@ export interface ZWaveJSNodeStatusUpdatedMessage { status: NodeStatus; } +export interface ZWaveJSNodeFirmwareUpdateProgressMessage { + event: "firmware update progress"; + sent_fragments: number; + total_fragments: number; +} + +export interface ZWaveJSNodeFirmwareUpdateFinishedMessage { + event: "firmware update finished"; + status: FirmwareUpdateStatus; + wait_time: number; +} + +export type ZWaveJSNodeFirmwareUpdateCapabilities = + | { firmware_upgradable: false } + | { + firmware_upgradable: true; + firmware_targets: number[]; + continues_to_function: boolean | null; + supports_activation: boolean | null; + }; + export interface ZWaveJSRemovedNode { node_id: number; manufacturer: string; @@ -628,6 +665,74 @@ export const subscribeZwaveNodeStatistics = ( } ); +export const fetchZwaveNodeIsFirmwareUpdateInProgress = ( + hass: HomeAssistant, + device_id: string +): Promise => + hass.callWS({ + type: "zwave_js/get_firmware_update_progress", + device_id, + }); + +export const fetchZwaveNodeFirmwareUpdateCapabilities = ( + hass: HomeAssistant, + device_id: string +): Promise => + hass.callWS({ + type: "zwave_js/get_firmware_update_capabilities", + device_id, + }); + +export const uploadFirmwareAndBeginUpdate = async ( + hass: HomeAssistant, + device_id: string, + file: File, + target?: number +) => { + const fd = new FormData(); + fd.append("file", file); + if (target !== undefined) { + fd.append("target", target.toString()); + } + const resp = await hass.fetchWithAuth( + `/api/zwave_js/firmware/upload/${device_id}`, + { + method: "POST", + body: fd, + } + ); + + if (resp.status !== 200) { + throw new Error(resp.statusText); + } +}; + +export const subscribeZwaveNodeFirmwareUpdate = ( + hass: HomeAssistant, + device_id: string, + callbackFunction: ( + message: + | ZWaveJSNodeFirmwareUpdateFinishedMessage + | ZWaveJSNodeFirmwareUpdateProgressMessage + ) => void +): Promise => + hass.connection.subscribeMessage( + (message: any) => callbackFunction(message), + { + type: "zwave_js/subscribe_firmware_update_status", + device_id, + } + ); + +export const abortZwaveNodeFirmwareUpdate = ( + hass: HomeAssistant, + device_id: string +): Promise => + hass.callWS({ + type: "zwave_js/abort_firmware_update", + device_id, + }); + export type ZWaveJSLogUpdate = ZWaveJSLogMessageUpdate | ZWaveJSLogConfigUpdate; interface ZWaveJSLogMessageUpdate { diff --git a/src/panels/config/devices/device-detail/integration-elements/zwave_js/device-actions.ts b/src/panels/config/devices/device-detail/integration-elements/zwave_js/device-actions.ts index 82694f4fa7..6ee8c30dc5 100644 --- a/src/panels/config/devices/device-detail/integration-elements/zwave_js/device-actions.ts +++ b/src/panels/config/devices/device-detail/integration-elements/zwave_js/device-actions.ts @@ -1,11 +1,16 @@ import { getConfigEntries } from "../../../../../../data/config_entries"; import { DeviceRegistryEntry } from "../../../../../../data/device_registry"; -import { fetchZwaveNodeStatus } from "../../../../../../data/zwave_js"; +import { + fetchZwaveNodeFirmwareUpdateCapabilities, + fetchZwaveNodeStatus, +} from "../../../../../../data/zwave_js"; +import { showConfirmationDialog } from "../../../../../../dialogs/generic/show-dialog-box"; import type { HomeAssistant } from "../../../../../../types"; import { showZWaveJSHealNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-heal-node"; import { showZWaveJSNodeStatisticsDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-node-statistics"; import { showZWaveJSReinterviewNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node"; import { showZWaveJSRemoveFailedNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-failed-node"; +import { showZWaveJUpdateFirmwareNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-update-firmware-node"; import type { DeviceAction } from "../../../ha-config-device-page"; export const getZwaveDeviceActions = async ( @@ -27,13 +32,13 @@ export const getZwaveDeviceActions = async ( const entryId = configEntry.entry_id; - const node = await fetchZwaveNodeStatus(hass, device.id); + const nodeStatus = await fetchZwaveNodeStatus(hass, device.id); - if (!node || node.is_controller_node) { + if (!nodeStatus || nodeStatus.is_controller_node) { return []; } - return [ + const actions = [ { label: hass.localize( "ui.panel.config.zwave_js.device_info.device_config" @@ -53,7 +58,7 @@ export const getZwaveDeviceActions = async ( label: hass.localize("ui.panel.config.zwave_js.device_info.heal_node"), action: () => showZWaveJSHealNodeDialog(el, { - device: device, + device, }), }, { @@ -71,8 +76,41 @@ export const getZwaveDeviceActions = async ( ), action: () => showZWaveJSNodeStatisticsDialog(el, { - device: device, + device, }), }, ]; + + if (!nodeStatus.ready) { + return actions; + } + + const firmwareUpdateCapabilities = + await fetchZwaveNodeFirmwareUpdateCapabilities(hass, device.id); + + if (firmwareUpdateCapabilities.firmware_upgradable) { + actions.push({ + label: hass.localize( + "ui.panel.config.zwave_js.device_info.update_firmware" + ), + action: async () => { + if ( + await showConfirmationDialog(el, { + text: hass.localize( + "ui.panel.config.zwave_js.update_firmware.warning" + ), + dismissText: hass.localize("ui.common.no"), + confirmText: hass.localize("ui.common.yes"), + }) + ) { + showZWaveJUpdateFirmwareNodeDialog(el, { + device, + firmwareUpdateCapabilities, + }); + } + }, + }); + } + + return actions; }; diff --git a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-update-firmware-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-update-firmware-node.ts new file mode 100644 index 0000000000..b0f76d331d --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-update-firmware-node.ts @@ -0,0 +1,461 @@ +import "../../../../../components/ha-file-upload"; +import "../../../../../components/ha-form/ha-form"; +import "../../../../../components/ha-svg-icon"; +import "@material/mwc-button/mwc-button"; +import "@material/mwc-linear-progress/mwc-linear-progress"; +import { mdiCheckCircle, mdiCloseCircle, mdiFileUpload } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { fireEvent } from "../../../../../common/dom/fire_event"; +import { createCloseHeading } from "../../../../../components/ha-dialog"; +import { + DeviceRegistryEntry, + computeDeviceName, +} from "../../../../../data/device_registry"; +import { + abortZwaveNodeFirmwareUpdate, + fetchZwaveNodeIsFirmwareUpdateInProgress, + fetchZwaveNodeStatus, + FirmwareUpdateStatus, + NodeStatus, + subscribeZwaveNodeStatus, + subscribeZwaveNodeFirmwareUpdate, + uploadFirmwareAndBeginUpdate, + ZWaveJSNodeFirmwareUpdateFinishedMessage, + ZWaveJSNodeFirmwareUpdateProgressMessage, + ZWaveJSNodeStatusUpdatedMessage, + ZWaveJSNodeFirmwareUpdateCapabilities, + ZWaveJSNodeStatus, +} from "../../../../../data/zwave_js"; +import { haStyleDialog } from "../../../../../resources/styles"; +import { HomeAssistant } from "../../../../../types"; +import { ZWaveJSUpdateFirmwareNodeDialogParams } from "./show-dialog-zwave_js-update-firmware-node"; +import { + showAlertDialog, + showConfirmationDialog, +} from "../../../../../dialogs/generic/show-dialog-box"; +import { HaFormIntegerSchema } from "../../../../../components/ha-form/types"; + +@customElement("dialog-zwave_js-update-firmware-node") +class DialogZWaveJSUpdateFirmwareNode extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private device?: DeviceRegistryEntry; + + @state() private _uploading = false; + + @state() + private _updateFinishedMessage?: ZWaveJSNodeFirmwareUpdateFinishedMessage; + + @state() + private _updateProgressMessage?: ZWaveJSNodeFirmwareUpdateProgressMessage; + + @state() private _updateInProgress = false; + + @state() private _firmwareFile?: File; + + @state() private _nodeStatus?: ZWaveJSNodeStatus; + + @state() private _firmwareTarget? = 0; + + private _subscribedNodeStatus?: Promise; + + private _subscribedNodeFirmwareUpdate?: Promise; + + private _deviceName?: string; + + private _firmwareUpdateCapabilities?: ZWaveJSNodeFirmwareUpdateCapabilities; + + public showDialog(params: ZWaveJSUpdateFirmwareNodeDialogParams): void { + this._deviceName = computeDeviceName(params.device, this.hass!); + this.device = params.device; + this._firmwareUpdateCapabilities = params.firmwareUpdateCapabilities; + this._fetchData(); + this._subscribeNodeStatus(); + } + + public closeDialog(): void { + this._unsubscribeNodeFirmwareUpdate(); + this._unsubscribeNodeStatus(); + this.device = + this._updateProgressMessage = + this._updateFinishedMessage = + this._firmwareFile = + this._nodeStatus = + this._firmwareUpdateCapabilities = + undefined; + this._firmwareTarget = 0; + this._uploading = this._updateInProgress = false; + + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + private _schema = memoizeOne( + ( + firmwareUpdateCapabilities: ZWaveJSNodeFirmwareUpdateCapabilities + ): HaFormIntegerSchema => { + if (!firmwareUpdateCapabilities.firmware_upgradable) { + // We should never get here, this is to pass type checks + throw new Error(); + } + return { + name: "firmware_target", + type: "integer", + valueMin: Math.min(...firmwareUpdateCapabilities.firmware_targets), + valueMax: Math.max(...firmwareUpdateCapabilities.firmware_targets), + }; + } + ); + + protected render(): TemplateResult { + if ( + !this.device || + !this._nodeStatus || + !this._firmwareUpdateCapabilities || + !this._firmwareUpdateCapabilities.firmware_upgradable || + this._updateInProgress === undefined + ) { + return html``; + } + + const beginFirmwareUpdateHTML = html` + ${this._firmwareUpdateCapabilities.firmware_targets.length > 1 + ? html`

+ ${this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.firmware_target_intro" + )} +

+ ` + : ""} + + ${this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.begin_update" + )} + `; + + const abortFirmwareUpdateButton = html` + + ${this.hass.localize("ui.panel.config.zwave_js.update_firmware.abort")} + + `; + + const status = this._updateFinishedMessage + ? FirmwareUpdateStatus[this._updateFinishedMessage.status] + .split("_")[0] + .toLowerCase() + : undefined; + + return html` + + ${!this._updateProgressMessage && !this._updateFinishedMessage + ? !this._updateInProgress + ? html` +

+ ${this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.introduction", + { + device: html`${this._deviceName}`, + } + )} +

+ ${beginFirmwareUpdateHTML} + ` + : html` +

+ ${this._nodeStatus.status === NodeStatus.Asleep + ? this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.queued", + { + device: html`${this._deviceName}`, + } + ) + : this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.awake", + { + device: html`${this._deviceName}`, + } + )} +

+

+ ${this._nodeStatus.status === NodeStatus.Asleep + ? this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.close_queued", + { + device: html`${this._deviceName}`, + } + ) + : this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.close", + { + device: html`${this._deviceName}`, + } + )} +

+ ${abortFirmwareUpdateButton} + ` + : this._updateProgressMessage && !this._updateFinishedMessage + ? html` +

+ ${this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.in_progress", + { + device: html`${this._deviceName}`, + progress: ( + (this._updateProgressMessage.sent_fragments * 100) / + this._updateProgressMessage.total_fragments + ).toFixed(2), + } + )} +

+ +

+ ${this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.close", + { + device: html`${this._deviceName}`, + } + )} +

+ ${abortFirmwareUpdateButton} + ` + : html` +
+ +
+

+ ${this.hass.localize( + `ui.panel.config.zwave_js.update_firmware.finished_status.${status}`, + { + device: html`${this._deviceName}`, + message: this.hass.localize( + `ui.panel.config.zwave_js.update_firmware.finished_status.${ + FirmwareUpdateStatus[ + this._updateFinishedMessage!.status + ] + }` + ), + } + )} +

+
+
+ ${status === "ok" + ? html`

+ ${this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.finished_status.done" + )} +

` + : html`

+ ${this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.finished_status.try_again" + )} +

+ ${beginFirmwareUpdateHTML}`} +

+ ${this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.finished_status.try_again" + )} +

+ ${beginFirmwareUpdateHTML} + `} +
+ `; + } + + private async _fetchData(): Promise { + [this._nodeStatus, this._updateInProgress] = await Promise.all([ + fetchZwaveNodeStatus(this.hass, this.device!.id), + fetchZwaveNodeIsFirmwareUpdateInProgress(this.hass, this.device!.id), + ]); + if (this._updateInProgress) { + this._subscribeNodeFirmwareUpdate(); + } + } + + private async _beginFirmwareUpdate(): Promise { + this._uploading = true; + this._updateProgressMessage = this._updateFinishedMessage = undefined; + try { + this._subscribeNodeFirmwareUpdate(); + await uploadFirmwareAndBeginUpdate( + this.hass, + this.device!.id, + this._firmwareFile!, + this._firmwareTarget + ); + this._updateInProgress = true; + this._uploading = false; + } catch (err: any) { + this._unsubscribeNodeFirmwareUpdate(); + this._uploading = false; + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.upload_failed" + ), + text: err.message, + confirmText: this.hass!.localize("ui.common.close"), + }); + } + } + + private async _abortFirmwareUpdate(): Promise { + if ( + await showConfirmationDialog(this, { + text: this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.confirm_abort", + { + device: html`${this._deviceName}`, + } + ), + dismissText: this.hass!.localize("ui.common.no"), + confirmText: this.hass!.localize("ui.common.yes"), + }) + ) { + this._unsubscribeNodeFirmwareUpdate(); + try { + await abortZwaveNodeFirmwareUpdate(this.hass, this.device!.id); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.abort_failed" + ), + text: err.message, + confirmText: this.hass!.localize("ui.common.close"), + }); + } + this._firmwareFile = undefined; + this._updateFinishedMessage = undefined; + this._updateProgressMessage = undefined; + this._updateInProgress = false; + } + } + + private _subscribeNodeStatus(): void { + if (!this.hass || !this.device || this._subscribedNodeStatus) { + return; + } + this._subscribedNodeStatus = subscribeZwaveNodeStatus( + this.hass, + this.device.id, + (message: ZWaveJSNodeStatusUpdatedMessage) => { + this._nodeStatus!.status = message.status; + } + ); + } + + private _unsubscribeNodeStatus(): void { + if (!this._subscribedNodeStatus) { + return; + } + this._subscribedNodeStatus.then((unsub) => unsub()); + this._subscribedNodeStatus = undefined; + } + + private _subscribeNodeFirmwareUpdate(): void { + if (!this.hass || !this.device || this._subscribedNodeFirmwareUpdate) { + return; + } + this._subscribedNodeFirmwareUpdate = subscribeZwaveNodeFirmwareUpdate( + this.hass, + this.device.id, + ( + message: + | ZWaveJSNodeFirmwareUpdateFinishedMessage + | ZWaveJSNodeFirmwareUpdateProgressMessage + ) => { + if (message.event === "firmware update progress") { + if (!this._updateFinishedMessage) { + this._updateProgressMessage = message; + } + } else { + this._unsubscribeNodeFirmwareUpdate(); + this._updateProgressMessage = undefined; + this._updateInProgress = false; + this._updateFinishedMessage = message; + } + } + ); + } + + private _unsubscribeNodeFirmwareUpdate(): void { + if (!this._subscribedNodeFirmwareUpdate) { + return; + } + this._subscribedNodeFirmwareUpdate.then((unsub) => unsub()); + this._subscribedNodeFirmwareUpdate = undefined; + } + + private async _firmwareTargetChanged(ev) { + this._firmwareTarget = ev.detail.value.firmware_target; + } + + private async _uploadFile(ev) { + this._firmwareFile = ev.detail.files[0]; + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + .ok { + color: var(--success-color); + } + + .error { + color: var(--error-color); + } + + .flex-container { + display: flex; + align-items: center; + margin-bottom: 5px; + } + + ha-svg-icon { + width: 68px; + height: 48px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-zwave_js-update-firmware-node": DialogZWaveJSUpdateFirmwareNode; + } +} diff --git a/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-update-firmware-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-update-firmware-node.ts new file mode 100644 index 0000000000..dc45f309f6 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-update-firmware-node.ts @@ -0,0 +1,22 @@ +import { fireEvent } from "../../../../../common/dom/fire_event"; +import { DeviceRegistryEntry } from "../../../../../data/device_registry"; +import { ZWaveJSNodeFirmwareUpdateCapabilities } from "../../../../../data/zwave_js"; + +export interface ZWaveJSUpdateFirmwareNodeDialogParams { + device: DeviceRegistryEntry; + firmwareUpdateCapabilities: ZWaveJSNodeFirmwareUpdateCapabilities; +} + +export const loadUpdateFirmwareNodeDialog = () => + import("./dialog-zwave_js-update-firmware-node"); + +export const showZWaveJUpdateFirmwareNodeDialog = ( + element: HTMLElement, + updateFirmwareNodeDialogParams: ZWaveJSUpdateFirmwareNodeDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-zwave_js-update-firmware-node", + dialogImport: loadUpdateFirmwareNodeDialog, + dialogParams: updateFirmwareNodeDialogParams, + }); +}; diff --git a/src/translations/en.json b/src/translations/en.json index 076cc9f6e0..a6e5a65b75 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -3107,6 +3107,7 @@ "reinterview_device": "Re-interview Device", "heal_node": "Heal Device", "remove_failed": "Remove Failed Device", + "update_firmware": "Update Device Firmware", "highest_security": "Highest Security", "unknown": "Unknown", "zwave_plus": "Z-Wave Plus", @@ -3314,6 +3315,43 @@ "in_progress": "{device} healing is in progress.", "network_heal_in_progress": "A Z-Wave network heal is already in progress. Please wait for it to finish before healing an individual device." }, + "update_firmware": { + "title": "Update Device Firmware", + "warning": "WARNING: Firmware updates can brick your device if you do not correctly follow the manufacturer's guidance. The Home Assistant and Z-Wave JS teams do not take any responsibility for any damages to your device as a result of the firmware update and will not be able to help you if you brick your device. Would you still like to continue?", + "introduction": "Select the firmware file you would like to use to update {device}.", + "upload_firmware": "Upload Firmware", + "firmware_target_intro": "Select the firmware target (0 for the Z-Wave chip, ≥1 for other chips if they exist) for this update, or uncheck the box to have the driver attempt to figure it out from the firmware file.", + "firmware_target": "Firmware Target (chip)", + "upload_failed": "Upload Failed", + "begin_update": "Begin Firmware Update", + "queued": "The firmware update is ready to be sent to {device} but the device is asleep, wake the device to start the update.", + "close_queued": "If you close this dialog, the update will continue to be queued in the background and start automatically once the device wakes up.", + "awake": "The firmware update should start being sent to {device} shortly.", + "close": "If you close this dialog, the update will continue in the background.", + "in_progress": "The firmware update on {device} is in progress ({progress}%).", + "abort": "Abort Firmware Update", + "abort_failed": "Abort Failed", + "confirm_abort": "Are you sure you want to abort the firmware update on {device}?", + "finished_status": { + "ok": "Successfully updated firmware on {device}: {message}.", + "error": "Unable to update firmware on {device}: {message}.", + "try_again": "To attempt the firmware update again, select the new firmware file you would like to use.", + "done": "The firmware update is complete! If you want to attempt another firmware update on this device, please wait until it gets re-interviewed.", + "Error_Timeout": "Timed Out", + "Error_Checksum": "Checksum Error", + "Error_TransmissionFailed": "Transmission Failed", + "Error_InvalidManufacturerID": "Invalid Manufacturer ID", + "Error_InvalidFirmwareID": "Invalid Firmware ID", + "Error_InvalidFirmwareTarget": "Invalid Firmware Target", + "Error_InvalidHeaderInformation": "Invalid Header Information", + "Error_InvalidHeaderFormat": "Invalid Header Format", + "Error_InsufficientMemory": "Insufficient Memory", + "Error_InvalidHardwareVersion": "Invalid Hardware Version", + "OK_WaitingForActivation": "Waiting for Activiation", + "OK_NoRestart": "No Restart", + "OK_RestartPending": "Restart Pending" + } + }, "logs": { "title": "Z-Wave JS Logs", "log_level": "Log Level",