From 54cc096b1acdd481ee5407a46439b071bdf509c1 Mon Sep 17 00:00:00 2001
From: Petar Petrov
Date: Fri, 14 Mar 2025 16:17:29 +0200
Subject: [PATCH] Backup/Restore NVM in Z-WaveJS dashboard (#24277)
* Backup/Restore NVM in Z-WaveJS dashboard
* update API
* Handle file with HTTP
* MVP with 2 buttons
* format
* improve naming
* text tweak
* migrate to ha-progress-ring
* handle download errors
* fix restore progress
---
src/data/zwave_js.ts | 22 ++
.../zwave_js/zwave_js-config-dashboard.ts | 249 +++++++++++++++++-
src/translations/en.json | 11 +
3 files changed, 268 insertions(+), 14 deletions(-)
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": {