diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 363b13853277b..b23d467acb8d1 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -152,6 +152,62 @@ async def set_channel( """Set current channel.""" await self.api.set_channel(channel, delay=int(delay * 1000)) + async def delete_pending_dataset(self) -> None: + """Delete the pending operational dataset. + + Tries DELETE first (spec-compliant). Falls back to creating a pending + dataset with the current active dataset and delay=0, which immediately + applies the current state and clears the pending migration. + """ + # Try the spec-compliant DELETE first; if it succeeds, return early. + # If it fails with 405 on older firmware, fall through to the + # fallback which overwrites the pending dataset instead. + # Re-raise any other error (connectivity, server errors, etc.). + try: + await self.api.delete_pending_dataset() + except python_otbr_api.OTBRError as exc: + if "405" not in str(exc): + raise HomeAssistantError("Failed to call OTBR API") from exc + _LOGGER.debug( + "DELETE pending dataset not supported, using fallback: %s", exc + ) + except (aiohttp.ClientError, TimeoutError) as exc: + raise HomeAssistantError("Failed to call OTBR API") from exc + else: + return + + # Fallback: set a pending dataset matching the current active dataset + # with delay=0, which immediately applies and clears the pending state. + try: + dataset = await self.api.get_active_dataset() + except ( + python_otbr_api.OTBRError, + aiohttp.ClientError, + TimeoutError, + ) as exc: + raise HomeAssistantError("Failed to call OTBR API") from exc + + if dataset is None: + raise HomeAssistantError( + "Failed to cancel pending dataset: no active dataset" + ) + + if dataset.active_timestamp and dataset.active_timestamp.seconds is not None: + dataset.active_timestamp.seconds += 1 + else: + dataset.active_timestamp = python_otbr_api.Timestamp(False, 1, 0) + + try: + await self.api.create_pending_dataset( + python_otbr_api.PendingDataSet(active_dataset=dataset, delay=0) + ) + except ( + python_otbr_api.OTBRError, + aiohttp.ClientError, + TimeoutError, + ) as exc: + raise HomeAssistantError("Failed to call OTBR API") from exc + @_handle_otbr_error async def get_extended_address(self) -> bytes: """Get extended address (EUI-64).""" diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 2bcd0da8f16c5..fd2d1aee8f9f2 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -35,6 +35,8 @@ def async_setup(hass: HomeAssistant) -> None: """Set up the OTBR Websocket API.""" websocket_api.async_register_command(hass, websocket_info) websocket_api.async_register_command(hass, websocket_create_network) + websocket_api.async_register_command(hass, websocket_get_pending_dataset) + websocket_api.async_register_command(hass, websocket_delete_pending_dataset) websocket_api.async_register_command(hass, websocket_set_channel) websocket_api.async_register_command(hass, websocket_set_network) @@ -295,3 +297,74 @@ async def websocket_set_channel( return connection.send_result(msg["id"], {"delay": delay}) + + +@websocket_api.websocket_command( + { + "type": "otbr/get_pending_dataset", + vol.Required("extended_address"): str, + } +) +@websocket_api.async_response +@async_get_otbr_data +async def websocket_get_pending_dataset( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, + data: OTBRData, +) -> None: + """Get pending dataset info.""" + try: + pending_tlvs = await data.get_pending_dataset_tlvs() + except HomeAssistantError as exc: + connection.send_error(msg["id"], "get_pending_dataset_failed", str(exc)) + return + + if pending_tlvs is None: + connection.send_result(msg["id"], None) + return + + dataset = tlv_parser.parse_tlv(pending_tlvs.hex()) + + channel_tlv = dataset.get(MeshcopTLVType.CHANNEL) + delay_tlv = dataset.get(MeshcopTLVType.DELAYTIMER) + + if channel_tlv is None or delay_tlv is None: + connection.send_result(msg["id"], None) + return + + pending_channel = cast(tlv_parser.Channel, channel_tlv).channel + delay_ms = int.from_bytes(delay_tlv.data, "big") + + connection.send_result( + msg["id"], + { + "pending_channel": pending_channel, + "pending_dataset_delay": delay_ms / 1000, + }, + ) + + +@websocket_api.websocket_command( + { + "type": "otbr/delete_pending_dataset", + vol.Required("extended_address"): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +@async_get_otbr_data +async def websocket_delete_pending_dataset( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, + data: OTBRData, +) -> None: + """Delete pending dataset.""" + try: + await data.delete_pending_dataset() + except HomeAssistantError as exc: + connection.send_error(msg["id"], "delete_pending_dataset_failed", str(exc)) + return + + connection.send_result(msg["id"]) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 7311b194df465..4d8dab6e779f2 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -20,6 +20,28 @@ from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import MockHAClientWebSocket, WebSocketGenerator +# Pending dataset with CHANNEL=15 and DELAYTIMER=26265ms (0x00006699) +PENDING_DATASET_CH15 = bytes.fromhex( + "0E080000000000020000" # ACTIVETIMESTAMP + "340400006699" # DELAYTIMER + "000300000F" # CHANNEL + "35060004001FFFE0" # CHANNELMASK + "0208F642646DA209B1C0" # EXTPANID + "0708FDF57B5A0FE2AAF6" # MESHLOCALPREFIX + "0510DE98B5BA1A528FEE049D4B4B01835375" # NETWORKKEY + "030D4F70656E546872656164204841" # NETWORKNAME + "010225A4" # PANID + "0410F5DD18371BFD29E1A601EF6FFAD94C03" # PSKC + "0C0402A0F7F8" # SECURITYPOLICY +) + +# Pending dataset with no CHANNEL or DELAYTIMER fields +PENDING_DATASET_NO_CHANNEL = bytes.fromhex( + "0E080000000000020000" # ACTIVETIMESTAMP + "35060004001FFFE0" # CHANNELMASK + "0208F642646DA209B1C0" # EXTPANID +) + @pytest.fixture async def websocket_client( @@ -699,6 +721,292 @@ async def test_set_network_fails_5( assert msg["error"]["code"] == "unknown_router" +async def test_get_pending_dataset( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry_thread, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test get pending dataset returns channel and delay.""" + with ( + patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ), + patch( + "python_otbr_api.OTBR.get_pending_dataset_tlvs", + return_value=PENDING_DATASET_CH15, + ), + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/get_pending_dataset", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + msg = await websocket_client.receive_json() + + assert msg["success"] + assert msg["result"] == { + "pending_channel": 15, + "pending_dataset_delay": 26.265, + } + + +async def test_get_pending_dataset_none( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry_thread, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test get pending dataset returns None when no pending dataset.""" + with ( + patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ), + patch( + "python_otbr_api.OTBR.get_pending_dataset_tlvs", + return_value=None, + ), + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/get_pending_dataset", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + msg = await websocket_client.receive_json() + + assert msg["success"] + assert msg["result"] is None + + +async def test_get_pending_dataset_missing_fields( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry_thread, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test get pending dataset returns None when TLV lacks CHANNEL or DELAYTIMER.""" + with ( + patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ), + patch( + "python_otbr_api.OTBR.get_pending_dataset_tlvs", + return_value=PENDING_DATASET_NO_CHANNEL, + ), + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/get_pending_dataset", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + msg = await websocket_client.receive_json() + + assert msg["success"] + assert msg["result"] is None + + +async def test_get_pending_dataset_api_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry_thread, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test get pending dataset error handling.""" + with ( + patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ), + patch( + "python_otbr_api.OTBR.get_pending_dataset_tlvs", + side_effect=python_otbr_api.OTBRError, + ), + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/get_pending_dataset", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "get_pending_dataset_failed" + + +async def test_delete_pending_dataset( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry_thread, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test delete pending dataset success.""" + with ( + patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ), + patch( + "python_otbr_api.OTBR.delete_pending_dataset", + ) as mock_delete, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/delete_pending_dataset", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + msg = await websocket_client.receive_json() + + assert msg["success"] + assert msg["result"] is None + mock_delete.assert_called_once() + + +async def test_delete_pending_dataset_fallback( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry_thread, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test delete pending dataset falls back when DELETE is not supported.""" + with ( + patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ), + patch( + "python_otbr_api.OTBR.delete_pending_dataset", + side_effect=python_otbr_api.OTBRError("unexpected http status 405"), + ), + patch( + "python_otbr_api.OTBR.get_active_dataset", + return_value=python_otbr_api.ActiveDataSet( + channel=15, + active_timestamp=python_otbr_api.Timestamp(False, 1, 0), + ), + ), + patch( + "python_otbr_api.OTBR.create_pending_dataset", + ) as mock_create_pending, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/delete_pending_dataset", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + msg = await websocket_client.receive_json() + + assert msg["success"] + assert msg["result"] is None + mock_create_pending.assert_called_once() + pending_dataset = mock_create_pending.call_args[0][0] + assert pending_dataset.delay == 0 + assert pending_dataset.active_dataset.active_timestamp.seconds == 2 + + +async def test_delete_pending_dataset_non_405_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry_thread, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test delete pending dataset raises on non-405 errors instead of falling back.""" + with ( + patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ), + patch( + "python_otbr_api.OTBR.delete_pending_dataset", + side_effect=python_otbr_api.OTBRError("unexpected http status 500"), + ), + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/delete_pending_dataset", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "delete_pending_dataset_failed" + + +async def test_delete_pending_dataset_fallback_fails( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry_thread, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test delete pending dataset when both DELETE and fallback fail.""" + with ( + patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ), + patch( + "python_otbr_api.OTBR.delete_pending_dataset", + side_effect=python_otbr_api.OTBRError("unexpected http status 405"), + ), + patch( + "python_otbr_api.OTBR.get_active_dataset", + side_effect=python_otbr_api.OTBRError, + ), + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/delete_pending_dataset", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "delete_pending_dataset_failed" + + +async def test_delete_pending_dataset_fallback_no_dataset( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry_thread, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test delete pending dataset fallback when no active dataset exists.""" + with ( + patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, + ), + patch( + "python_otbr_api.OTBR.delete_pending_dataset", + side_effect=python_otbr_api.OTBRError("unexpected http status 405"), + ), + patch( + "python_otbr_api.OTBR.get_active_dataset", + return_value=None, + ), + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/delete_pending_dataset", + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), + } + ) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "delete_pending_dataset_failed" + + async def test_set_channel( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker,