Add Z-Wave JS NVM backup and restore API (#139233)

* ZWaveJS: NVM backup and restore API

* remove unused const

* test fix

* switch to WS commands

* Backup & restore MVP

* Use base64 data directly

* update tests

* fix mistake

* Apply suggestions from code review

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* PR comments

* update tests

* more tests

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Petar Petrov 2025-03-14 16:17:23 +02:00 committed by GitHub
parent e9c8b3acfc
commit de0efd61d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 361 additions and 0 deletions

View File

@ -454,6 +454,8 @@ def async_register_api(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_node_capabilities)
websocket_api.async_register_command(hass, websocket_invoke_cc_api)
websocket_api.async_register_command(hass, websocket_get_integration_settings)
websocket_api.async_register_command(hass, websocket_backup_nvm)
websocket_api.async_register_command(hass, websocket_restore_nvm)
hass.http.register_view(FirmwareUploadView(dr.async_get(hass)))
@ -2780,3 +2782,126 @@ def websocket_get_integration_settings(
CONF_INSTALLER_MODE: hass.data[DOMAIN].get(CONF_INSTALLER_MODE, False),
},
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/backup_nvm",
vol.Required(ENTRY_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_backup_nvm(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Backup NVM data."""
controller = driver.controller
@callback
def async_cleanup() -> None:
"""Remove signal listeners."""
for unsub in unsubs:
unsub()
@callback
def forward_progress(event: dict) -> None:
"""Forward progress events to websocket."""
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"event": event["event"],
"bytesRead": event["bytesRead"],
"total": event["total"],
},
)
)
# Set up subscription for progress events
connection.subscriptions[msg["id"]] = async_cleanup
msg[DATA_UNSUBSCRIBE] = unsubs = [
controller.on("nvm backup progress", forward_progress),
]
result = await controller.async_backup_nvm_raw_base64()
# Send the finished event with the backup data
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"event": "finished",
"data": result,
},
)
)
connection.send_result(msg[ID])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/restore_nvm",
vol.Required(ENTRY_ID): str,
vol.Required("data"): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_restore_nvm(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Restore NVM data."""
controller = driver.controller
@callback
def async_cleanup() -> None:
"""Remove signal listeners."""
for unsub in unsubs:
unsub()
@callback
def forward_progress(event: dict) -> None:
"""Forward progress events to websocket."""
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"event": event["event"],
"bytesRead": event.get("bytesRead"),
"bytesWritten": event.get("bytesWritten"),
"total": event["total"],
},
)
)
# Set up subscription for progress events
connection.subscriptions[msg["id"]] = async_cleanup
msg[DATA_UNSUBSCRIBE] = unsubs = [
controller.on("nvm convert progress", forward_progress),
controller.on("nvm restore progress", forward_progress),
]
await controller.async_restore_nvm_base64(msg["data"])
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"event": "finished",
},
)
)
connection.send_result(msg[ID])

View File

@ -5201,6 +5201,242 @@ async def test_get_integration_settings(
}
async def test_backup_nvm(
hass: HomeAssistant,
integration,
client,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test the backup NVM websocket command."""
ws_client = await hass_ws_client(hass)
# Set up mocks for the controller events
controller = client.driver.controller
# Test subscription and events
with patch.object(
controller, "async_backup_nvm_raw_base64", return_value="test"
) as mock_backup:
# Send the subscription request
await ws_client.send_json_auto_id(
{
"type": "zwave_js/backup_nvm",
"entry_id": integration.entry_id,
}
)
# Verify the finished event with data first
msg = await ws_client.receive_json()
assert msg["type"] == "event"
assert msg["event"]["event"] == "finished"
assert msg["event"]["data"] == "test"
# Verify subscription success
msg = await ws_client.receive_json()
assert msg["type"] == "result"
assert msg["success"] is True
# Simulate progress events
event = Event(
"nvm backup progress",
{
"source": "controller",
"event": "nvm backup progress",
"bytesRead": 25,
"total": 100,
},
)
controller.receive_event(event)
msg = await ws_client.receive_json()
assert msg["event"]["event"] == "nvm backup progress"
assert msg["event"]["bytesRead"] == 25
assert msg["event"]["total"] == 100
event = Event(
"nvm backup progress",
{
"source": "controller",
"event": "nvm backup progress",
"bytesRead": 50,
"total": 100,
},
)
controller.receive_event(event)
msg = await ws_client.receive_json()
assert msg["event"]["event"] == "nvm backup progress"
assert msg["event"]["bytesRead"] == 50
assert msg["event"]["total"] == 100
# Wait for the backup to complete
await hass.async_block_till_done()
# Verify the backup was called
assert mock_backup.called
# Test backup failure
with patch.object(
controller,
"async_backup_nvm_raw_base64",
side_effect=FailedCommand("failed_command", "Backup failed"),
):
# Send the subscription request
await ws_client.send_json_auto_id(
{
"type": "zwave_js/backup_nvm",
"entry_id": integration.entry_id,
}
)
# Verify error response
msg = await ws_client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "Backup failed"
# Test config entry not found
await ws_client.send_json_auto_id(
{
"type": "zwave_js/backup_nvm",
"entry_id": "invalid_entry_id",
}
)
msg = await ws_client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "not_found"
# Test config entry not loaded
await hass.config_entries.async_unload(integration.entry_id)
await hass.async_block_till_done()
await ws_client.send_json_auto_id(
{
"type": "zwave_js/backup_nvm",
"entry_id": integration.entry_id,
}
)
msg = await ws_client.receive_json()
assert msg["error"]["code"] == "not_loaded"
async def test_restore_nvm(
hass: HomeAssistant,
integration,
client,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test the restore NVM websocket command."""
ws_client = await hass_ws_client(hass)
# Set up mocks for the controller events
controller = client.driver.controller
# Test restore success
with patch.object(
controller, "async_restore_nvm_base64", return_value=None
) as mock_restore:
# Send the subscription request
await ws_client.send_json_auto_id(
{
"type": "zwave_js/restore_nvm",
"entry_id": integration.entry_id,
"data": "dGVzdA==", # base64 encoded "test"
}
)
# Verify the finished event first
msg = await ws_client.receive_json()
assert msg["type"] == "event"
assert msg["event"]["event"] == "finished"
# Verify subscription success
msg = await ws_client.receive_json()
assert msg["type"] == "result"
assert msg["success"] is True
# Simulate progress events
event = Event(
"nvm restore progress",
{
"source": "controller",
"event": "nvm restore progress",
"bytesWritten": 25,
"total": 100,
},
)
controller.receive_event(event)
msg = await ws_client.receive_json()
assert msg["event"]["event"] == "nvm restore progress"
assert msg["event"]["bytesWritten"] == 25
assert msg["event"]["total"] == 100
event = Event(
"nvm restore progress",
{
"source": "controller",
"event": "nvm restore progress",
"bytesWritten": 50,
"total": 100,
},
)
controller.receive_event(event)
msg = await ws_client.receive_json()
assert msg["event"]["event"] == "nvm restore progress"
assert msg["event"]["bytesWritten"] == 50
assert msg["event"]["total"] == 100
# Wait for the restore to complete
await hass.async_block_till_done()
# Verify the restore was called
assert mock_restore.called
# Test restore failure
with patch.object(
controller,
"async_restore_nvm_base64",
side_effect=FailedCommand("failed_command", "Restore failed"),
):
# Send the subscription request
await ws_client.send_json_auto_id(
{
"type": "zwave_js/restore_nvm",
"entry_id": integration.entry_id,
"data": "dGVzdA==", # base64 encoded "test"
}
)
# Verify error response
msg = await ws_client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "Restore failed"
# Test entry_id not found
await ws_client.send_json_auto_id(
{
"type": "zwave_js/restore_nvm",
"entry_id": "invalid_entry_id",
"data": "dGVzdA==", # base64 encoded "test"
}
)
msg = await ws_client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "not_found"
# Test config entry not loaded
await hass.config_entries.async_unload(integration.entry_id)
await hass.async_block_till_done()
await ws_client.send_json_auto_id(
{
"type": "zwave_js/restore_nvm",
"entry_id": integration.entry_id,
"data": "dGVzdA==", # base64 encoded "test"
}
)
msg = await ws_client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "not_loaded"
async def test_cancel_secure_bootstrap_s2(
hass: HomeAssistant, client, integration, hass_ws_client: WebSocketGenerator
) -> None: