From 40a0159d917a73214983810fce5bc69767697616 Mon Sep 17 00:00:00 2001 From: huangrenwei79 Date: Mon, 21 Sep 2026 17:28:52 +0800 Subject: [PATCH 1/4] Add Tuya 20-channel meter fixture (#182848) --- .../tuya/fixtures/zndb_qxlwffgv8avf5rrw.json | 160 ++++++++++++++++++ .../components/tuya/snapshots/test_init.ambr | 30 ++++ 2 files changed, 190 insertions(+) create mode 100644 tests/components/tuya/fixtures/zndb_qxlwffgv8avf5rrw.json diff --git a/tests/components/tuya/fixtures/zndb_qxlwffgv8avf5rrw.json b/tests/components/tuya/fixtures/zndb_qxlwffgv8avf5rrw.json new file mode 100644 index 00000000000000..ccf0970e1200d5 --- /dev/null +++ b/tests/components/tuya/fixtures/zndb_qxlwffgv8avf5rrw.json @@ -0,0 +1,160 @@ +{ + "endpoint": "https://apigw.tuyacn.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "测试无回路-多路计量cat.1", + "category": "zndb", + "product_id": "qxlwffgv8avf5rrw", + "product_name": "Test No Loop-Multi-Channel Metering Cat.1", + "online": true, + "sub": false, + "time_zone": "+8:00", + "function": {}, + "status_range": { + "phase_s1": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "phase_s2": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "phase_s3": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "phase_s4": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "phase_s5": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "phase_s6": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "phase_s7": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "phase_s8": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "phase_s9": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "phase_s10": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "phase_s11": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "phase_s12": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "phase_s13": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "phase_s14": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "phase_s15": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "phase_s16": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "phase_s17": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "phase_s18": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "phase_s19": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "phase_s20": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status": { + "phase_s1": "020F08FD00044C0000D20000320000DC5F00", + "phase_s2": "020F08FE0008980001A40000640001B86000", + "phase_s3": "020F08FF000CE40002760000960002946100", + "phase_s4": "020F09000011300003480000C80003706200", + "phase_s5": "020F090100157C00041A0000FA00044C6300", + "phase_s6": "020F09020019C80004EC00012C0005285E00", + "phase_s7": "020F0903001E140005BE00015E0006045F00", + "phase_s8": "020F09040022600006900001900006E06000", + "phase_s9": "020F09050026AC0007620001C20007BC6100", + "phase_s10": "020F0906002AF80008340001F40008986200", + "phase_s11": "020F0907002F440009060002260009746300", + "phase_s12": "020F09080033900009D8000258000A505E00", + "phase_s13": "020F09090037DC000AAA00028A000B2C5F00", + "phase_s14": "020F090A003C28000B7C0002BC000C086000", + "phase_s15": "020F090B004074000C4E0002EE000CE46100", + "phase_s16": "020F090C0044C0000D20000320000DC06200", + "phase_s17": "020F090D00490C000DF2000352000E9C6300", + "phase_s18": "020F090E004D58000EC4000384000F785E00", + "phase_s19": "020F090D00490C000DF2000352000E9C6300", + "phase_s20": "020F090E004D58000EC4000384000F785E00" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 364e22de103790..b987149b107509 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -8819,6 +8819,36 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[wrr5fva8vgffwlxqbdnz] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'wrr5fva8vgffwlxqbdnz', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Test No Loop-Multi-Channel Metering Cat.1', + 'model_id': 'qxlwffgv8avf5rrw', + 'name': '测试无回路-多路计量cat.1', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[x4nogasbi8ggpb3lcd] DeviceRegistryEntrySnapshot({ 'area_id': None, From facae81125435dc6e1af9acd05a5d46c5220f391 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 21 Sep 2026 06:41:32 -0400 Subject: [PATCH 2/4] Fix typo in the Z-Wave controller statistics key (#182827) Co-authored-by: Claude --- homeassistant/components/zwave_js/api.py | 2 +- tests/components/zwave_js/test_api.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 877dbd225314f2..06c17142c77137 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -2844,7 +2844,7 @@ def _get_controller_statistics_dict( "nak": statistics.nak, "can": statistics.can, "timeout_ack": statistics.timeout_ack, - "timout_response": statistics.timeout_response, + "timeout_response": statistics.timeout_response, "timeout_callback": statistics.timeout_callback, } diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 742c2331604973..3b14b6de1d00a3 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -5525,7 +5525,7 @@ async def test_subscribe_controller_statistics( "nak": 0, "can": 0, "timeout_ack": 0, - "timout_response": 0, + "timeout_response": 0, "timeout_callback": 0, } @@ -5560,7 +5560,7 @@ async def test_subscribe_controller_statistics( "nak": 1, "can": 1, "timeout_ack": 1, - "timout_response": 1, + "timeout_response": 1, "timeout_callback": 1, } From 37b44a6636aeac5340d98260ade73b7baf54e839 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 21 Sep 2026 13:30:42 +0200 Subject: [PATCH 3/4] Fix HomematicIP Cloud config flow advancing on a rejected PIN (#182601) --- .../components/homematicip_cloud/hap.py | 3 ++ .../homematicip_cloud/test_config_flow.py | 36 ++++++++++++++++++- .../components/homematicip_cloud/test_hap.py | 25 +++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index ad0699f5d89a04..de71dfe033344d 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -105,6 +105,9 @@ async def get_auth(self, hass: HomeAssistant, hapid, pin): _LOGGER.debug("Connection request result: %s", result) except HmipConnectionError: return None + # a rejected SGTIN or PIN comes back as a failed result, not an exception + if not result.success: + return None return auth diff --git a/tests/components/homematicip_cloud/test_config_flow.py b/tests/components/homematicip_cloud/test_config_flow.py index fedf7902a39276..4fd0e67ba1c0f8 100644 --- a/tests/components/homematicip_cloud/test_config_flow.py +++ b/tests/components/homematicip_cloud/test_config_flow.py @@ -1,6 +1,9 @@ """Tests for HomematicIP Cloud config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +from homematicip.connection.connection_context import ConnectionContext +from homematicip.connection.rest_connection import RestResult from homeassistant import config_entries from homeassistant.components.homematicip_cloud.const import ( @@ -104,6 +107,37 @@ async def test_flow_init_connection_error(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "invalid_sgtin_or_pin"} +async def test_flow_init_rejected_pin( + hass: HomeAssistant, simple_mock_auth: AsyncMock +) -> None: + """Test the flow stays on the first step when the cloud rejects the PIN.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # the library hands a 400 back as a failed result instead of raising + simple_mock_auth.connection_request.return_value = RestResult( + status=400, text='{"errorCode":"INVALID_PIN"}' + ) + with ( + patch( + "homeassistant.components.homematicip_cloud.hap.Auth", + return_value=simple_mock_auth, + ), + patch( + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=DEFAULT_CONFIG + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] == {"base": "invalid_sgtin_or_pin"} + + async def test_flow_link_connection_error(hass: HomeAssistant) -> None: """Test config flow client registration connection error.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index 1fa4b859097ea0..48ed9e9ffb79b9 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -5,6 +5,7 @@ from homematicip.auth import Auth from homematicip.connection.connection_context import ConnectionContext +from homematicip.connection.rest_connection import RestResult from homematicip.exceptions.connection_exceptions import ( HmipAuthenticationError, HmipConnectionError, @@ -197,6 +198,7 @@ async def test_auth_create(hass: HomeAssistant, simple_mock_auth) -> None: """Mock AsyncAuth to execute get_auth.""" config = {HMIPC_HAPID: HAPID, HMIPC_PIN: HAPPIN, HMIPC_NAME: "hmip"} hmip_auth = HomematicipAuth(hass, config) + simple_mock_auth.connection_request.return_value = RestResult(status=200) assert hmip_auth with ( @@ -245,6 +247,29 @@ async def test_auth_create_exception(hass: HomeAssistant, simple_mock_auth) -> N assert not await hmip_auth.get_auth(hass, HAPID, HAPPIN) +async def test_auth_create_rejected( + hass: HomeAssistant, simple_mock_auth: AsyncMock +) -> None: + """Test a connection request the cloud rejects does not yield an auth.""" + config = {HMIPC_HAPID: HAPID, HMIPC_PIN: HAPPIN, HMIPC_NAME: "hmip"} + hmip_auth = HomematicipAuth(hass, config) + # a wrong or missing PIN is answered with a 400, which the library returns + simple_mock_auth.connection_request.return_value = RestResult( + status=400, text='{"errorCode":"INVALID_PIN"}' + ) + with ( + patch( + "homeassistant.components.homematicip_cloud.hap.Auth", + return_value=simple_mock_auth, + ), + patch( + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), + ), + ): + assert not await hmip_auth.async_setup() + + async def test_get_state_after_disconnect( hass: HomeAssistant, hmip_config_entry: MockConfigEntry, simple_mock_home ) -> None: From 58aaf6fa97ae57bb189b9d5e929864b74a1e3852 Mon Sep 17 00:00:00 2001 From: Andre Faria Date: Mon, 21 Sep 2026 12:37:42 +0100 Subject: [PATCH 4/4] Fix Todoist timed calendar event duration (#182121) --- homeassistant/components/todoist/calendar.py | 9 ++- tests/components/todoist/test_calendar.py | 74 +++++++++++++++++--- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 5fd9f38e5e9215..b871327aee7981 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -518,10 +518,15 @@ async def async_get_events( start = parse_due_date(task.due) if start is None: continue + duration = ( + timedelta(minutes=30) + if isinstance(start, datetime) + else timedelta(days=1) + ) event = CalendarEvent( summary=task.content, start=start, - end=start + timedelta(days=1), + end=start + duration, ) if ( event.start_datetime_local is not None @@ -530,7 +535,7 @@ async def async_get_events( continue if ( event.end_datetime_local is not None - and event.end_datetime_local < start_date + and event.end_datetime_local <= start_date ): continue events.append(event) diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 1a1d3c20776d52..26563e74fc5617 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -221,6 +221,39 @@ async def test_calendar_custom_project_unique_id( assert entity is None +@pytest.mark.parametrize( + ("due", "expected_start", "expected_end"), + [ + ( + make_api_due(date="2023-03-30", string="Mar 30"), + {"date": "2023-03-30"}, + {"date": "2023-03-31"}, + ), + ( + make_api_due(date="2023-03-30T18:00:00", string="Mar 30 6:00 PM"), + {"dateTime": "2023-03-30T18:00:00-06:00"}, + {"dateTime": "2023-03-30T18:30:00-06:00"}, + ), + ], + ids=("all_day", "timed"), +) +async def test_event_duration( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + expected_start: dict[str, str], + expected_end: dict[str, str], +) -> None: + """Test date-only tasks last one day and timed tasks last 30 minutes.""" + client = await hass_client() + response = await client.get( + get_events_url( + "calendar.name", "2023-03-30T00:00:00.000Z", "2023-04-01T00:00:00.000Z" + ) + ) + assert response.status == HTTPStatus.OK + assert await response.json() == [get_events_response(expected_start, expected_end)] + + @pytest.mark.parametrize( ("due", "start", "end", "expected_response"), [ @@ -254,6 +287,12 @@ async def test_calendar_custom_project_unique_id( "2023-04-01T08:00:00.000Z", [], ), + ( + make_api_due(date="2023-03-30", is_recurring=False, string="Mar 30"), + "2023-03-31T06:00:00.000Z", + "2023-04-01T06:00:00.000Z", + [], + ), ( make_api_due(date="2023-03-30", is_recurring=False, string="Mar 30"), "2023-03-29T06:00:00.000Z", @@ -261,7 +300,15 @@ async def test_calendar_custom_project_unique_id( [], ), ], - ids=("included", "exact", "overlap_start", "overlap_end", "after", "before"), + ids=( + "included", + "exact", + "overlap_start", + "overlap_end", + "after", + "end_boundary", + "before", + ), ) async def test_all_day_event( hass: HomeAssistant, @@ -374,14 +421,14 @@ async def test_task_due_datetime( has_task_response = [ get_events_response( {"dateTime": "2023-03-30T18:00:00-06:00"}, - {"dateTime": "2023-03-31T18:00:00-06:00"}, + {"dateTime": "2023-03-30T18:30:00-06:00"}, ) ] # Completely includes the start/end of the task response = await client.get( get_events_url( - "calendar.name", "2023-03-30T08:00:00.000Z", "2023-03-31T08:00:00.000Z" + "calendar.name", "2023-03-30T23:00:00.000Z", "2023-03-31T01:00:00.000Z" ), ) assert response.status == HTTPStatus.OK @@ -390,7 +437,7 @@ async def test_task_due_datetime( # Overlap with the start of the event response = await client.get( get_events_url( - "calendar.name", "2023-03-29T20:00:00.000Z", "2023-03-31T02:00:00.000Z" + "calendar.name", "2023-03-30T23:30:00.000Z", "2023-03-31T00:15:00.000Z" ), ) assert response.status == HTTPStatus.OK @@ -399,7 +446,7 @@ async def test_task_due_datetime( # Overlap with the end of the event response = await client.get( get_events_url( - "calendar.name", "2023-03-31T20:00:00.000Z", "2023-04-01T02:00:00.000Z" + "calendar.name", "2023-03-31T00:15:00.000Z", "2023-03-31T01:00:00.000Z" ), ) assert response.status == HTTPStatus.OK @@ -408,7 +455,7 @@ async def test_task_due_datetime( # Task is active, but range does not include start/end response = await client.get( get_events_url( - "calendar.name", "2023-03-31T10:00:00.000Z", "2023-03-31T11:00:00.000Z" + "calendar.name", "2023-03-31T00:10:00.000Z", "2023-03-31T00:20:00.000Z" ), ) assert response.status == HTTPStatus.OK @@ -417,7 +464,16 @@ async def test_task_due_datetime( # Query is before the task starts (no results) response = await client.get( get_events_url( - "calendar.name", "2023-03-28T00:00:00.000Z", "2023-03-29T00:00:00.000Z" + "calendar.name", "2023-03-30T22:00:00.000Z", "2023-03-30T23:00:00.000Z" + ), + ) + assert response.status == HTTPStatus.OK + assert await response.json() == [] + + # Query starts exactly when the task ends (no results) + response = await client.get( + get_events_url( + "calendar.name", "2023-03-31T00:30:00.000Z", "2023-03-31T01:00:00.000Z" ), ) assert response.status == HTTPStatus.OK @@ -426,7 +482,7 @@ async def test_task_due_datetime( # Query is after the task ends (no results) response = await client.get( get_events_url( - "calendar.name", "2023-04-01T07:00:00.000Z", "2023-04-02T07:00:00.000Z" + "calendar.name", "2023-03-31T01:00:00.000Z", "2023-03-31T02:00:00.000Z" ), ) assert response.status == HTTPStatus.OK @@ -535,6 +591,6 @@ async def test_config_entry( assert await response.json() == [ get_events_response( {"dateTime": "2023-03-30T18:00:00-06:00"}, - {"dateTime": "2023-03-31T18:00:00-06:00"}, + {"dateTime": "2023-03-30T18:30:00-06:00"}, ) ]