diff --git a/src/data/zwave_js.ts b/src/data/zwave_js.ts index 5a4887e92f..99f0866bb9 100644 --- a/src/data/zwave_js.ts +++ b/src/data/zwave_js.ts @@ -916,6 +916,28 @@ export const abortZwaveNodeFirmwareUpdate = ( device_id, }); +export const subscribeZwaveNVMBackup = ( + hass: HomeAssistant, + entry_id: string, + callbackFunction: (message: any) => void +): Promise => + hass.connection.subscribeMessage(callbackFunction, { + type: "zwave_js/backup_nvm", + entry_id, + }); + +export const restoreZwaveNVM = ( + hass: HomeAssistant, + entry_id: string, + data: string, + callbackFunction: (message: any) => void +): Promise => + hass.connection.subscribeMessage(callbackFunction, { + type: "zwave_js/restore_nvm", + entry_id, + data, + }); + export type ZWaveJSLogUpdate = ZWaveJSLogMessageUpdate | ZWaveJSLogConfigUpdate; interface ZWaveJSLogMessageUpdate { diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts index f2848d8605..27190a1622 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts @@ -17,8 +17,10 @@ import "../../../../../components/ha-expansion-panel"; import "../../../../../components/ha-fab"; import "../../../../../components/ha-spinner"; import "../../../../../components/ha-icon-button"; +import "../../../../../components/ha-button"; import "../../../../../components/ha-icon-next"; import "../../../../../components/ha-svg-icon"; +import "../../../../../components/ha-progress-ring"; import type { ConfigEntry } from "../../../../../data/config_entries"; import { ERROR_STATES, @@ -35,11 +37,14 @@ import { fetchZwaveNetworkStatus, fetchZwaveProvisioningEntries, InclusionState, + restoreZwaveNVM, setZwaveDataCollectionPreference, subscribeS2Inclusion, subscribeZwaveControllerStatistics, + subscribeZwaveNVMBackup, } from "../../../../../data/zwave_js"; import { showOptionsFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-options-flow"; +import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box"; import "../../../../../layouts/hass-tabs-subpage"; import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin"; import { haStyle } from "../../../../../resources/styles"; @@ -48,6 +53,7 @@ import { showZWaveJSAddNodeDialog } from "./show-dialog-zwave_js-add-node"; import { showZWaveJSRebuildNetworkRoutesDialog } from "./show-dialog-zwave_js-rebuild-network-routes"; import { showZWaveJSRemoveNodeDialog } from "./show-dialog-zwave_js-remove-node"; import { configTabs } from "./zwave_js-config-router"; +import { fileDownload } from "../../../../../util/file_download"; @customElement("zwave_js-config-dashboard") class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) { @@ -80,6 +86,14 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) { private _s2InclusionUnsubscribe?: Promise; + private _unsubscribeBackup?: UnsubscribeFunc; + + private _unsubscribeRestore?: UnsubscribeFunc; + + private _backupProgress?: number; + + private _restoreProgress?: number; + protected async firstUpdated() { if (this.hass) { await this._fetchData(); @@ -186,25 +200,25 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) { - + ${this.hass.localize("ui.panel.config.devices.caption")} - + - + ${this.hass.localize("ui.panel.config.entities.caption")} - + ${this._provisioningEntries?.length ? html` + > ${this.hass.localize( "ui.panel.config.zwave_js.dashboard.provisioned_devices" )} - ` : nothing} @@ -385,7 +399,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
- - + ${this.hass.localize( "ui.panel.config.zwave_js.common.rebuild_network_routes" )} - - + + ${this.hass.localize( "ui.panel.config.zwave_js.common.reconfigure_server" )} - +
@@ -440,6 +454,63 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {

+ +
+

+ ${this.hass.localize( + "ui.panel.config.zwave_js.dashboard.nvm_backup.description" + )} +

+
+
+ ${this._backupProgress !== undefined + ? html` + ${this.hass.localize( + "ui.panel.config.zwave_js.dashboard.nvm_backup.downloading" + )} + ${this._backupProgress}%` + : this._restoreProgress !== undefined + ? html` + ${this.hass.localize( + "ui.panel.config.zwave_js.dashboard.nvm_backup.restoring" + )} + ${this._restoreProgress}%` + : html` + ${this.hass.localize( + "ui.panel.config.zwave_js.dashboard.nvm_backup.download_backup" + )} + +
+ + + ${this.hass.localize( + "ui.panel.config.zwave_js.dashboard.nvm_backup.restore_backup" + )} + + + +
`} +
+
` : nothing}

${stateTextExtra}

- + ${this.hass?.localize("ui.common.back")} - + ` : nothing}`; @@ -600,6 +671,78 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) { showOptionsFlowDialog(this, configEntry!); } + private async _downloadBackup() { + try { + this._backupProgress = 0; + this._unsubscribeBackup = await subscribeZwaveNVMBackup( + this.hass!, + this.configEntryId!, + this._handleBackupMessage + ); + } catch (err: any) { + this._backupProgress = undefined; + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.zwave_js.dashboard.nvm_backup.backup_failed" + ), + text: err.message, + warning: true, + }); + } + } + + private _restoreButtonClick() { + const fileInput = this.shadowRoot?.querySelector( + "#nvm-restore-file" + ) as HTMLInputElement; + fileInput?.click(); + } + + private async _handleRestoreFileSelected(ev: Event) { + const file = (ev.target as HTMLInputElement).files?.[0]; + if (!file) return; + const input = ev.target as HTMLInputElement; + + try { + this._restoreProgress = 0; + // Read the file as base64 + const base64Data = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as ArrayBuffer; + const base64 = btoa( + new Uint8Array(result).reduce( + (data, byte) => data + String.fromCharCode(byte), + "" + ) + ); + resolve(base64); + }; + reader.onerror = () => reject(new Error("Failed to read file")); + reader.readAsArrayBuffer(file); + }); + + this._unsubscribeRestore = await restoreZwaveNVM( + this.hass!, + this.configEntryId!, + base64Data, + this._handleRestoreMessage + ); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.zwave_js.dashboard.nvm_backup.restore_failed" + ), + text: err.message, + warning: true, + }); + this._restoreProgress = undefined; + } + + // Reset the file input so the same file can be selected again + input.value = ""; + } + private _openInclusionDialog(dsk?: string) { if (!this._dialogOpen) { // Unsubscribe from S2 inclusion before opening dialog @@ -635,6 +778,61 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) { return this._s2InclusionUnsubscribe; } + private _handleBackupMessage = (message: any) => { + if (message.event === "finished") { + this._backupProgress = undefined; + this._unsubscribeBackup?.(); + this._unsubscribeBackup = undefined; + try { + const blob = new Blob( + [Uint8Array.from(atob(message.data), (c) => c.charCodeAt(0))], + { type: "application/octet-stream" } + ); + const url = URL.createObjectURL(blob); + fileDownload( + url, + `zwave_js_backup_${new Date().toISOString().replace(/[:.]/g, "-")}.bin` + ); + URL.revokeObjectURL(url); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.zwave_js.dashboard.nvm_backup.backup_failed" + ), + text: err.message, + warning: true, + }); + } + } else if (message.event === "nvm backup progress") { + this._backupProgress = Math.round( + (message.bytesRead / message.total) * 100 + ); + } + }; + + private _handleRestoreMessage = (message: any) => { + if (message.event === "finished") { + this._restoreProgress = undefined; + this._unsubscribeRestore?.(); + this._unsubscribeRestore = undefined; + + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.zwave_js.dashboard.nvm_backup.restore_complete" + ), + }); + this._fetchData(); + } else if (message.event === "nvm convert progress") { + // assume convert takes half the time of restore + this._restoreProgress = Math.round( + (message.bytesRead / message.total) * 50 + ); + } else if (message.event === "nvm restore progress") { + this._restoreProgress = + Math.round((message.bytesWritten / message.total) * 50) + 50; + } + }; + static get styles(): CSSResultGroup { return [ haStyle, @@ -738,9 +936,32 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) { max-width: 600px; } + .card-actions { + display: flex; + align-items: center; + } + + .card-actions ha-progress-ring { + margin-right: 16px; + } + [hidden] { display: none; } + + .upload-button { + display: inline-block; + position: relative; + } + + .upload-button ha-button { + position: relative; + overflow: hidden; + } + + .button-content { + pointer-events: none; + } `, ]; } diff --git a/src/translations/en.json b/src/translations/en.json index 09372d2b2d..454d4d6f30 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -5566,6 +5566,17 @@ "devices": "{count} {count, plural,\n one {device}\n other {devices}\n}", "provisioned_devices": "Provisioned devices", "not_ready": "{count} not ready", + "nvm_backup": { + "title": "Backup and Restore", + "description": "Back up or restore your Z-Wave controller's Non-Volatile Memory (NVM). The NVM contains your network information including paired devices. It's recommended to create a backup before making any major changes to your Z-Wave network.", + "download_backup": "Download backup", + "restore_backup": "Restore from backup", + "backup_failed": "Failed to download backup", + "restore_complete": "Backup restored", + "restore_failed": "Failed to restore backup", + "downloading": "Downloading backup", + "restoring": "Restoring backup" + }, "statistics": { "title": "Controller statistics", "messages_tx": {