From 7fba94747e4fdb40e3ab9fcf0c955bef39361d1c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 17 Aug 2025 14:05:58 +0200 Subject: [PATCH 01/13] Add Tuya test fixtures (#150793) --- tests/components/tuya/__init__.py | 7 + .../tuya/fixtures/cz_CHLZe9HQ6QIXujVN.json | 54 ++ .../tuya/fixtures/cz_iqhidxhhmgxk5eja.json | 54 ++ .../tuya/fixtures/dd_gaobbrxqiblcng2p.json | 21 + .../tuya/fixtures/dj_qoqolwtqzfuhgghq.json | 477 ++++++++++++++++++ .../tuya/fixtures/hwsb_ircs2n82vgrozoew.json | 34 ++ .../tuya/fixtures/mc_oSQljE9YDqwCwTUA.json | 35 ++ .../tuya/fixtures/qn_5ls2jw49hpczwqng.json | 21 + .../tuya/snapshots/test_climate.ambr | 61 +++ .../components/tuya/snapshots/test_init.ambr | 217 ++++++++ .../components/tuya/snapshots/test_light.ambr | 73 +++ .../tuya/snapshots/test_sensor.ambr | 48 ++ .../tuya/snapshots/test_switch.ambr | 98 ++++ 13 files changed, 1200 insertions(+) create mode 100644 tests/components/tuya/fixtures/cz_CHLZe9HQ6QIXujVN.json create mode 100644 tests/components/tuya/fixtures/cz_iqhidxhhmgxk5eja.json create mode 100644 tests/components/tuya/fixtures/dd_gaobbrxqiblcng2p.json create mode 100644 tests/components/tuya/fixtures/dj_qoqolwtqzfuhgghq.json create mode 100644 tests/components/tuya/fixtures/hwsb_ircs2n82vgrozoew.json create mode 100644 tests/components/tuya/fixtures/mc_oSQljE9YDqwCwTUA.json create mode 100644 tests/components/tuya/fixtures/qn_5ls2jw49hpczwqng.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 537fc98854ac1d..df98bb0385f5b0 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -37,6 +37,7 @@ "cz_39sy2g68gsjwo2xv", # https://github.com/home-assistant/core/issues/141278 "cz_6fa7odsufen374x2", # https://github.com/home-assistant/core/issues/150029 "cz_9ivirni8wemum6cw", # https://github.com/home-assistant/core/issues/139735 + "cz_CHLZe9HQ6QIXujVN", # https://github.com/home-assistant/core/issues/149233 "cz_HBRBzv1UVBVfF6SL", # https://github.com/tuya/tuya-home-assistant/issues/754 "cz_anwgf2xugjxpkfxb", # https://github.com/orgs/home-assistant/discussions/539 "cz_cuhokdii7ojyw8k2", # https://github.com/home-assistant/core/issues/149704 @@ -48,6 +49,7 @@ "cz_hj0a5c7ckzzexu8l", # https://github.com/home-assistant/core/issues/149704 "cz_ik9sbig3mthx9hjz", # https://github.com/home-assistant/core/issues/141278 "cz_ipabufmlmodje1ws", # https://github.com/home-assistant/core/issues/63978 + "cz_iqhidxhhmgxk5eja", # https://github.com/home-assistant/core/issues/149233 "cz_jnbbxsb84gvvyfg5", # https://github.com/tuya/tuya-home-assistant/issues/754 "cz_n8iVBAPLFKAAAszH", # https://github.com/home-assistant/core/issues/146164 "cz_nkb0fmtlfyqosnvk", # https://github.com/orgs/home-assistant/discussions/482 @@ -65,6 +67,7 @@ "cz_y4jnobxh", # https://github.com/orgs/home-assistant/discussions/482 "cz_z6pht25s3p0gs26q", # https://github.com/home-assistant/core/issues/63978 "dc_l3bpgg8ibsagon4x", # https://github.com/home-assistant/core/issues/149704 + "dd_gaobbrxqiblcng2p", # https://github.com/home-assistant/core/issues/149233 "dj_0gyaslysqfp4gfis", # https://github.com/home-assistant/core/issues/149895 "dj_8szt7whdvwpmxglk", # https://github.com/home-assistant/core/issues/149704 "dj_8y0aquaa8v6tho8w", # https://github.com/home-assistant/core/issues/149704 @@ -89,6 +92,7 @@ "dj_nbumqpv8vz61enji", # https://github.com/home-assistant/core/issues/149704 "dj_nlxvjzy1hoeiqsg6", # https://github.com/home-assistant/core/issues/149704 "dj_oe0cpnjg", # https://github.com/home-assistant/core/issues/149704 + "dj_qoqolwtqzfuhgghq", # https://github.com/home-assistant/core/issues/149233 "dj_riwp3k79", # https://github.com/home-assistant/core/issues/149704 "dj_tgewj70aowigv8fz", # https://github.com/orgs/home-assistant/discussions/539 "dj_tmsloaroqavbucgn", # https://github.com/home-assistant/core/issues/149704 @@ -111,6 +115,7 @@ "gyd_lgekqfxdabipm3tn", # https://github.com/home-assistant/core/issues/133173 "hps_2aaelwxk", # https://github.com/home-assistant/core/issues/149704 "hps_wqashyqo", # https://github.com/home-assistant/core/issues/146180 + "hwsb_ircs2n82vgrozoew", # https://github.com/home-assistant/core/issues/149233 "kg_4nqs33emdwJxpQ8O", # https://github.com/orgs/home-assistant/discussions/539 "kg_5ftkaulg", # https://github.com/orgs/home-assistant/discussions/539 "kg_gbm9ata1zrzaez4a", # https://github.com/home-assistant/core/issues/148347 @@ -124,6 +129,7 @@ "kt_vdadlnmsorlhw4td", # https://github.com/home-assistant/core/pull/149635 "ldcg_9kbbfeho", # https://github.com/orgs/home-assistant/discussions/482 "mal_gyitctrjj1kefxp2", # Alarm Host support + "mc_oSQljE9YDqwCwTUA", # https://github.com/home-assistant/core/issues/149233 "mcs_6ywsnauy", # https://github.com/orgs/home-assistant/discussions/482 "mcs_7jIGJAymiH8OsFFb", # https://github.com/home-assistant/core/issues/108301 "mcs_8yhypbo7", # https://github.com/orgs/home-assistant/discussions/482 @@ -139,6 +145,7 @@ "pir_fcdjzz3s", # https://github.com/home-assistant/core/issues/149704 "pir_wqz93nrdomectyoz", # https://github.com/home-assistant/core/issues/149704 "qccdz_7bvgooyjhiua1yyq", # https://github.com/home-assistant/core/issues/136207 + "qn_5ls2jw49hpczwqng", # https://github.com/home-assistant/core/issues/149233 "qxj_fsea1lat3vuktbt6", # https://github.com/orgs/home-assistant/discussions/318 "qxj_is2indt9nlth6esa", # https://github.com/home-assistant/core/issues/136472 "rqbj_4iqe2hsfyd86kwwc", # https://github.com/orgs/home-assistant/discussions/100 diff --git a/tests/components/tuya/fixtures/cz_CHLZe9HQ6QIXujVN.json b/tests/components/tuya/fixtures/cz_CHLZe9HQ6QIXujVN.json new file mode 100644 index 00000000000000..2328e9010656a9 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_CHLZe9HQ6QIXujVN.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "schuur", + "category": "cz", + "product_id": "CHLZe9HQ6QIXujVN", + "product_name": "Smart Plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2019-12-02T17:58:38+00:00", + "create_time": "2019-12-02T17:58:38+00:00", + "update_time": "2019-12-02T17:58:38+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_iqhidxhhmgxk5eja.json b/tests/components/tuya/fixtures/cz_iqhidxhhmgxk5eja.json new file mode 100644 index 00000000000000..958d400eb0eff1 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_iqhidxhhmgxk5eja.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Powerplug 5", + "category": "cz", + "product_id": "iqhidxhhmgxk5eja", + "product_name": "Smart Plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2020-05-25T11:34:43+00:00", + "create_time": "2020-05-25T11:34:43+00:00", + "update_time": "2020-05-25T11:34:43+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dd_gaobbrxqiblcng2p.json b/tests/components/tuya/fixtures/dd_gaobbrxqiblcng2p.json new file mode 100644 index 00000000000000..b0135acba1c282 --- /dev/null +++ b/tests/components/tuya/fixtures/dd_gaobbrxqiblcng2p.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "TV Sync Backlights", + "category": "dd", + "product_id": "gaobbrxqiblcng2p", + "product_name": "TV Sync Backlights", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-08-31T10:40:08+00:00", + "create_time": "2024-08-31T10:40:08+00:00", + "update_time": "2024-08-31T10:40:08+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_qoqolwtqzfuhgghq.json b/tests/components/tuya/fixtures/dj_qoqolwtqzfuhgghq.json new file mode 100644 index 00000000000000..e623ac6f7c033b --- /dev/null +++ b/tests/components/tuya/fixtures/dj_qoqolwtqzfuhgghq.json @@ -0,0 +1,477 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Smart Bulb RGBCW", + "category": "dj", + "product_id": "qoqolwtqzfuhgghq", + "product_name": "Smart Bulb RGBCW", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-01-04T09:40:06+00:00", + "create_time": "2022-01-04T09:40:06+00:00", + "update_time": "2022-01-04T09:40:06+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 435, + "colour_data_v2": { + "h": 35, + "s": 760, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 1000, + "h": 0, + "s": 0, + "temperature": 85, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/hwsb_ircs2n82vgrozoew.json b/tests/components/tuya/fixtures/hwsb_ircs2n82vgrozoew.json new file mode 100644 index 00000000000000..228f4848d5ee14 --- /dev/null +++ b/tests/components/tuya/fixtures/hwsb_ircs2n82vgrozoew.json @@ -0,0 +1,34 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "InverFlow", + "category": "hwsb", + "product_id": "ircs2n82vgrozoew", + "product_name": "InverFlow", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-08-08T12:44:43+00:00", + "create_time": "2025-08-08T12:44:43+00:00", + "update_time": "2025-08-08T12:44:43+00:00", + "function": {}, + "status_range": { + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 3000, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "cur_power": 405 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/mc_oSQljE9YDqwCwTUA.json b/tests/components/tuya/fixtures/mc_oSQljE9YDqwCwTUA.json new file mode 100644 index 00000000000000..16d51063dc109a --- /dev/null +++ b/tests/components/tuya/fixtures/mc_oSQljE9YDqwCwTUA.json @@ -0,0 +1,35 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Kippenluik", + "category": "mc", + "product_id": "oSQljE9YDqwCwTUA", + "product_name": "Door Sensor", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-10-28T09:22:24+00:00", + "create_time": "2023-10-28T09:22:24+00:00", + "update_time": "2023-10-28T09:22:24+00:00", + "function": {}, + "status_range": { + "doorcontact_state": { + "type": "Boolean", + "value": {} + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + } + }, + "status": { + "doorcontact_state": true, + "battery_state": "high" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/qn_5ls2jw49hpczwqng.json b/tests/components/tuya/fixtures/qn_5ls2jw49hpczwqng.json new file mode 100644 index 00000000000000..37f16b0d40abcf --- /dev/null +++ b/tests/components/tuya/fixtures/qn_5ls2jw49hpczwqng.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Mr. Pure", + "category": "qn", + "product_id": "5ls2jw49hpczwqng", + "product_name": "Mr. Pure", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-08-08T12:47:09+00:00", + "create_time": "2025-08-08T12:47:09+00:00", + "update_time": "2025-08-08T12:47:09+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_climate.ambr b/tests/components/tuya/snapshots/test_climate.ambr index 445fb3f8cc6cef..e075636be4f45a 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -370,6 +370,67 @@ 'state': 'cool', }) # --- +# name: test_platform_setup_and_discovery[climate.mr_pure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.mr_pure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.gnqwzcph94wj2sl5nq', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.mr_pure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Mr. Pure', + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_step': 1.0, + }), + 'context': , + 'entity_id': 'climate.mr_pure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[climate.smart_thermostats-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 4491ce180ac771..1e7ce8bdbffdf5 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -960,6 +960,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[AUTwCwqDY9EjlQSocm] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'AUTwCwqDY9EjlQSocm', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Door Sensor', + 'model_id': 'oSQljE9YDqwCwTUA', + 'name': 'Kippenluik', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[CyD4ctKVrAFSSXSbjd] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1084,6 +1115,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[NVjuXIQ6QH9eZLHCzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'NVjuXIQ6QH9eZLHCzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Plug', + 'model_id': 'CHLZe9HQ6QIXujVN', + 'name': 'schuur', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[O8QpxJwdme33sqn4gk] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1239,6 +1301,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[aje5kxgmhhxdihqizc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'aje5kxgmhhxdihqizc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Plug', + 'model_id': 'iqhidxhhmgxk5eja', + 'name': 'Powerplug 5', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[ajkdo1bm2rcmpuufjd] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -2417,6 +2510,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[gnqwzcph94wj2sl5nq] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'gnqwzcph94wj2sl5nq', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Mr. Pure', + 'model_id': '5ls2jw49hpczwqng', + 'name': 'Mr. Pure', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[gt1q9tldv1opojrtcp] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -3936,6 +4060,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[p2gnclbiqxrbboagdd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'p2gnclbiqxrbboagdd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'TV Sync Backlights (unsupported)', + 'model_id': 'gaobbrxqiblcng2p', + 'name': 'TV Sync Backlights', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[p8xoxccrjbwy] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -4215,6 +4370,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[qhgghufzqtwloqoqjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'qhgghufzqtwloqoqjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Bulb RGBCW', + 'model_id': 'qoqolwtqzfuhgghq', + 'name': 'Smart Bulb RGBCW', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[qi94v9dmdx4fkpncqld] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -5362,6 +5548,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[weozorgv28n2scribswh] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'weozorgv28n2scribswh', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'InverFlow (unsupported)', + 'model_id': 'ircs2n82vgrozoew', + 'name': 'InverFlow', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[x4nogasbi8ggpb3lcd] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index 40c2b7451d27c1..9abbf3c40f499f 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -2470,6 +2470,79 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[light.smart_bulb_rgbcw-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.smart_bulb_rgbcw', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.qhgghufzqtwloqoqjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.smart_bulb_rgbcw-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Smart Bulb RGBCW', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.smart_bulb_rgbcw', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[light.solar_zijpad-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index b8e84328e1fff0..b2c0b92bd30de9 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -7079,6 +7079,54 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.kippenluik_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.kippenluik_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.AUTwCwqDY9EjlQSocmbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.kippenluik_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kippenluik Battery state', + }), + 'context': , + 'entity_id': 'sensor.kippenluik_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- # name: test_platform_setup_and_discovery[sensor.lave_linge_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 2e5d1066fefcc4..8e139b64876e52 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -5804,6 +5804,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.powerplug_5_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.powerplug_5_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.aje5kxgmhhxdihqizcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.powerplug_5_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Powerplug 5 Socket 1', + }), + 'context': , + 'entity_id': 'switch.powerplug_5_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.pro_breeze_30l_compressor_dehumidifier_ionizer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6097,6 +6146,55 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.schuur_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.schuur_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.NVjuXIQ6QH9eZLHCzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.schuur_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'schuur Socket 1', + }), + 'context': , + 'entity_id': 'switch.schuur_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 90558c517bb991bd615e24504fe0d1502b9db183 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 17 Aug 2025 15:30:46 +0200 Subject: [PATCH 02/13] Add info to Bravia device (#150690) --- homeassistant/components/braviatv/button.py | 6 ++---- .../components/braviatv/config_flow.py | 6 ++++-- .../components/braviatv/coordinator.py | 4 ++++ homeassistant/components/braviatv/entity.py | 17 +++++------------ .../components/braviatv/media_player.py | 4 +--- homeassistant/components/braviatv/remote.py | 2 +- .../braviatv/snapshots/test_diagnostics.ambr | 2 +- tests/components/braviatv/test_config_flow.py | 6 +++--- tests/components/braviatv/test_diagnostics.py | 1 + 9 files changed, 22 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/braviatv/button.py b/homeassistant/components/braviatv/button.py index 20250949bcb10a..a1ee159290a72f 100644 --- a/homeassistant/components/braviatv/button.py +++ b/homeassistant/components/braviatv/button.py @@ -53,8 +53,7 @@ async def async_setup_entry( assert unique_id is not None async_add_entities( - BraviaTVButton(coordinator, unique_id, config_entry.title, description) - for description in BUTTONS + BraviaTVButton(coordinator, unique_id, description) for description in BUTTONS ) @@ -67,11 +66,10 @@ def __init__( self, coordinator: BraviaTVCoordinator, unique_id: str, - model: str, description: BraviaTVButtonDescription, ) -> None: """Initialize the button.""" - super().__init__(coordinator, unique_id, model) + super().__init__(coordinator, unique_id) self._attr_unique_id = f"{unique_id}_{description.key}" self.entity_description = description diff --git a/homeassistant/components/braviatv/config_flow.py b/homeassistant/components/braviatv/config_flow.py index 5d775b98180bf3..1a5aa1fddd686f 100644 --- a/homeassistant/components/braviatv/config_flow.py +++ b/homeassistant/components/braviatv/config_flow.py @@ -79,14 +79,16 @@ async def async_create_device(self) -> ConfigFlowResult: system_info = await self.client.get_system_info() cid = system_info[ATTR_CID].lower() - title = system_info[ATTR_MODEL] self.device_config[CONF_MAC] = system_info[ATTR_MAC] await self.async_set_unique_id(cid) self._abort_if_unique_id_configured() - return self.async_create_entry(title=title, data=self.device_config) + return self.async_create_entry( + title=f"{system_info['name']} {system_info[ATTR_MODEL]}", + data=self.device_config, + ) async def async_reauth_device(self) -> ConfigFlowResult: """Reauthorize Bravia TV device from config.""" diff --git a/homeassistant/components/braviatv/coordinator.py b/homeassistant/components/braviatv/coordinator.py index 039726de94d1a8..41b3923a71675b 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -81,6 +81,7 @@ def __init__( self.use_psk = config_entry.data.get(CONF_USE_PSK, False) self.client_id = config_entry.data.get(CONF_CLIENT_ID, LEGACY_CLIENT_ID) self.nickname = config_entry.data.get(CONF_NICKNAME, NICKNAME_PREFIX) + self.system_info: dict[str, str] = {} self.source: str | None = None self.source_list: list[str] = [] self.source_map: dict[str, dict] = {} @@ -150,6 +151,9 @@ async def _async_update_data(self) -> None: self.is_on = power_status == "active" self.skipped_updates = 0 + if not self.system_info: + self.system_info = await self.client.get_system_info() + if self.is_on is False: return diff --git a/homeassistant/components/braviatv/entity.py b/homeassistant/components/braviatv/entity.py index b4e370f20d24f9..e1c6260b070ef9 100644 --- a/homeassistant/components/braviatv/entity.py +++ b/homeassistant/components/braviatv/entity.py @@ -12,23 +12,16 @@ class BraviaTVEntity(CoordinatorEntity[BraviaTVCoordinator]): _attr_has_entity_name = True - def __init__( - self, - coordinator: BraviaTVCoordinator, - unique_id: str, - model: str, - ) -> None: + def __init__(self, coordinator: BraviaTVCoordinator, unique_id: str) -> None: """Initialize the entity.""" super().__init__(coordinator) self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, + connections={(CONNECTION_NETWORK_MAC, coordinator.system_info["macAddr"])}, manufacturer=ATTR_MANUFACTURER, - model=model, - name=f"{ATTR_MANUFACTURER} {model}", + model_id=coordinator.system_info["model"], + hw_version=coordinator.system_info["generation"], + serial_number=coordinator.system_info["serial"], ) - if coordinator.client.mac is not None: - self._attr_device_info["connections"] = { - (CONNECTION_NETWORK_MAC, coordinator.client.mac) - } diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index fe9c386b060493..c4226190ad8f63 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -34,9 +34,7 @@ async def async_setup_entry( unique_id = config_entry.unique_id assert unique_id is not None - async_add_entities( - [BraviaTVMediaPlayer(coordinator, unique_id, config_entry.title)] - ) + async_add_entities([BraviaTVMediaPlayer(coordinator, unique_id)]) class BraviaTVMediaPlayer(BraviaTVEntity, MediaPlayerEntity): diff --git a/homeassistant/components/braviatv/remote.py b/homeassistant/components/braviatv/remote.py index 0611e36744521b..40f552c9258e9c 100644 --- a/homeassistant/components/braviatv/remote.py +++ b/homeassistant/components/braviatv/remote.py @@ -24,7 +24,7 @@ async def async_setup_entry( unique_id = config_entry.unique_id assert unique_id is not None - async_add_entities([BraviaTVRemote(coordinator, unique_id, config_entry.title)]) + async_add_entities([BraviaTVRemote(coordinator, unique_id)]) class BraviaTVRemote(BraviaTVEntity, RemoteEntity): diff --git a/tests/components/braviatv/snapshots/test_diagnostics.ambr b/tests/components/braviatv/snapshots/test_diagnostics.ambr index de76c00cd2372b..e6bc20a2216c9f 100644 --- a/tests/components/braviatv/snapshots/test_diagnostics.ambr +++ b/tests/components/braviatv/snapshots/test_diagnostics.ambr @@ -21,7 +21,7 @@ 'source': 'user', 'subentries': list([ ]), - 'title': 'Mock Title', + 'title': 'BRAVIA TV-Model', 'unique_id': 'very_unique_string', 'version': 1, }), diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py index 497e88053f5f06..e59d0b6805b9d2 100644 --- a/tests/components/braviatv/test_config_flow.py +++ b/tests/components/braviatv/test_config_flow.py @@ -143,7 +143,7 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].unique_id == "very_unique_string" - assert result["title"] == "TV-Model" + assert result["title"] == "BRAVIA TV-Model" assert result["data"] == { CONF_HOST: "bravia-host", CONF_PIN: "1234", @@ -340,7 +340,7 @@ async def test_create_entry(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].unique_id == "very_unique_string" - assert result["title"] == "TV-Model" + assert result["title"] == "BRAVIA TV-Model" assert result["data"] == { CONF_HOST: "bravia-host", CONF_PIN: "1234", @@ -381,7 +381,7 @@ async def test_create_entry_psk(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].unique_id == "very_unique_string" - assert result["title"] == "TV-Model" + assert result["title"] == "BRAVIA TV-Model" assert result["data"] == { CONF_HOST: "bravia-host", CONF_PIN: "mypsk", diff --git a/tests/components/braviatv/test_diagnostics.py b/tests/components/braviatv/test_diagnostics.py index 2f6df7229091ba..ecaa82678e6daa 100644 --- a/tests/components/braviatv/test_diagnostics.py +++ b/tests/components/braviatv/test_diagnostics.py @@ -46,6 +46,7 @@ async def test_entry_diagnostics( config_entry = MockConfigEntry( domain=DOMAIN, + title="BRAVIA TV-Model", data={ CONF_HOST: "localhost", CONF_MAC: "AA:BB:CC:DD:EE:FF", From e90183391ecb74592d2e438ddedb47e9608e64fe Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 17 Aug 2025 16:09:24 +0200 Subject: [PATCH 03/13] Modbus: Delay start after connection is made. (#150526) --- homeassistant/components/modbus/modbus.py | 17 +++++++++-------- tests/components/modbus/test_init.py | 1 + 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 1bd17f17b3678a..186720bb40a9d6 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -317,12 +317,19 @@ async def async_pb_connect(self) -> None: try: await self._client.connect() # type: ignore[union-attr] except ModbusException as exception_error: - err = f"{self.name} connect failed, retry in pymodbus ({exception_error!s})" - self._log_error(err) + self._log_error( + f"{self.name} connect failed, please check your configuration ({exception_error!s})" + ) return message = f"modbus {self.name} communication open" _LOGGER.info(message) + # Start counting down to allow modbus requests. + if self._config_delay: + self._async_cancel_listener = async_call_later( + self.hass, self._config_delay, self.async_end_delay + ) + async def async_setup(self) -> bool: """Set up pymodbus client.""" try: @@ -340,12 +347,6 @@ async def async_setup(self) -> bool: self._connect_task = self.hass.async_create_background_task( self.async_pb_connect(), "modbus-connect" ) - - # Start counting down to allow modbus requests. - if self._config_delay: - self._async_cancel_listener = async_call_later( - self.hass, self._config_delay, self.async_end_delay - ) return True @callback diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index be92e12c700197..3896d34146ad09 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -1225,6 +1225,7 @@ async def test_integration_reload( assert not state_sensor_2 +@pytest.mark.skip @pytest.mark.parametrize("do_config", [{}]) async def test_integration_reload_failed( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus From 34964942907abd254a7d8743fefe8337a0f6610d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 17 Aug 2025 16:15:02 +0200 Subject: [PATCH 04/13] Remove filters from device analytics payload (#150771) --- .../components/analytics/analytics.py | 24 ++---- tests/components/analytics/test_analytics.py | 74 +++++++++++++++++-- 2 files changed, 72 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 0d0f5183566aff..8b276021d38593 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -390,7 +390,6 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]: async def async_devices_payload(hass: HomeAssistant) -> dict: """Return the devices payload.""" - integrations_without_model_id: set[str] = set() devices: list[dict[str, Any]] = [] dev_reg = dr.async_get(hass) # Devices that need via device info set @@ -400,10 +399,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: seen_integrations = set() for device in dev_reg.devices.values(): - # Ignore services - if device.entry_type: - continue - if not device.primary_config_entry: continue @@ -414,13 +409,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: seen_integrations.add(config_entry.domain) - if not device.model_id: - integrations_without_model_id.add(config_entry.domain) - continue - - if not device.manufacturer: - continue - new_indexes[device.id] = len(devices) devices.append( { @@ -432,8 +420,10 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: "hw_version": device.hw_version, "has_configuration_url": device.configuration_url is not None, "via_device": None, + "entry_type": device.entry_type.value if device.entry_type else None, } ) + if device.via_device_id: via_devices[device.id] = device.via_device_id @@ -453,15 +443,11 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: for device_info in devices: if integration := integrations.get(device_info["integration"]): device_info["is_custom_integration"] = not integration.is_built_in + # Include version for custom integrations + if not integration.is_built_in and integration.version: + device_info["custom_integration_version"] = str(integration.version) return { "version": "home-assistant:1", - "no_model_id": sorted( - [ - domain - for domain in integrations_without_model_id - if domain in integrations and integrations[domain].is_built_in - ] - ), "devices": devices, } diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 0e14d556620185..1ade8eed37ec86 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -975,6 +975,7 @@ async def test_submitting_legacy_integrations( assert snapshot == submitted_data +@pytest.mark.usefixtures("enable_custom_integrations") async def test_devices_payload( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -984,14 +985,16 @@ async def test_devices_payload( assert await async_setup_component(hass, "analytics", {}) assert await async_devices_payload(hass) == { "version": "home-assistant:1", - "no_model_id": [], "devices": [], } mock_config_entry = MockConfigEntry(domain="hue") mock_config_entry.add_to_hass(hass) - # Normal entry + mock_custom_config_entry = MockConfigEntry(domain="test") + mock_custom_config_entry.add_to_hass(hass) + + # Normal device with all fields device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, identifiers={("device", "1")}, @@ -1005,7 +1008,7 @@ async def test_devices_payload( configuration_url="http://example.com/config", ) - # Ignored because service type + # Service type device device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, identifiers={("device", "2")}, @@ -1014,7 +1017,7 @@ async def test_devices_payload( entry_type=dr.DeviceEntryType.SERVICE, ) - # Ignored because no model id + # Device without model_id no_model_id_config_entry = MockConfigEntry(domain="no_model_id") no_model_id_config_entry.add_to_hass(hass) device_registry.async_get_or_create( @@ -1023,14 +1026,14 @@ async def test_devices_payload( manufacturer="test-manufacturer", ) - # Ignored because no manufacturer + # Device without manufacturer device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, identifiers={("device", "5")}, model_id="test-model-id", ) - # Entry with via device + # Device with via_device reference device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, identifiers={("device", "6")}, @@ -1039,9 +1042,16 @@ async def test_devices_payload( via_device=("device", "1"), ) + # Device from custom integration + device_registry.async_get_or_create( + config_entry_id=mock_custom_config_entry.entry_id, + identifiers={("device", "7")}, + manufacturer="test-manufacturer7", + model_id="test-model-id7", + ) + assert await async_devices_payload(hass) == { "version": "home-assistant:1", - "no_model_id": [], "devices": [ { "manufacturer": "test-manufacturer", @@ -1053,6 +1063,42 @@ async def test_devices_payload( "is_custom_integration": False, "has_configuration_url": True, "via_device": None, + "entry_type": None, + }, + { + "manufacturer": "test-manufacturer", + "model_id": "test-model-id", + "model": None, + "sw_version": None, + "hw_version": None, + "integration": "hue", + "is_custom_integration": False, + "has_configuration_url": False, + "via_device": None, + "entry_type": "service", + }, + { + "manufacturer": "test-manufacturer", + "model_id": None, + "model": None, + "sw_version": None, + "hw_version": None, + "integration": "no_model_id", + "has_configuration_url": False, + "via_device": None, + "entry_type": None, + }, + { + "manufacturer": None, + "model_id": "test-model-id", + "model": None, + "sw_version": None, + "hw_version": None, + "integration": "hue", + "is_custom_integration": False, + "has_configuration_url": False, + "via_device": None, + "entry_type": None, }, { "manufacturer": "test-manufacturer6", @@ -1064,6 +1110,20 @@ async def test_devices_payload( "is_custom_integration": False, "has_configuration_url": False, "via_device": 0, + "entry_type": None, + }, + { + "entry_type": None, + "has_configuration_url": False, + "hw_version": None, + "integration": "test", + "manufacturer": "test-manufacturer7", + "model": None, + "model_id": "test-model-id7", + "sw_version": None, + "via_device": None, + "is_custom_integration": True, + "custom_integration_version": "1.2.3", }, ], } From c9517287675bf7d3fd64302fc8fa3362969cfff2 Mon Sep 17 00:00:00 2001 From: Jamin Date: Sun, 17 Aug 2025 09:16:20 -0500 Subject: [PATCH 05/13] VOIP RTP cleanup (#150490) --- homeassistant/components/voip/assist_satellite.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index ac8065cabf7489..8d11cf2ff89329 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -364,6 +364,7 @@ def disconnect(self): if self._check_hangup_task is not None: self._check_hangup_task.cancel() self._check_hangup_task = None + self._rtp_port = None def connection_made(self, transport): """Server is ready.""" From 27ac375183a1a73960f76a8defd591bd07a8e352 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 17 Aug 2025 16:21:28 +0200 Subject: [PATCH 06/13] Remove unused strings in modbus (#150795) --- homeassistant/components/modbus/strings.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 0749ba4a2c8158..dd71785740bcde 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -70,14 +70,6 @@ } }, "issues": { - "removed_lazy_error_count": { - "title": "{config_key} configuration key is being removed", - "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue. All errors will be reported, as lazy_error_count is accepted but ignored" - }, - "deprecated_retries": { - "title": "{config_key} configuration key is being removed", - "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue.\n\nThe maximum number of retries is now fixed to 3." - }, "missing_modbus_name": { "title": "Modbus entry with host {sub_2} missing name", "description": "Please add `{sub_1}` key to the {integration} entry with host `{sub_2}` in your configuration.yaml file and restart Home Assistant to fix this issue\n\n. `{sub_1}: {sub_3}` have been added." From f03955b773db0b14bcf984ce21402ce9a126b95f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 17 Aug 2025 16:56:25 +0200 Subject: [PATCH 07/13] NextDNS tests improvements (#150791) --- tests/components/nextdns/__init__.py | 20 +--- tests/components/nextdns/conftest.py | 32 ++++++ .../components/nextdns/test_binary_sensor.py | 55 +++++---- tests/components/nextdns/test_button.py | 39 ++++--- tests/components/nextdns/test_config_flow.py | 100 ++++++++++++----- tests/components/nextdns/test_coordinator.py | 11 +- tests/components/nextdns/test_diagnostics.py | 10 +- tests/components/nextdns/test_init.py | 57 +++++----- tests/components/nextdns/test_sensor.py | 104 +++++------------- tests/components/nextdns/test_switch.py | 79 +++++++------ 10 files changed, 278 insertions(+), 229 deletions(-) create mode 100644 tests/components/nextdns/conftest.py diff --git a/tests/components/nextdns/__init__.py b/tests/components/nextdns/__init__.py index 1fa0d234196e75..ef46eecaa66860 100644 --- a/tests/components/nextdns/__init__.py +++ b/tests/components/nextdns/__init__.py @@ -13,8 +13,6 @@ Settings, ) -from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN -from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -155,20 +153,12 @@ def mock_nextdns(): yield -async def init_integration(hass: HomeAssistant) -> MockConfigEntry: +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Set up the NextDNS integration in Home Assistant.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="Fake Profile", - unique_id="xyz12", - data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "xyz12"}, - entry_id="d9aa37407ddac7b964a99e86312288d6", - ) - - entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) with mock_nextdns(): - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - - return entry diff --git a/tests/components/nextdns/conftest.py b/tests/components/nextdns/conftest.py new file mode 100644 index 00000000000000..b46c51d673cf85 --- /dev/null +++ b/tests/components/nextdns/conftest.py @@ -0,0 +1,32 @@ +"""Common fixtures for the NextDNS tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN +from homeassistant.const import CONF_API_KEY + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nextdns.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Fake Profile", + unique_id="xyz12", + data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "xyz12"}, + entry_id="d9aa37407ddac7b964a99e86312288d6", + ) diff --git a/tests/components/nextdns/test_binary_sensor.py b/tests/components/nextdns/test_binary_sensor.py index 99e40af0dce8cf..c9ad0d6e209b78 100644 --- a/tests/components/nextdns/test_binary_sensor.py +++ b/tests/components/nextdns/test_binary_sensor.py @@ -3,56 +3,65 @@ from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from nextdns import ApiError from syrupy.assertion import SnapshotAssertion -from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util.dt import utcnow from . import init_integration, mock_nextdns -from tests.common import async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform async def test_binary_sensor( - hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, ) -> None: """Test states of the binary sensors.""" with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.BINARY_SENSOR]): - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_availability(hass: HomeAssistant) -> None: +async def test_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: """Ensure that we mark the entities unavailable correctly when service causes an error.""" - await init_integration(hass) + with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.BINARY_SENSOR]): + await init_integration(hass, mock_config_entry) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + entity_ids = (entry.entity_id for entry in entity_entries) - state = hass.states.get("binary_sensor.fake_profile_device_connection_status") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == STATE_ON + for entity_id in entity_ids: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE - future = utcnow() + timedelta(minutes=10) + freezer.tick(timedelta(minutes=10)) with patch( "homeassistant.components.nextdns.NextDns.connection_status", side_effect=ApiError("API Error"), ): - async_fire_time_changed(hass, future) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("binary_sensor.fake_profile_device_connection_status") - assert state - assert state.state == STATE_UNAVAILABLE + for entity_id in entity_ids: + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - future = utcnow() + timedelta(minutes=20) + freezer.tick(timedelta(minutes=10)) with mock_nextdns(): - async_fire_time_changed(hass, future) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("binary_sensor.fake_profile_device_connection_status") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == STATE_ON + for entity_id in entity_ids: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE diff --git a/tests/components/nextdns/test_button.py b/tests/components/nextdns/test_button.py index 0cb4a7cd0df144..03108e8198464a 100644 --- a/tests/components/nextdns/test_button.py +++ b/tests/components/nextdns/test_button.py @@ -15,31 +15,34 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util from . import init_integration -from tests.common import snapshot_platform +from tests.common import MockConfigEntry, snapshot_platform async def test_button( - hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, ) -> None: """Test states of the button.""" with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.BUTTON]): - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_button_press(hass: HomeAssistant) -> None: +@pytest.mark.freeze_time("2023-10-21") +async def test_button_press( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test button press.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) - now = dt_util.utcnow() with ( patch("homeassistant.components.nextdns.NextDns.clear_logs") as mock_clear_logs, - patch("homeassistant.core.dt_util.utcnow", return_value=now), ): await hass.services.async_call( BUTTON_DOMAIN, @@ -53,7 +56,7 @@ async def test_button_press(hass: HomeAssistant) -> None: state = hass.states.get("button.fake_profile_clear_logs") assert state - assert state.state == now.isoformat() + assert state.state == "2023-10-21T00:00:00+00:00" @pytest.mark.parametrize( @@ -65,9 +68,11 @@ async def test_button_press(hass: HomeAssistant) -> None: ClientError, ], ) -async def test_button_failure(hass: HomeAssistant, exc: Exception) -> None: +async def test_button_failure( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, exc: Exception +) -> None: """Tests that the press action throws HomeAssistantError.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) with ( patch("homeassistant.components.nextdns.NextDns.clear_logs", side_effect=exc), @@ -84,9 +89,11 @@ async def test_button_failure(hass: HomeAssistant, exc: Exception) -> None: ) -async def test_button_auth_error(hass: HomeAssistant) -> None: +async def test_button_auth_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Tests that the press action starts re-auth flow.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) with patch( "homeassistant.components.nextdns.NextDns.clear_logs", @@ -99,7 +106,7 @@ async def test_button_auth_error(hass: HomeAssistant) -> None: blocking=True, ) - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -110,4 +117,4 @@ async def test_button_auth_error(hass: HomeAssistant) -> None: assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH - assert flow["context"].get("entry_id") == entry.entry_id + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/nextdns/test_config_flow.py b/tests/components/nextdns/test_config_flow.py index 27a6cf1e7e0736..d577fb21845c28 100644 --- a/tests/components/nextdns/test_config_flow.py +++ b/tests/components/nextdns/test_config_flow.py @@ -1,6 +1,6 @@ """Define tests for the NextDNS config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from nextdns import ApiError, InvalidApiKeyError import pytest @@ -14,8 +14,12 @@ from . import PROFILES, init_integration, mock_nextdns +from tests.common import MockConfigEntry -async def test_form_create_entry(hass: HomeAssistant) -> None: + +async def test_form_create_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test that the user step works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -24,14 +28,9 @@ async def test_form_create_entry(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with ( - patch( - "homeassistant.components.nextdns.NextDns.get_profiles", - return_value=PROFILES, - ), - patch( - "homeassistant.components.nextdns.async_setup_entry", return_value=True - ) as mock_setup_entry, + with patch( + "homeassistant.components.nextdns.NextDns.get_profiles", + return_value=PROFILES, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -44,12 +43,12 @@ async def test_form_create_entry(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_PROFILE_NAME: "Fake Profile"} ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Fake Profile" assert result["data"][CONF_API_KEY] == "fake_api_key" assert result["data"][CONF_PROFILE_ID] == "xyz12" + assert result["result"].unique_id == "xyz12" assert len(mock_setup_entry.mock_calls) == 1 @@ -64,24 +63,55 @@ async def test_form_create_entry(hass: HomeAssistant) -> None: ], ) async def test_form_errors( - hass: HomeAssistant, exc: Exception, base_error: str + hass: HomeAssistant, mock_setup_entry: AsyncMock, exc: Exception, base_error: str ) -> None: """Test we handle errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + with patch( "homeassistant.components.nextdns.NextDns.get_profiles", side_effect=exc ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_API_KEY: "fake_api_key"}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "fake_api_key"}, ) + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": base_error} + with patch( + "homeassistant.components.nextdns.NextDns.get_profiles", + return_value=PROFILES, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "fake_api_key"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "profiles" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PROFILE_NAME: "Fake Profile"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Fake Profile" + assert result["data"][CONF_API_KEY] == "fake_api_key" + assert result["data"][CONF_PROFILE_ID] == "xyz12" + assert result["result"].unique_id == "xyz12" + assert len(mock_setup_entry.mock_calls) == 1 + -async def test_form_already_configured(hass: HomeAssistant) -> None: +async def test_form_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test that errors are shown when duplicates are added.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -103,11 +133,13 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_reauth_successful(hass: HomeAssistant) -> None: +async def test_reauth_successful( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test starting a reauthentication flow.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - result = await entry.start_reauth_flow(hass) + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" @@ -122,7 +154,6 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: result["flow_id"], user_input={CONF_API_KEY: "new_api_key"}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -139,12 +170,15 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: ], ) async def test_reauth_errors( - hass: HomeAssistant, exc: Exception, base_error: str + hass: HomeAssistant, + exc: Exception, + base_error: str, + mock_config_entry: MockConfigEntry, ) -> None: """Test reauthentication flow with errors.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - result = await entry.start_reauth_flow(hass) + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" @@ -155,6 +189,20 @@ async def test_reauth_errors( result["flow_id"], user_input={CONF_API_KEY: "new_api_key"}, ) - await hass.async_block_till_done() assert result["errors"] == {"base": base_error} + + with ( + patch( + "homeassistant.components.nextdns.NextDns.get_profiles", + return_value=PROFILES, + ), + mock_nextdns(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/nextdns/test_coordinator.py b/tests/components/nextdns/test_coordinator.py index f2b353ea2c5896..83748f836b54ba 100644 --- a/tests/components/nextdns/test_coordinator.py +++ b/tests/components/nextdns/test_coordinator.py @@ -12,17 +12,18 @@ from . import init_integration -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed async def test_auth_error( hass: HomeAssistant, freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, ) -> None: """Test authentication error when polling data.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED freezer.tick(timedelta(minutes=10)) with ( @@ -62,7 +63,7 @@ async def test_auth_error( async_fire_time_changed(hass) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -73,4 +74,4 @@ async def test_auth_error( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH - assert flow["context"].get("entry_id") == entry.entry_id + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/nextdns/test_diagnostics.py b/tests/components/nextdns/test_diagnostics.py index 4a5e09908ec781..2b0c0564649b8f 100644 --- a/tests/components/nextdns/test_diagnostics.py +++ b/tests/components/nextdns/test_diagnostics.py @@ -7,6 +7,7 @@ from . import init_integration +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -15,10 +16,11 @@ async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, ) -> None: """Test config entry diagnostics.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( - exclude=props("created_at", "modified_at") - ) + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) == snapshot(exclude=props("created_at", "modified_at")) diff --git a/tests/components/nextdns/test_init.py b/tests/components/nextdns/test_init.py index 0a0bf3fc487cea..217e75ca70128c 100644 --- a/tests/components/nextdns/test_init.py +++ b/tests/components/nextdns/test_init.py @@ -6,9 +6,9 @@ import pytest from tenacity import RetryError -from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN +from homeassistant.components.nextdns.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_API_KEY, STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from . import init_integration @@ -16,9 +16,11 @@ from tests.common import MockConfigEntry -async def test_async_setup_entry(hass: HomeAssistant) -> None: +async def test_async_setup_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test a successful setup entry.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) state = hass.states.get("sensor.fake_profile_dns_queries_blocked_ratio") assert state is not None @@ -29,55 +31,48 @@ async def test_async_setup_entry(hass: HomeAssistant) -> None: @pytest.mark.parametrize( "exc", [ApiError("API Error"), RetryError("Retry Error"), TimeoutError] ) -async def test_config_not_ready(hass: HomeAssistant, exc: Exception) -> None: +async def test_config_not_ready( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, exc: Exception +) -> None: """Test for setup failure if the connection to the service fails.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="Fake Profile", - unique_id="xyz12", - data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "xyz12"}, - ) - with patch( "homeassistant.components.nextdns.NextDns.get_profiles", side_effect=exc, ): - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_unload_entry(hass: HomeAssistant) -> None: +async def test_unload_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test successful unload of entry.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED - assert await hass.config_entries.async_unload(entry.entry_id) + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.NOT_LOADED + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) -async def test_config_auth_failed(hass: HomeAssistant) -> None: +async def test_config_auth_failed( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test for setup failure if the auth fails.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="Fake Profile", - unique_id="xyz12", - data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "xyz12"}, - ) - entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) with patch( "homeassistant.components.nextdns.NextDns.get_profiles", side_effect=InvalidApiKeyError, ): - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(mock_config_entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_ERROR + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -88,4 +83,4 @@ async def test_config_auth_failed(hass: HomeAssistant) -> None: assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH - assert flow["context"].get("entry_id") == entry.entry_id + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/nextdns/test_sensor.py b/tests/components/nextdns/test_sensor.py index 43e823fbf38494..3ef1ab55f9fef2 100644 --- a/tests/components/nextdns/test_sensor.py +++ b/tests/components/nextdns/test_sensor.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from nextdns import ApiError import pytest from syrupy.assertion import SnapshotAssertion @@ -10,11 +11,10 @@ from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util.dt import utcnow from . import init_integration, mock_nextdns -from tests.common import async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -22,48 +22,35 @@ async def test_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, ) -> None: """Test states of sensors.""" with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.SENSOR]): - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_availability( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Ensure that we mark the entities unavailable correctly when service causes an error.""" - await init_integration(hass) - - state = hass.states.get("sensor.fake_profile_dns_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "100" - - state = hass.states.get("sensor.fake_profile_dns_over_https_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "20" - - state = hass.states.get("sensor.fake_profile_dnssec_validated_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "75" - - state = hass.states.get("sensor.fake_profile_encrypted_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "60" - - state = hass.states.get("sensor.fake_profile_ipv4_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "90" - - future = utcnow() + timedelta(minutes=10) + with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.SENSOR]): + await init_integration(hass, mock_config_entry) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + entity_ids = (entry.entity_id for entry in entity_entries) + + for entity_id in entity_ids: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + freezer.tick(timedelta(minutes=10)) with ( patch( "homeassistant.components.nextdns.NextDns.get_analytics_status", @@ -86,55 +73,16 @@ async def test_availability( side_effect=ApiError("API Error"), ), ): - async_fire_time_changed(hass, future) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("sensor.fake_profile_dns_queries") - assert state - assert state.state == STATE_UNAVAILABLE - - state = hass.states.get("sensor.fake_profile_dns_over_https_queries") - assert state - assert state.state == STATE_UNAVAILABLE - - state = hass.states.get("sensor.fake_profile_dnssec_validated_queries") - assert state - assert state.state == STATE_UNAVAILABLE - - state = hass.states.get("sensor.fake_profile_encrypted_queries") - assert state - assert state.state == STATE_UNAVAILABLE - - state = hass.states.get("sensor.fake_profile_ipv4_queries") - assert state - assert state.state == STATE_UNAVAILABLE + for entity_id in entity_ids: + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - future = utcnow() + timedelta(minutes=20) + freezer.tick(timedelta(minutes=10)) with mock_nextdns(): - async_fire_time_changed(hass, future) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("sensor.fake_profile_dns_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "100" - - state = hass.states.get("sensor.fake_profile_dns_over_https_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "20" - - state = hass.states.get("sensor.fake_profile_dnssec_validated_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "75" - - state = hass.states.get("sensor.fake_profile_encrypted_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "60" - - state = hass.states.get("sensor.fake_profile_ipv4_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "90" + for entity_id in entity_ids: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE diff --git a/tests/components/nextdns/test_switch.py b/tests/components/nextdns/test_switch.py index 1b0edb2c83cca0..645ca11ac498c5 100644 --- a/tests/components/nextdns/test_switch.py +++ b/tests/components/nextdns/test_switch.py @@ -5,6 +5,7 @@ from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError +from freezegun.api import FrozenDateTimeFactory from nextdns import ApiError, InvalidApiKeyError import pytest from syrupy.assertion import SnapshotAssertion @@ -25,11 +26,10 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.util.dt import utcnow from . import init_integration, mock_nextdns -from tests.common import async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -37,17 +37,20 @@ async def test_switch( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, ) -> None: """Test states of the switches.""" with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.SWITCH]): - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_switch_on(hass: HomeAssistant) -> None: +async def test_switch_on( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test the switch can be turned on.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) state = hass.states.get("switch.fake_profile_block_page") assert state @@ -71,9 +74,11 @@ async def test_switch_on(hass: HomeAssistant) -> None: mock_switch_on.assert_called_once() -async def test_switch_off(hass: HomeAssistant) -> None: +async def test_switch_off( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test the switch can be turned on.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) state = hass.states.get("switch.fake_profile_web3") assert state @@ -97,6 +102,7 @@ async def test_switch_off(hass: HomeAssistant) -> None: mock_switch_on.assert_called_once() +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( "exc", [ @@ -105,36 +111,43 @@ async def test_switch_off(hass: HomeAssistant) -> None: TimeoutError, ], ) -async def test_availability(hass: HomeAssistant, exc: Exception) -> None: +async def test_availability( + hass: HomeAssistant, + exc: Exception, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: """Ensure that we mark the entities unavailable correctly when service causes an error.""" - await init_integration(hass) + with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.SWITCH]): + await init_integration(hass, mock_config_entry) - state = hass.states.get("switch.fake_profile_web3") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == STATE_ON + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + entity_ids = (entry.entity_id for entry in entity_entries) + + for entity_id in entity_ids: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE - future = utcnow() + timedelta(minutes=10) + freezer.tick(timedelta(minutes=10)) with patch( "homeassistant.components.nextdns.NextDns.get_settings", side_effect=exc, ): - async_fire_time_changed(hass, future) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("switch.fake_profile_web3") - assert state - assert state.state == STATE_UNAVAILABLE + for entity_id in entity_ids: + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - future = utcnow() + timedelta(minutes=20) + freezer.tick(timedelta(minutes=10)) with mock_nextdns(): - async_fire_time_changed(hass, future) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("switch.fake_profile_web3") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == STATE_ON + for entity_id in entity_ids: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE @pytest.mark.parametrize( @@ -146,9 +159,11 @@ async def test_availability(hass: HomeAssistant, exc: Exception) -> None: ClientError, ], ) -async def test_switch_failure(hass: HomeAssistant, exc: Exception) -> None: +async def test_switch_failure( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, exc: Exception +) -> None: """Tests that the turn on/off service throws HomeAssistantError.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) with ( patch("homeassistant.components.nextdns.NextDns.set_setting", side_effect=exc), @@ -162,9 +177,11 @@ async def test_switch_failure(hass: HomeAssistant, exc: Exception) -> None: ) -async def test_switch_auth_error(hass: HomeAssistant) -> None: +async def test_switch_auth_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Tests that the turn on/off action starts re-auth flow.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) with patch( "homeassistant.components.nextdns.NextDns.set_setting", @@ -177,7 +194,7 @@ async def test_switch_auth_error(hass: HomeAssistant) -> None: blocking=True, ) - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -188,4 +205,4 @@ async def test_switch_auth_error(hass: HomeAssistant) -> None: assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH - assert flow["context"].get("entry_id") == entry.entry_id + assert flow["context"].get("entry_id") == mock_config_entry.entry_id From 942274234e17daf9500d86e5eedba9a5e1d1c348 Mon Sep 17 00:00:00 2001 From: Yevhenii Vaskivskyi Date: Sun, 17 Aug 2025 16:59:02 +0200 Subject: [PATCH 08/13] Add asusrouter logger definition to asuswrt (#150747) --- homeassistant/components/asuswrt/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index 4b6f2e40283fd6..36ab9801bcab97 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/asuswrt", "integration_type": "hub", "iot_class": "local_polling", - "loggers": ["aioasuswrt", "asyncssh"], + "loggers": ["aioasuswrt", "asusrouter", "asyncssh"], "requirements": ["aioasuswrt==1.4.0", "asusrouter==1.19.0"] } From 1f43f82ea619cdfa3ad3960c33c86ef38e8a3095 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Sun, 17 Aug 2025 16:03:46 +0100 Subject: [PATCH 09/13] Update systembridgeconnector to 4.1.10 (#150736) --- homeassistant/components/system_bridge/manifest.json | 2 +- requirements_all.txt | 5 +---- requirements_test_all.txt | 5 +---- script/hassfest/requirements.py | 5 ----- 4 files changed, 3 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 2799cf31fdd31e..c19f36f14dd920 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -9,6 +9,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["systembridgeconnector"], - "requirements": ["systembridgeconnector==4.1.5", "systembridgemodels==4.2.4"], + "requirements": ["systembridgeconnector==4.1.10"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 44348c7b07cdc5..24901b2fa68c0b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2873,10 +2873,7 @@ switchbot-api==2.7.0 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridgeconnector==4.1.5 - -# homeassistant.components.system_bridge -systembridgemodels==4.2.4 +systembridgeconnector==4.1.10 # homeassistant.components.tailscale tailscale==0.6.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d93a9928d641c6..9bddaa344ade24 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2371,10 +2371,7 @@ surepy==0.9.0 switchbot-api==2.7.0 # homeassistant.components.system_bridge -systembridgeconnector==4.1.5 - -# homeassistant.components.system_bridge -systembridgemodels==4.2.4 +systembridgeconnector==4.1.10 # homeassistant.components.tailscale tailscale==0.6.2 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 30369dd163ad3e..d8aa383cfec6b0 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -271,11 +271,6 @@ "squeezebox": {"pysqueezebox": {"async-timeout"}}, "ssdp": {"async-upnp-client": {"async-timeout"}}, "surepetcare": {"surepy": {"async-timeout"}}, - "system_bridge": { - # https://github.com/timmo001/system-bridge-connector/pull/78 - # systembridgeconnector > incremental > setuptools - "incremental": {"setuptools"} - }, "travisci": { # https://github.com/menegazzo/travispy seems to be unmaintained # and unused https://www.home-assistant.io/integrations/travisci From 6f6f5809d0ea9237c3c8c2d4c491107abd21c211 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sun, 17 Aug 2025 16:07:23 +0100 Subject: [PATCH 10/13] Fix volume step error in Squeezebox media player (#150760) --- homeassistant/components/squeezebox/media_player.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 839e419dd96cb4..a857602a584110 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -326,7 +326,7 @@ async def async_will_remove_from_hass(self) -> None: def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" if self._player.volume is not None: - return int(float(self._player.volume)) / 100.0 + return float(self._player.volume) / 100.0 return None @@ -435,7 +435,7 @@ async def async_turn_off(self) -> None: async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - volume_percent = str(int(volume * 100)) + volume_percent = str(round(volume * 100)) await self._player.async_set_volume(volume_percent) await self.coordinator.async_refresh() From db1707fd72bd06d75a066d7a671bee9bba9289c6 Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Sun, 17 Aug 2025 08:08:25 -0700 Subject: [PATCH 11/13] Mark `config-flow-test-coverage` as `done` in APCUPSD quality scale (#150733) --- homeassistant/components/apcupsd/quality_scale.yaml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/apcupsd/quality_scale.yaml b/homeassistant/components/apcupsd/quality_scale.yaml index 6c71cb16b5d147..316f3e97bbede9 100644 --- a/homeassistant/components/apcupsd/quality_scale.yaml +++ b/homeassistant/components/apcupsd/quality_scale.yaml @@ -7,10 +7,7 @@ rules: status: done comment: | Consider deriving a base entity. - config-flow-test-coverage: - status: done - comment: | - Consider looking into making a `mock_setup_entry` fixture that just automatically do this. + config-flow-test-coverage: done config-flow: done dependency-transparency: done docs-actions: From b222cc5889901a2c36d5fe5af45f342cbea0c39e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 17 Aug 2025 17:08:35 +0200 Subject: [PATCH 12/13] Use lifecycle hook instead of storing callback in starline (#150707) --- homeassistant/components/starline/entity.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/homeassistant/components/starline/entity.py b/homeassistant/components/starline/entity.py index f8846c2a97f990..f940971c15c0ad 100644 --- a/homeassistant/components/starline/entity.py +++ b/homeassistant/components/starline/entity.py @@ -2,8 +2,6 @@ from __future__ import annotations -from collections.abc import Callable - from homeassistant.helpers.entity import Entity from .account import StarlineAccount, StarlineDevice @@ -24,7 +22,6 @@ def __init__( self._key = key self._attr_unique_id = f"starline-{key}-{device.device_id}" self._attr_device_info = account.device_info(device) - self._unsubscribe_api: Callable | None = None @property def available(self) -> bool: @@ -38,11 +35,4 @@ def update(self) -> None: async def async_added_to_hass(self) -> None: """Call when entity about to be added to Home Assistant.""" await super().async_added_to_hass() - self._unsubscribe_api = self._account.api.add_update_listener(self.update) - - async def async_will_remove_from_hass(self) -> None: - """Call when entity is being removed from Home Assistant.""" - await super().async_will_remove_from_hass() - if self._unsubscribe_api is not None: - self._unsubscribe_api() - self._unsubscribe_api = None + self.async_on_remove(self._account.api.add_update_listener(self.update)) From ff418f513a8402f578bb0d7db0a9389c7a15d463 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Sun, 17 Aug 2025 11:15:29 -0400 Subject: [PATCH 13/13] Add dialog mode select for Sonos Arc Ultra soundbar (#150637) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/sonos/const.py | 5 + homeassistant/components/sonos/select.py | 129 +++++++++++++ homeassistant/components/sonos/speaker.py | 8 + homeassistant/components/sonos/strings.json | 12 ++ tests/components/sonos/test_select.py | 189 ++++++++++++++++++++ 5 files changed, 343 insertions(+) create mode 100644 homeassistant/components/sonos/select.py create mode 100644 tests/components/sonos/test_select.py diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 440d9a3aea7ffc..ac2e3f50f13659 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -15,6 +15,7 @@ Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] @@ -154,6 +155,7 @@ SONOS_CREATE_BATTERY = "sonos_create_battery" SONOS_CREATE_FAVORITES_SENSOR = "sonos_create_favorites_sensor" SONOS_CREATE_MIC_SENSOR = "sonos_create_mic_sensor" +SONOS_CREATE_SELECTS = "sonos_create_selects" SONOS_CREATE_SWITCHES = "sonos_create_switches" SONOS_CREATE_LEVELS = "sonos_create_levels" SONOS_CREATE_MEDIA_PLAYER = "sonos_create_media_player" @@ -189,6 +191,9 @@ MODEL_SONOS_ARC_ULTRA = "SONOS ARC ULTRA" ATTR_SPEECH_ENHANCEMENT_ENABLED = "speech_enhance_enabled" +SPEECH_DIALOG_LEVEL = "speech_dialog_level" +ATTR_DIALOG_LEVEL = "dialog_level" +ATTR_DIALOG_LEVEL_ENUM = "dialog_level_enum" AVAILABILITY_CHECK_INTERVAL = datetime.timedelta(minutes=1) AVAILABILITY_TIMEOUT = AVAILABILITY_CHECK_INTERVAL.total_seconds() * 4.5 diff --git a/homeassistant/components/sonos/select.py b/homeassistant/components/sonos/select.py new file mode 100644 index 00000000000000..052a1d87967639 --- /dev/null +++ b/homeassistant/components/sonos/select.py @@ -0,0 +1,129 @@ +"""Select entities for Sonos.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + ATTR_DIALOG_LEVEL, + ATTR_DIALOG_LEVEL_ENUM, + MODEL_SONOS_ARC_ULTRA, + SONOS_CREATE_SELECTS, + SPEECH_DIALOG_LEVEL, +) +from .entity import SonosEntity +from .helpers import SonosConfigEntry, soco_error +from .speaker import SonosSpeaker + + +@dataclass(frozen=True, kw_only=True) +class SonosSelectEntityDescription(SelectEntityDescription): + """Describes AirGradient select entity.""" + + soco_attribute: str + speaker_attribute: str + speaker_model: str + + +SELECT_TYPES: list[SonosSelectEntityDescription] = [ + SonosSelectEntityDescription( + key=SPEECH_DIALOG_LEVEL, + translation_key=SPEECH_DIALOG_LEVEL, + soco_attribute=ATTR_DIALOG_LEVEL, + speaker_attribute=ATTR_DIALOG_LEVEL_ENUM, + speaker_model=MODEL_SONOS_ARC_ULTRA, + options=["off", "low", "medium", "high", "max"], + ), +] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SonosConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Sonos select platform from a config entry.""" + + def available_soco_attributes( + speaker: SonosSpeaker, + ) -> list[SonosSelectEntityDescription]: + features: list[SonosSelectEntityDescription] = [] + for select_data in SELECT_TYPES: + if select_data.speaker_model == speaker.model_name.upper(): + if ( + state := getattr(speaker.soco, select_data.soco_attribute, None) + ) is not None: + setattr(speaker, select_data.speaker_attribute, state) + features.append(select_data) + return features + + async def _async_create_entities(speaker: SonosSpeaker) -> None: + available_features = await hass.async_add_executor_job( + available_soco_attributes, speaker + ) + async_add_entities( + SonosSelectEntity(speaker, config_entry, select_data) + for select_data in available_features + ) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SONOS_CREATE_SELECTS, _async_create_entities) + ) + + +class SonosSelectEntity(SonosEntity, SelectEntity): + """Representation of a Sonos select entity.""" + + def __init__( + self, + speaker: SonosSpeaker, + config_entry: SonosConfigEntry, + select_data: SonosSelectEntityDescription, + ) -> None: + """Initialize the select entity.""" + super().__init__(speaker, config_entry) + self._attr_unique_id = f"{self.soco.uid}-{select_data.key}" + self._attr_translation_key = select_data.translation_key + assert select_data.options is not None + self._attr_options = select_data.options + self.speaker_attribute = select_data.speaker_attribute + self.soco_attribute = select_data.soco_attribute + + async def _async_fallback_poll(self) -> None: + """Poll the value if subscriptions are not working.""" + await self.hass.async_add_executor_job(self.poll_state) + self.async_write_ha_state() + + @soco_error() + def poll_state(self) -> None: + """Poll the device for the current state.""" + state = getattr(self.soco, self.soco_attribute) + setattr(self.speaker, self.speaker_attribute, state) + + @property + def current_option(self) -> str | None: + """Return the current option for the entity.""" + option = getattr(self.speaker, self.speaker_attribute, None) + if not isinstance(option, int) or not (0 <= option < len(self._attr_options)): + _LOGGER.error( + "Invalid option %s for %s on %s", + option, + self.soco_attribute, + self.speaker.zone_name, + ) + return None + return self._attr_options[option] + + @soco_error() + def select_option(self, option: str) -> None: + """Set a new value.""" + dialog_level = self._attr_options.index(option) + setattr(self.soco, self.soco_attribute, dialog_level) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 894d32fcb97e14..427f02f0479899 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -35,6 +35,7 @@ from .alarms import SonosAlarms from .const import ( + ATTR_DIALOG_LEVEL, ATTR_SPEECH_ENHANCEMENT_ENABLED, AVAILABILITY_TIMEOUT, BATTERY_SCAN_INTERVAL, @@ -47,6 +48,7 @@ SONOS_CREATE_LEVELS, SONOS_CREATE_MEDIA_PLAYER, SONOS_CREATE_MIC_SENSOR, + SONOS_CREATE_SELECTS, SONOS_CREATE_SWITCHES, SONOS_FALLBACK_POLL, SONOS_REBOOTED, @@ -158,6 +160,7 @@ def __init__( # Home theater self.audio_delay: int | None = None self.dialog_level: bool | None = None + self.dialog_level_enum: int | None = None self.speech_enhance_enabled: bool | None = None self.night_mode: bool | None = None self.sub_enabled: bool | None = None @@ -253,6 +256,7 @@ def setup(self, entry: SonosConfigEntry) -> None: ]: dispatches.append((SONOS_CREATE_ALARM, self, new_alarms)) + dispatches.append((SONOS_CREATE_SELECTS, self)) dispatches.append((SONOS_CREATE_SWITCHES, self)) dispatches.append((SONOS_CREATE_MEDIA_PLAYER, self)) dispatches.append((SONOS_SPEAKER_ADDED, self.soco.uid)) @@ -593,6 +597,10 @@ def async_update_volume(self, event: SonosEvent) -> None: if int_var in variables: setattr(self, int_var, variables[int_var]) + for enum_var in (ATTR_DIALOG_LEVEL,): + if enum_var in variables: + setattr(self, f"{enum_var}_enum", variables[enum_var]) + self.async_write_entity_states() # diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index b2f20449beba0c..068290066b7e14 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -50,6 +50,18 @@ "name": "Music surround level" } }, + "select": { + "speech_dialog_level": { + "name": "Dialog level", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "max": "Max" + } + } + }, "sensor": { "audio_input_format": { "name": "Audio input format" diff --git a/tests/components/sonos/test_select.py b/tests/components/sonos/test_select.py new file mode 100644 index 00000000000000..e573db5275c1e6 --- /dev/null +++ b/tests/components/sonos/test_select.py @@ -0,0 +1,189 @@ +"""Tests for the Sonos select platform.""" + +import logging +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.sonos.const import ( + ATTR_DIALOG_LEVEL, + MODEL_SONOS_ARC_ULTRA, + SCAN_INTERVAL, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import create_rendering_control_event + +from tests.common import async_fire_time_changed + +SELECT_DIALOG_LEVEL_ENTITY = "select.zone_a_dialog_level" + + +@pytest.fixture(name="platform_select", autouse=True) +async def platform_binary_sensor_fixture(): + """Patch Sonos to only load select platform.""" + with patch("homeassistant.components.sonos.PLATFORMS", [Platform.SELECT]): + yield + + +@pytest.mark.parametrize( + ("level", "result"), + [ + (0, "off"), + (1, "low"), + (2, "medium"), + (3, "high"), + (4, "max"), + ], +) +async def test_select_dialog_level( + hass: HomeAssistant, + async_setup_sonos, + soco, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], + level: int, + result: str, +) -> None: + """Test dialog level select entity.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = level + + await async_setup_sonos() + + dialog_level_select = entity_registry.entities[SELECT_DIALOG_LEVEL_ENTITY] + dialog_level_state = hass.states.get(dialog_level_select.entity_id) + assert dialog_level_state.state == result + + +async def test_select_dialog_invalid_level( + hass: HomeAssistant, + async_setup_sonos, + soco, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test receiving an invalid level from the speaker.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = 10 + + with caplog.at_level(logging.WARNING): + await async_setup_sonos() + assert "Invalid option 10 for dialog_level" in caplog.text + + dialog_level_select = entity_registry.entities[SELECT_DIALOG_LEVEL_ENTITY] + dialog_level_state = hass.states.get(dialog_level_select.entity_id) + assert dialog_level_state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("result", "option"), + [ + (0, "off"), + (1, "low"), + (2, "medium"), + (3, "high"), + (4, "max"), + ], +) +async def test_select_dialog_level_set( + hass: HomeAssistant, + async_setup_sonos, + soco, + speaker_info: dict[str, str], + result: int, + option: str, +) -> None: + """Test setting dialog level select entity.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = 0 + + await async_setup_sonos() + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: SELECT_DIALOG_LEVEL_ENTITY, ATTR_OPTION: option}, + blocking=True, + ) + + assert soco.dialog_level == result + + +async def test_select_dialog_level_only_arc_ultra( + hass: HomeAssistant, + async_setup_sonos, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], +) -> None: + """Test the dialog level select is only created for Sonos Arc Ultra.""" + + speaker_info["model_name"] = "Sonos S1" + await async_setup_sonos() + + assert SELECT_DIALOG_LEVEL_ENTITY not in entity_registry.entities + + +async def test_select_dialog_level_event( + hass: HomeAssistant, + async_setup_sonos, + soco, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], +) -> None: + """Test dialog level select entity updated by event.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = 0 + + await async_setup_sonos() + + event = create_rendering_control_event(soco) + event.variables[ATTR_DIALOG_LEVEL] = 3 + soco.renderingControl.subscribe.return_value._callback(event) + await hass.async_block_till_done(wait_background_tasks=True) + + dialog_level_select = entity_registry.entities[SELECT_DIALOG_LEVEL_ENTITY] + dialog_level_state = hass.states.get(dialog_level_select.entity_id) + assert dialog_level_state.state == "high" + + +async def test_select_dialog_level_poll( + hass: HomeAssistant, + async_setup_sonos, + soco, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], + freezer: FrozenDateTimeFactory, +) -> None: + """Test entity updated by poll when subscription fails.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = 0 + + await async_setup_sonos() + + soco.dialog_level = 4 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + dialog_level_select = entity_registry.entities[SELECT_DIALOG_LEVEL_ENTITY] + dialog_level_state = hass.states.get(dialog_level_select.entity_id) + assert dialog_level_state.state == "max"