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
This commit is contained in:
Petar Petrov 2025-03-14 16:17:29 +02:00 committed by GitHub
parent 49b1198cb7
commit 54cc096b1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 268 additions and 14 deletions

View File

@ -916,6 +916,28 @@ export const abortZwaveNodeFirmwareUpdate = (
device_id,
});
export const subscribeZwaveNVMBackup = (
hass: HomeAssistant,
entry_id: string,
callbackFunction: (message: any) => void
): Promise<UnsubscribeFunc> =>
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<UnsubscribeFunc> =>
hass.connection.subscribeMessage(callbackFunction, {
type: "zwave_js/restore_nvm",
entry_id,
data,
});
export type ZWaveJSLogUpdate = ZWaveJSLogMessageUpdate | ZWaveJSLogConfigUpdate;
interface ZWaveJSLogMessageUpdate {

View File

@ -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<UnsubscribeFunc>;
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) {
<a
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this.configEntryId}`}
>
<mwc-button>
<ha-button>
${this.hass.localize("ui.panel.config.devices.caption")}
</mwc-button>
</ha-button>
</a>
<a
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this.configEntryId}`}
>
<mwc-button>
<ha-button>
${this.hass.localize("ui.panel.config.entities.caption")}
</mwc-button>
</ha-button>
</a>
${this._provisioningEntries?.length
? html`<a
href=${`provisioned?config_entry=${this.configEntryId}`}
><mwc-button>
><ha-button>
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.provisioned_devices"
)}
</mwc-button></a
</ha-button></a
>`
: nothing}
</div>
@ -385,7 +399,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
</ha-expansion-panel>
</div>
<div class="card-actions">
<mwc-button
<ha-button
@click=${this._removeNodeClicked}
.disabled=${this._status !== "connected" ||
(this._network?.controller.inclusion_state !==
@ -396,20 +410,20 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
${this.hass.localize(
"ui.panel.config.zwave_js.common.remove_node"
)}
</mwc-button>
<mwc-button
</ha-button>
<ha-button
@click=${this._rebuildNetworkRoutesClicked}
.disabled=${this._status === "disconnected"}
>
${this.hass.localize(
"ui.panel.config.zwave_js.common.rebuild_network_routes"
)}
</mwc-button>
<mwc-button @click=${this._openOptionFlow}>
</ha-button>
<ha-button @click=${this._openOptionFlow}>
${this.hass.localize(
"ui.panel.config.zwave_js.common.reconfigure_server"
)}
</mwc-button>
</ha-button>
</div>
</ha-card>
<ha-card>
@ -440,6 +454,63 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
</p>
</div>
</ha-card>
<ha-card
.header=${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.nvm_backup.title"
)}
>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.nvm_backup.description"
)}
</p>
</div>
<div class="card-actions">
${this._backupProgress !== undefined
? html`<ha-progress-ring
size="small"
.value=${this._backupProgress}
></ha-progress-ring>
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.nvm_backup.downloading"
)}
${this._backupProgress}%`
: this._restoreProgress !== undefined
? html`<ha-progress-ring
size="small"
.value=${this._restoreProgress}
></ha-progress-ring>
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.nvm_backup.restoring"
)}
${this._restoreProgress}%`
: html`<ha-button @click=${this._downloadBackup}>
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.nvm_backup.download_backup"
)}
</ha-button>
<div class="upload-button">
<ha-button
@click=${this._restoreButtonClick}
class="warning"
>
<span class="button-content">
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.nvm_backup.restore_backup"
)}
</span>
</ha-button>
<input
type="file"
id="nvm-restore-file"
accept=".bin"
@change=${this._handleRestoreFileSelected}
style="display: none"
/>
</div>`}
</div>
</ha-card>
`
: nothing}
<ha-fab
@ -512,9 +583,9 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
${this._configEntry!.title}: ${this.hass.localize(...stateText)}
</h3>
<p>${stateTextExtra}</p>
<mwc-button @click=${this._handleBack}>
<ha-button @click=${this._handleBack}>
${this.hass?.localize("ui.common.back")}
</mwc-button>
</ha-button>
</div>
`
: 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<string>((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;
}
`,
];
}

View File

@ -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": {