mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-28 11:46:42 +00:00
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:
parent
49b1198cb7
commit
54cc096b1a
@ -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 {
|
||||
|
@ -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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
@ -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": {
|
||||
|
Loading…
x
Reference in New Issue
Block a user