From 40c6b7b467c9c31235ce0bc0d10f079dae7ba257 Mon Sep 17 00:00:00 2001 From: Rhys Saldanha Date: Mon, 3 Aug 2026 00:01:24 +0100 Subject: [PATCH 001/109] Bump anova-wifi to 1.0.1 (#175146) --- homeassistant/components/anova/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/anova/manifest.json b/homeassistant/components/anova/manifest.json index a4119a03abc3..3ca21d35d03c 100644 --- a/homeassistant/components/anova/manifest.json +++ b/homeassistant/components/anova/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["anova_wifi"], - "requirements": ["anova-wifi==0.17.1"] + "requirements": ["anova-wifi==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index b61f572ace20..67d80716b4e9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -525,7 +525,7 @@ androidtvremote2==0.3.1 anel-pwrctrl-homeassistant==0.0.1.dev2 # homeassistant.components.anova -anova-wifi==0.17.1 +anova-wifi==1.0.1 # homeassistant.components.anthemav anthemav==1.4.2 From eb38ba2962a34fededb78c15dfa8d23ed9939991 Mon Sep 17 00:00:00 2001 From: dvdinth <43087214+dvdinth@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:25:07 +0200 Subject: [PATCH 002/109] Bump pyintelliclima to 0.4.0 (#177561) --- homeassistant/components/intelliclima/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/intelliclima/manifest.json b/homeassistant/components/intelliclima/manifest.json index 97c90f65e96e..37c2a3ab0045 100644 --- a/homeassistant/components/intelliclima/manifest.json +++ b/homeassistant/components/intelliclima/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["pyintelliclima==0.3.1"] + "requirements": ["pyintelliclima==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 67d80716b4e9..dc89cc026209 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2267,7 +2267,7 @@ pyimouapi==1.3.0 pyinsteon==1.6.4 # homeassistant.components.intelliclima -pyintelliclima==0.3.1 +pyintelliclima==0.4.0 # homeassistant.components.intesishome pyintesishome==2.0.3 From 4a28fc938b84b768724dfb5632724b7e8bdf74eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 3 Aug 2026 05:07:07 +0200 Subject: [PATCH 003/109] Add Aqara Shutter Switch H2 EU fixture to Matter tests (#178035) --- tests/components/matter/common.py | 1 + .../nodes/aqara_shutter_switch_h2.json | 717 ++++++++++++++++++ .../matter/snapshots/test_binary_sensor.ambr | 51 ++ .../matter/snapshots/test_button.ambr | 153 ++++ .../matter/snapshots/test_cover.ambr | 54 ++ .../matter/snapshots/test_event.ambr | 118 +++ .../matter/snapshots/test_sensor.ambr | 686 +++++++++++++++++ 7 files changed, 1780 insertions(+) create mode 100644 tests/components/matter/fixtures/nodes/aqara_shutter_switch_h2.json diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index 2c80490865f0..9962d4308a0b 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -24,6 +24,7 @@ FIXTURES = [ "aqara_multi_state_p100", "aqara_presence_fp300", "aqara_sensor_w100", + "aqara_shutter_switch_h2", "aqara_thermostat_w500", "aqara_u200", "atios_knx_bridge", diff --git a/tests/components/matter/fixtures/nodes/aqara_shutter_switch_h2.json b/tests/components/matter/fixtures/nodes/aqara_shutter_switch_h2.json new file mode 100644 index 000000000000..a9e894b2206b --- /dev/null +++ b/tests/components/matter/fixtures/nodes/aqara_shutter_switch_h2.json @@ -0,0 +1,717 @@ +{ + "node_id": 193, + "date_commissioned": "2026-07-30T15:08:38.765000", + "last_interview": "2026-07-30T15:08:50.683000", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/65532": 1, + "0/29/4": [ + { + "0": null, + "1": 7, + "2": 0 + } + ], + "0/29/65533": 2, + "0/29/0": [ + { + "0": 1296, + "1": 1 + }, + { + "0": 18, + "1": 1 + }, + { + "0": 22, + "1": 3 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 52, 53, 60, 62, 63, 144, 145, 156], + "0/29/2": [41], + "0/29/3": [1, 2, 3], + "0/29/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/29/65529": [], + "0/29/65528": [], + "0/31/1": [], + "0/31/65533": 2, + "0/31/65532": 0, + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 + } + ], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/31/65529": [], + "0/31/65528": [], + "0/40/11": "20251103", + "0/40/12": "AK189", + "0/40/13": "https://www.aqara.com/en/products.html", + "0/40/14": "Aqara Shutter Switch H2 EU", + "0/40/15": "************", + "0/40/16": false, + "0/40/18": "************", + "0/40/21": 17039616, + "0/40/22": 1, + "0/40/65533": 3, + "0/40/0": 18, + "0/40/1": "Aqara", + "0/40/2": 4447, + "0/40/3": "Aqara Shutter Switch H2 EU", + "0/40/4": 4109, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1000, + "0/40/8": "1.0.0.0", + "0/40/9": 1000, + "0/40/10": "1.0.0.0", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 21, 22, + 65528, 65529, 65531, 65532, 65533 + ], + "0/40/65529": [], + "0/40/65528": [], + "0/42/65533": 1, + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/42/65529": [0], + "0/42/65528": [], + "0/48/65533": 1, + "0/48/65532": 0, + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/48/65529": [0, 2, 4], + "0/48/65528": [1, 3, 5], + "0/49/65532": 2, + "0/49/2": 10, + "0/49/3": 20, + "0/49/9": 10, + "0/49/10": 4, + "0/49/65533": 2, + "0/49/0": 1, + "0/49/1": [ + { + "0": "p0jbsOzJRNw=", + "1": true + } + ], + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "p0jbsOzJRNw=", + "0/49/7": null, + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65528": [1, 5, 7], + "0/51/3": 0, + "0/51/4": 1, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/65533": 2, + "0/51/65532": 0, + "0/51/0": [ + { + "0": "MyHome", + "1": true, + "2": null, + "3": null, + "4": "hgG3BqEgn+8=", + "5": [], + "6": [ + "/VX8YmMnAAEBfYZlMPDvkA==", + "/QANuACgAAAAAAD//gDUEw==", + "/QANuACgAADzd3MoSXUu0Q==", + "/oAAAAAAAACEAbcGoSCf7w==" + ], + "7": 4 + } + ], + "0/51/1": 4, + "0/51/2": 437, + "0/51/8": false, + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/51/65529": [0, 1], + "0/51/65528": [2], + "0/52/65532": 1, + "0/52/0": [ + { + "0": 10, + "1": "Bluetoot", + "3": 1438 + }, + { + "0": 9, + "1": "Bluetoot", + "3": 107 + }, + { + "0": 2, + "1": "OT Stack", + "3": 692 + }, + { + "0": 3, + "1": "sys_evt", + "3": 1968 + }, + { + "0": 1, + "1": "Bluetoot", + "3": 306 + }, + { + "0": 8, + "1": "Tmr Svc", + "3": 801 + }, + { + "0": 7, + "1": "IDLE", + "3": 249 + }, + { + "0": 11, + "1": "app", + "3": 704 + }, + { + "0": 5, + "1": "CHIP", + "3": 217 + } + ], + "0/52/1": 64024, + "0/52/2": 36696, + "0/52/3": 4294960819, + "0/52/65533": 1, + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/52/65529": [0], + "0/52/65528": [], + "0/53/65532": 15, + "0/53/6": 0, + "0/53/14": 1, + "0/53/15": 1, + "0/53/16": 0, + "0/53/17": 0, + "0/53/18": 1, + "0/53/19": 1, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 458, + "0/53/23": 457, + "0/53/24": 1, + "0/53/25": 457, + "0/53/26": 457, + "0/53/27": 1, + "0/53/28": 458, + "0/53/29": 0, + "0/53/30": 0, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 23, + "0/53/34": 0, + "0/53/35": 0, + "0/53/36": 14, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 420, + "0/53/40": 249, + "0/53/41": 100, + "0/53/42": 348, + "0/53/43": 0, + "0/53/44": 0, + "0/53/45": 0, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 0, + "0/53/49": 1, + "0/53/50": 0, + "0/53/51": 71, + "0/53/52": 0, + "0/53/53": 0, + "0/53/54": 0, + "0/53/55": 0, + "0/53/56": 65536, + "0/53/57": 0, + "0/53/58": 0, + "0/53/63": null, + "0/53/64": null, + "0/53/65533": 2, + "0/53/0": 25, + "0/53/1": 4, + "0/53/2": "MyHome", + "0/53/3": 4660, + "0/53/4": 12054125955590472924, + "0/53/5": "QP0ADbgAoAAA", + "0/53/7": [ + { + "0": 7684895770532068360, + "1": 11, + "2": 13312, + "3": 345810, + "4": 17279, + "5": 3, + "6": -73, + "7": -73, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 8265194500311707858, + "1": 18, + "2": 30720, + "3": 60579, + "4": 19423, + "5": 3, + "6": -37, + "7": -36, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 4206032556233211940, + "1": 22, + "2": 53248, + "3": 232648, + "4": 45717, + "5": 2, + "6": -80, + "7": -80, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + } + ], + "0/53/8": [ + { + "0": 7684895770532068360, + "1": 13312, + "2": 13, + "3": 53, + "4": 1, + "5": 3, + "6": 3, + "7": 11, + "8": true, + "9": true + }, + { + "0": 0, + "1": 14336, + "2": 14, + "3": 53, + "4": 3, + "5": 0, + "6": 0, + "7": 11, + "8": true, + "9": false + }, + { + "0": 0, + "1": 25600, + "2": 25, + "3": 53, + "4": 1, + "5": 0, + "6": 0, + "7": 26, + "8": true, + "9": false + }, + { + "0": 0, + "1": 26624, + "2": 26, + "3": 53, + "4": 1, + "5": 0, + "6": 0, + "7": 11, + "8": true, + "9": false + }, + { + "0": 8265194500311707858, + "1": 30720, + "2": 30, + "3": 53, + "4": 1, + "5": 3, + "6": 3, + "7": 18, + "8": true, + "9": true + }, + { + "0": 0, + "1": 44032, + "2": 43, + "3": 53, + "4": 1, + "5": 0, + "6": 0, + "7": 20, + "8": true, + "9": false + }, + { + "0": 0, + "1": 48128, + "2": 47, + "3": 53, + "4": 1, + "5": 0, + "6": 0, + "7": 9, + "8": true, + "9": false + }, + { + "0": 0, + "1": 50176, + "2": 49, + "3": 53, + "4": 1, + "5": 0, + "6": 0, + "7": 18, + "8": true, + "9": false + }, + { + "0": 4206032556233211940, + "1": 53248, + "2": 52, + "3": 53, + "4": 1, + "5": 2, + "6": 3, + "7": 22, + "8": true, + "9": true + }, + { + "0": 0, + "1": 54272, + "2": 53, + "3": 63, + "4": 0, + "5": 0, + "6": 0, + "7": 182, + "8": true, + "9": false + } + ], + "0/53/9": 831392105, + "0/53/10": 68, + "0/53/11": 240, + "0/53/12": 81, + "0/53/13": 49, + "0/53/59": { + "0": 672, + "1": 32911 + }, + "0/53/60": "AB//4A==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/53/65529": [0], + "0/53/65528": [], + "0/60/65533": 1, + "0/60/65532": 0, + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/60/65529": [0, 2], + "0/60/65528": [], + "0/62/65533": 1, + "0/62/0": [ + { + "1": "FTABAQQkAgE3AyQTAhgmBNXVHDAmBVUMykQ3BiQVAiQRwRgkBwEkCAEwCUEE1bzlh41SHAvtqjt1CCJh/L/YPbmjgQm3Jx2yJJ20w4WIKSvzgaew2G2+dTWDiuBb81+6LLqqHnM+IgD7pLyiiTcKNQEoARgkAgE2AwQCBAEYMAQU7oj9S/XvnOObKU8M+mbmT1VMv50wBRRa34d1hFPuca7UFWclq9cFnlPhShgwC0AgqBh37ol1axuvIRjJEf/zy1BkOa+/2wtJ3l/mahFhqXuh7dDav5z1d1x0Bz7puMY7L3RdO/4uWJEHxWEwB+CfGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEAV5qZprx2HWOKSP2iCzsI7A0CHgZVtbwsQ/y4ssETfB9z00733STIN0AfD552Vi1h6fJSeEg0/pA82bJL/y0azcKNQEpARgkAmAwBBRa34d1hFPuca7UFWclq9cFnlPhSjAFFG9oKFV1nAO5dx/+jKvq8o8oKcZbGDALQDD8OnB1NcHRxx387f9wZeFDYf32VZ3ZENQrlWBTQZqEKP+K6XjWmjTWttDEeW1kiNtB1T5ZBIaJUxVdqMuNQx8Y", + "254": 3 + } + ], + "0/62/1": [ + { + "1": "BG1FPaj8U/IZMJ0lkYRWnL7PNr67I7GmbKqrzOPKp92GWZkMLbxHskSpehAxMxW6nepQHWr+Eq9LLp7wy4CB7a4=", + "2": 4939, + "3": 2, + "4": 193, + "5": "ha-freebox", + "254": 3 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/4": [ + "FTABAQAkAgE3AycUQhmZbaIbYjokFQIYJgRWZLcqJAUANwYnFEIZmW2iG2I6JBUCGCQHASQIATAJQQT2AlKGW/kOMjqayzeO0md523/fuhrhGEUU91uQpTiKo0I7wcPpKnmrwfQNPX6g0kEQl+VGaXa3e22lzfu5Tzp0Nwo1ASkBGCQCYDAEFOOMk13ScMKuT2hlaydi1yEJnhTqMAUU44yTXdJwwq5PaGVrJ2LXIQmeFOoYMAtAv2jJd1qd5miXbYesH1XrJ+vgyY0hzGuZ78N6Jw4Cb1oN1sLSpA+PNM0u7+hsEqcSvvn2eSV8EaRR+hg5YQjHDxg=", + "FTABD38O1NiPyscyxScZaN7uECQCATcDJhSoQfl2GCYEIqqfLyYFImy36zcGJhSoQfl2GCQHASQIATAJQQT5WrI2v6EgLRXdxlmZLlXX3rxeBe1C3NN/x9QV0tMVF+gH/FPSyq69dZKuoyskx0UOHcN20wdPffFuqgy/4uiaNwo1ASkBGCQCYDAEFM8XoLF/WKnSeqflSO5TQBQz4ObIMAUUzxegsX9YqdJ6p+VI7lNAFDPg5sgYMAtAHTWpsQPPwqR9gCqBGcDbPu2gusKeVuytcD5v7qK1/UjVr2/WGjMw3SYM10HWKdPTQZa2f3JI3uxv1nFnlcQpDBg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEbUU9qPxT8hkwnSWRhFacvs82vrsjsaZsqqvM48qn3YZZmQwtvEeyRKl6EDEzFbqd6lAdav4Sr0sunvDLgIHtrjcKNQEpARgkAmAwBBRvaChVdZwDuXcf/oyr6vKPKCnGWzAFFG9oKFV1nAO5dx/+jKvq8o8oKcZbGDALQLa3jnnqN0/o6VG8wM4V9FDzrgDfKPd5cn3BBz77K80Jzo/aNotaTNOa6zX//yIvOkBZfGyq1Dh1vXZ4g2NKcXoY" + ], + "0/62/5": 3, + "0/62/65532": 0, + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65528": [1, 3, 5, 8], + "0/63/65533": 2, + "0/63/65532": 0, + "0/63/0": [], + "0/63/1": [], + "0/63/2": 12, + "0/63/3": 3, + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/63/65529": [0, 1, 3, 4], + "0/63/65528": [2, 5], + "0/144/65532": 2, + "0/144/4": null, + "0/144/5": null, + "0/144/65533": 1, + "0/144/0": 2, + "0/144/1": 2, + "0/144/2": [ + { + "0": 5, + "1": false, + "2": 2200, + "3": 48000000, + "4": [ + { + "0": 2200, + "1": 48000000, + "5": 0 + } + ] + }, + { + "0": 2, + "1": false, + "2": 20, + "3": 200000, + "4": [ + { + "0": 20, + "1": 200000, + "5": 0 + } + ] + } + ], + "0/144/8": null, + "0/144/65531": [0, 1, 2, 4, 5, 8, 65528, 65529, 65531, 65532, 65533], + "0/144/65529": [], + "0/144/65528": [], + "0/145/65532": 5, + "0/145/1": { + "0": 0, + "2": 845310100 + }, + "0/145/5": null, + "0/145/65533": 1, + "0/145/0": { + "0": 14, + "1": false, + "2": 0, + "3": 4611686018427387904 + }, + "0/145/65531": [0, 1, 3, 5, 65528, 65529, 65531, 65532, 65533], + "0/145/65529": [], + "0/145/65528": [], + "0/156/65532": 1, + "0/156/65533": 1, + "0/156/65531": [65528, 65529, 65531, 65532, 65533], + "0/156/65529": [], + "0/156/65528": [], + "1/3/65533": 5, + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/3/65529": [0, 64], + "1/3/65528": [], + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/0": 128, + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65528": [0, 1, 2, 3], + "1/29/65532": 1, + "1/29/4": [ + { + "0": null, + "1": 8, + "2": 0 + } + ], + "1/29/65533": 2, + "1/29/0": [ + { + "0": 514, + "1": 3 + } + ], + "1/29/1": [3, 4, 29, 258], + "1/29/2": [], + "1/29/3": [], + "1/29/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "1/29/65529": [], + "1/29/65528": [], + "1/258/65532": 5, + "1/258/11": 10000, + "1/258/14": 10000, + "1/258/26": 0, + "1/258/65533": 5, + "1/258/0": 0, + "1/258/7": 9, + "1/258/10": 0, + "1/258/13": 0, + "1/258/23": 0, + "1/258/65531": [ + 0, 7, 10, 11, 13, 14, 23, 26, 65528, 65529, 65531, 65532, 65533 + ], + "1/258/65529": [0, 1, 2, 4, 5], + "1/258/65528": [], + "2/3/65533": 5, + "2/3/0": 0, + "2/3/1": 2, + "2/3/65532": 0, + "2/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "2/3/65529": [0, 64], + "2/3/65528": [], + "2/29/65532": 1, + "2/29/4": [ + { + "0": null, + "1": 8, + "2": 1 + }, + { + "0": null, + "1": 7, + "2": 1 + } + ], + "2/29/65533": 2, + "2/29/0": [ + { + "0": 15, + "1": 3 + } + ], + "2/29/1": [3, 29, 59], + "2/29/2": [], + "2/29/3": [], + "2/29/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "2/29/65529": [], + "2/29/65528": [], + "2/59/65532": 2, + "2/59/65533": 2, + "2/59/0": 2, + "2/59/1": 0, + "2/59/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "2/59/65529": [], + "2/59/65528": [], + "3/3/65533": 5, + "3/3/0": 0, + "3/3/1": 2, + "3/3/65532": 0, + "3/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "3/3/65529": [0, 64], + "3/3/65528": [], + "3/29/65532": 1, + "3/29/4": [ + { + "0": null, + "1": 8, + "2": 1 + }, + { + "0": null, + "1": 7, + "2": 2 + } + ], + "3/29/65533": 2, + "3/29/0": [ + { + "0": 15, + "1": 3 + } + ], + "3/29/1": [3, 29, 59], + "3/29/2": [], + "3/29/3": [], + "3/29/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "3/29/65529": [], + "3/29/65528": [], + "3/59/65532": 2, + "3/59/65533": 2, + "3/59/0": 2, + "3/59/1": 0, + "3/59/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "3/59/65529": [], + "3/59/65528": [] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index 4e55d95fca8f..e8dd0b3a4279 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -203,6 +203,57 @@ 'state': 'on', }) # --- +# name: test_binary_sensors[aqara_shutter_switch_h2][binary_sensor.aqara_shutter_switch_h2_eu_configuration_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.aqara_shutter_switch_h2_eu_configuration_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Configuration status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Configuration status', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'config_status_operational', + 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-1-WindowCoveringConfigStatusOperational-258-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[aqara_shutter_switch_h2][binary_sensor.aqara_shutter_switch_h2_eu_configuration_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : 'Aqara Shutter Switch H2 EU Configuration status', + }), + 'context': , + 'entity_id': 'binary_sensor.aqara_shutter_switch_h2_eu_configuration_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensors[aqara_u200][binary_sensor.aqara_smart_lock_u200_actuator-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index c2dade92f269..f791eca115b1 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -662,6 +662,159 @@ 'state': 'unknown', }) # --- +# name: test_buttons[aqara_shutter_switch_h2][button.aqara_shutter_switch_h2_eu_identify_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.aqara_shutter_switch_h2_eu_identify_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Identify (1)', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify (1)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[aqara_shutter_switch_h2][button.aqara_shutter_switch_h2_eu_identify_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'identify', + : 'Aqara Shutter Switch H2 EU Identify (1)', + }), + 'context': , + 'entity_id': 'button.aqara_shutter_switch_h2_eu_identify_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[aqara_shutter_switch_h2][button.aqara_shutter_switch_h2_eu_identify_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.aqara_shutter_switch_h2_eu_identify_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Identify (2)', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify (2)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-2-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[aqara_shutter_switch_h2][button.aqara_shutter_switch_h2_eu_identify_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'identify', + : 'Aqara Shutter Switch H2 EU Identify (2)', + }), + 'context': , + 'entity_id': 'button.aqara_shutter_switch_h2_eu_identify_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[aqara_shutter_switch_h2][button.aqara_shutter_switch_h2_eu_identify_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.aqara_shutter_switch_h2_eu_identify_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Identify (3)', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify (3)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-3-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[aqara_shutter_switch_h2][button.aqara_shutter_switch_h2_eu_identify_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'identify', + : 'Aqara Shutter Switch H2 EU Identify (3)', + }), + 'context': , + 'entity_id': 'button.aqara_shutter_switch_h2_eu_identify_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[aqara_thermostat_w500][button.floor_heating_thermostat_identify_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_cover.ambr b/tests/components/matter/snapshots/test_cover.ambr index 1f322b6d5d91..c226415fc0c3 100644 --- a/tests/components/matter/snapshots/test_cover.ambr +++ b/tests/components/matter/snapshots/test_cover.ambr @@ -1,4 +1,58 @@ # serializer version: 1 +# name: test_covers[aqara_shutter_switch_h2][cover.aqara_shutter_switch_h2_eu-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.aqara_shutter_switch_h2_eu', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-1-MatterCoverPositionAwareLift-258-10', + 'unit_of_measurement': None, + }) +# --- +# name: test_covers[aqara_shutter_switch_h2][cover.aqara_shutter_switch_h2_eu-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 0, + : 'shade', + : 'Aqara Shutter Switch H2 EU', + : True, + : , + }), + 'context': , + 'entity_id': 'cover.aqara_shutter_switch_h2_eu', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- # name: test_covers[eve_shutter][cover.eve_shutter_switch_20eci1701-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_event.ambr b/tests/components/matter/snapshots/test_event.ambr index 0ec4e27fff8a..f6e318209c24 100644 --- a/tests/components/matter/snapshots/test_event.ambr +++ b/tests/components/matter/snapshots/test_event.ambr @@ -194,6 +194,124 @@ 'state': 'unknown', }) # --- +# name: test_events[aqara_shutter_switch_h2][event.aqara_shutter_switch_h2_eu_button_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'initial_press', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.aqara_shutter_switch_h2_eu_button_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Button (2)', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button (2)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-2-GenericSwitch-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[aqara_shutter_switch_h2][event.aqara_shutter_switch_h2_eu_button_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'button', + : None, + : list([ + 'initial_press', + ]), + : 'Aqara Shutter Switch H2 EU Button (2)', + }), + 'context': , + 'entity_id': 'event.aqara_shutter_switch_h2_eu_button_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_events[aqara_shutter_switch_h2][event.aqara_shutter_switch_h2_eu_button_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'initial_press', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.aqara_shutter_switch_h2_eu_button_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Button (3)', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button (3)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-3-GenericSwitch-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[aqara_shutter_switch_h2][event.aqara_shutter_switch_h2_eu_button_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'button', + : None, + : list([ + 'initial_press', + ]), + : 'Aqara Shutter Switch H2 EU Button (3)', + }), + 'context': , + 'entity_id': 'event.aqara_shutter_switch_h2_eu_button_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_events[haojai_switch][event.hjmt_6b_button_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 49e92ae6c5e1..fe243c28e258 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -3328,6 +3328,692 @@ 'state': '2025-01-01T13:55:01+00:00', }) # --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_active_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_active_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active current', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_current', + 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-0-ElectricalPowerMeasurementActiveCurrent-144-5', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_active_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Aqara Shutter Switch H2 EU Active current', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_active_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_boot_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'unspecified', + 'power_on_reboot', + 'brown_out_reset', + 'software_watchdog_reset', + 'hardware_watchdog_reset', + 'software_update_completed', + 'software_reset', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_boot_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Boot reason', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Boot reason', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'boot_reason', + 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-0-GeneralDiagnosticsBootReason-51-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_boot_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Aqara Shutter Switch H2 EU Boot reason', + : list([ + 'unspecified', + 'power_on_reboot', + 'brown_out_reset', + 'software_watchdog_reset', + 'hardware_watchdog_reset', + 'software_update_completed', + 'software_reset', + ]), + }), + 'context': , + 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_boot_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'power_on_reboot', + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_current_switch_position_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_current_switch_position_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current switch position (2)', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current switch position (2)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-2-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_current_switch_position_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Aqara Shutter Switch H2 EU Current switch position (2)', + : , + }), + 'context': , + 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_current_switch_position_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_current_switch_position_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_current_switch_position_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current switch position (3)', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current switch position (3)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-3-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_current_switch_position_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Aqara Shutter Switch H2 EU Current switch position (3)', + : , + }), + 'context': , + 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_current_switch_position_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-0-ElectricalEnergyMeasurementCumulativeEnergyImported-145-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Aqara Shutter Switch H2 EU Energy', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_reboot_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_reboot_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reboot count', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reboot count', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reboot_count', + 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-0-GeneralDiagnosticsRebootCount-51-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_reboot_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Aqara Shutter Switch H2 EU Reboot count', + : , + }), + 'context': , + 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_reboot_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_target_opening_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.aqara_shutter_switch_h2_eu_target_opening_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Target opening position', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Target opening position', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'window_covering_target_position', + 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-1-TargetPositionLiftPercent100ths-258-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_target_opening_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Aqara Shutter Switch H2 EU Target opening position', + : , + }), + 'context': , + 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_target_opening_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_thread_channel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.aqara_shutter_switch_h2_eu_thread_channel', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Thread channel', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thread channel', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'thread_channel', + 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-0-ThreadDiagnosticsChannel-53-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_thread_channel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Aqara Shutter Switch H2 EU Thread channel', + }), + 'context': , + 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_thread_channel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25', + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_thread_network_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.aqara_shutter_switch_h2_eu_thread_network_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Thread network name', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thread network name', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'thread_network_name', + 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-0-ThreadDiagnosticsNetworkName-53-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_thread_network_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Aqara Shutter Switch H2 EU Thread network name', + }), + 'context': , + 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_thread_network_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'MyHome', + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_thread_routing_role-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'unspecified', + 'unassigned', + 'sleepy_end_device', + 'end_device', + 'reed', + 'router', + 'leader', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_thread_routing_role', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Thread routing role', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thread routing role', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'thread_routing_role', + 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-0-ThreadDiagnosticsRoutingRole-53-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_thread_routing_role-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Aqara Shutter Switch H2 EU Thread routing role', + : list([ + 'unspecified', + 'unassigned', + 'sleepy_end_device', + 'end_device', + 'reed', + 'router', + 'leader', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_thread_routing_role', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'reed', + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.aqara_shutter_switch_h2_eu_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Uptime', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uptime', + 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-0-GeneralDiagnosticsUpTime-51-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'uptime', + : 'Aqara Shutter Switch H2 EU Uptime', + }), + 'context': , + 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-01T13:52:43+00:00', + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': '00000000000004D2-00000000000000C1-MatterNodeDevice-0-ElectricalPowerMeasurementVoltage-144-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[aqara_shutter_switch_h2][sensor.aqara_shutter_switch_h2_eu_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Aqara Shutter Switch H2 EU Voltage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.aqara_shutter_switch_h2_eu_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[aqara_thermostat_w500][sensor.floor_heating_thermostat_active_current-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ From eccb5cac1ddf246ceb19edcf359febc18c8a4760 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 07:52:30 +0200 Subject: [PATCH 004/109] Fix via_device race in isy994 (#177722) --- homeassistant/components/isy994/__init__.py | 32 +++++++++++++++------ homeassistant/components/isy994/helpers.py | 13 ++++++--- tests/components/isy994/test_init.py | 30 +++++++++++++++++++ 3 files changed, 62 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 938a53e59584..b7cdb9c6f925 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -138,12 +138,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: ) from err isy_data = entry.runtime_data = IsyData() - _categorize_nodes(isy_data, isy.nodes, ignore_identifier, sensor_identifier) + isy_data.root = isy + _async_get_or_create_isy_device_in_registry(hass, entry, isy) + via_device_id = dr.async_get_device_id_by_identifier( + hass, (DOMAIN, isy.uuid), config_entry_id=entry.entry_id + ) + _categorize_nodes( + isy_data, isy.nodes, ignore_identifier, sensor_identifier, via_device_id + ) _categorize_programs(isy_data, isy.programs) # Gather ISY Variables to be added. if isy.variables.children: isy_data.devices[CONF_VARIABLES] = _create_service_device_info( - isy, name=CONF_VARIABLES.title(), unique_id=CONF_VARIABLES + isy, + name=CONF_VARIABLES.title(), + unique_id=CONF_VARIABLES, + via_device_id=via_device_id, ) numbers = isy_data.variables[Platform.NUMBER] for vtype, _, vid in isy.variables.children: @@ -152,7 +162,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: isy.conf[CONFIG_NETWORKING] or isy.conf.get(CONFIG_PORTAL) ) and isy.networking.nobjs: isy_data.devices[CONF_NETWORK] = _create_service_device_info( - isy, name=CONFIG_NETWORKING, unique_id=CONF_NETWORK + isy, + name=CONFIG_NETWORKING, + unique_id=CONF_NETWORK, + via_device_id=via_device_id, ) for resource in isy.networking.nobjs: isy_data.net_resources.append(resource) @@ -160,9 +173,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: # Dump ISY Clock Information. Future: Add ISY as sensor to Hass with attrs LOGGER.debug(repr(isy.clock)) - isy_data.root = isy - _async_get_or_create_isy_device_in_registry(hass, entry, isy) - # Load platforms for the devices in the ISY controller that we support. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -202,9 +212,11 @@ def _async_get_or_create_isy_device_in_registry( ) -def _create_service_device_info(isy: ISY, name: str, unique_id: str) -> DeviceInfo: +def _create_service_device_info( + isy: ISY, name: str, unique_id: str, via_device_id: str | None +) -> DeviceInfo: """Create device info for ISY service devices.""" - return DeviceInfo( + device_info = DeviceInfo( identifiers={ ( DOMAIN, @@ -216,9 +228,11 @@ def _create_service_device_info(isy: ISY, name: str, unique_id: str) -> DeviceIn model=isy.conf[ISY_CONF_MODEL], sw_version=isy.conf[ISY_CONF_FIRMWARE], configuration_url=isy.conn.url, - via_device=(DOMAIN, isy.uuid), entry_type=DeviceEntryType.SERVICE, ) + if via_device_id is not None: + device_info["via_device_id"] = via_device_id + return device_info async def async_unload_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index 9da04c45a254..ebd9a38e1347 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -288,17 +288,18 @@ def _add_backlight_if_supported(isy_data: IsyData, node: Node) -> None: isy_data.aux_properties[Platform.NUMBER].append((node, CMD_BACKLIGHT)) -def _generate_device_info(node: Node) -> DeviceInfo: +def _generate_device_info(node: Node, via_device_id: str | None) -> DeviceInfo: """Generate the device info for a root node device.""" isy = node.isy device_info = DeviceInfo( identifiers={(DOMAIN, f"{isy.uuid}_{node.address}")}, manufacturer=node.protocol.title(), name=node.name, - via_device=(DOMAIN, isy.uuid), configuration_url=isy.conn.url, suggested_area=node.folder, ) + if via_device_id is not None: + device_info["via_device_id"] = via_device_id # ISYv5 Device Types can provide model and manufacturer model: str = str(node.address).rpartition(" ")[0] or node.address @@ -328,7 +329,11 @@ def _generate_device_info(node: Node) -> DeviceInfo: def _categorize_nodes( - isy_data: IsyData, nodes: Nodes, ignore_identifier: str, sensor_identifier: str + isy_data: IsyData, + nodes: Nodes, + ignore_identifier: str, + sensor_identifier: str, + via_device_id: str | None, ) -> None: """Sort the nodes to their proper platforms.""" for path, node in nodes: @@ -339,7 +344,7 @@ def _categorize_nodes( if hasattr(node, "parent_node") and node.parent_node is None: # This is a physical device / parent node - isy_data.devices[node.address] = _generate_device_info(node) + isy_data.devices[node.address] = _generate_device_info(node, via_device_id) isy_data.root_nodes[Platform.BUTTON].append(node) # Any parent node can have communication errors: isy_data.aux_properties[Platform.SENSOR].append((node, PROP_COMMS_ERROR)) diff --git a/tests/components/isy994/test_init.py b/tests/components/isy994/test_init.py index 2cd003ce5d83..59d724cb43fc 100644 --- a/tests/components/isy994/test_init.py +++ b/tests/components/isy994/test_init.py @@ -1,5 +1,7 @@ """Test the Universal Devices ISY/IoX integration init.""" +from collections.abc import Callable +from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -8,6 +10,7 @@ from homeassistant.components.isy994.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -71,3 +74,30 @@ async def test_setup_forwards_verify_ssl_to_pyisy( assert entry.state is ConfigEntryState.LOADED assert isy_constructor.call_args.kwargs["verify_ssl"] is verify_ssl + + +async def test_node_device_linked_to_isy_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_isy: MagicMock, + mock_node: Callable[..., Any], +) -> None: + """Test a root node's device is linked to the ISY device via via_device_id.""" + mock_config_entry.add_to_hass(hass) + + node = mock_node(mock_isy, "22 22 22 1", "Test Node", "GenericNode") + mock_isy.nodes.__iter__.return_value = [("Test Node", node)] + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + isy_device = device_registry.async_get_device_by_identifier( + (DOMAIN, mock_isy.uuid), mock_config_entry.entry_id + ) + node_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{mock_isy.uuid}_{node.address}"), mock_config_entry.entry_id + ) + assert isy_device is not None + assert node_device is not None + assert node_device.via_device_id == isy_device.id From 00fa395ee5902e5dfc0b9f40f0138d375d0f19ce Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 07:52:44 +0200 Subject: [PATCH 005/109] Fix via_device race in lookin (#177728) --- homeassistant/components/lookin/__init__.py | 8 ++ homeassistant/components/lookin/entity.py | 18 ++++- tests/components/lookin/test_init.py | 83 +++++++++++++++++++++ 3 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 tests/components/lookin/test_init.py diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index 73ecf37efd41..8594a0b20ca5 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -32,6 +32,7 @@ from .const import ( TYPE_TO_PLATFORM, ) from .coordinator import LookinDataUpdateCoordinator, LookinPushCoordinator +from .entity import _lookin_device_to_device_info from .models import LookinConfigEntry, LookinData LOGGER = logging.getLogger(__name__) @@ -182,6 +183,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: LookinConfigEntry) -> bo device_coordinators=device_coordinators, ) + # Register the lookin device so entities on the controlled devices can link + # to it via via_device_id, regardless of which platform is set up first. + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + **_lookin_device_to_device_info(lookin_device, host), + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/lookin/entity.py b/homeassistant/components/lookin/entity.py index 4a7e53351e85..d4460612fd42 100644 --- a/homeassistant/components/lookin/entity.py +++ b/homeassistant/components/lookin/entity.py @@ -14,6 +14,7 @@ from aiolookin import ( ) from aiolookin.models import Device, UDPCommandType, UDPEvent +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -37,15 +38,17 @@ def _lookin_device_to_device_info(lookin_device: Device, host: str) -> DeviceInf def _lookin_controlled_device_to_device_info( - lookin_device: Device, uuid: str, device: Climate | Remote, host: str + uuid: str, device: Climate | Remote, host: str, via_device_id: str | None ) -> DeviceInfo: - return DeviceInfo( + device_info = DeviceInfo( identifiers={(DOMAIN, uuid)}, name=device.name, model=device.device_type, - via_device=(DOMAIN, lookin_device.id), configuration_url=f"http://{host}/data/{uuid}", ) + if via_device_id is not None: + device_info["via_device_id"] = via_device_id + return device_info class LookinDeviceMixIn: @@ -113,7 +116,14 @@ class LookinCoordinatorEntity( self._set_lookin_device_attrs(lookin_data) self._set_lookin_entity_attrs(uuid, device, lookin_data) self._attr_device_info = _lookin_controlled_device_to_device_info( - self._lookin_device, uuid, device, lookin_data.host + uuid, + device, + lookin_data.host, + dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, self._lookin_device.id), + config_entry_id=coordinator.config_entry.entry_id, + ), ) self._attr_unique_id = uuid self._attr_name = device.name diff --git a/tests/components/lookin/test_init.py b/tests/components/lookin/test_init.py new file mode 100644 index 000000000000..7d3dd7bd6abb --- /dev/null +++ b/tests/components/lookin/test_init.py @@ -0,0 +1,83 @@ +"""Tests for the lookin integration setup.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.components.lookin.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import DEVICE_ID, DEVICE_NAME, IP_ADDRESS, MODULE + +from tests.common import MockConfigEntry + +MEDIA_UUID = "9999" + + +def _mocked_device() -> MagicMock: + device = MagicMock() + device.name = DEVICE_NAME + device.id = DEVICE_ID + # A model < 2 device has no meteo sensor, so the lookin device is only + # registered by async_setup_entry, not by a sibling sensor entity. + device.model = 1 + device.firmware = "1.2.3" + return device + + +def _mocked_remote() -> MagicMock: + remote = MagicMock() + remote.name = "Living Room TV" + remote.device_type = "TV" + remote.status = "1000" + remote.functions = [] + return remote + + +def _mocked_protocol(device: MagicMock, remote: MagicMock) -> MagicMock: + protocol = MagicMock() + protocol.get_info = AsyncMock(return_value=device) + protocol.get_devices = AsyncMock(return_value=[{"Type": "01", "UUID": MEDIA_UUID}]) + protocol.get_remote = AsyncMock(return_value=remote) + protocol.get_media_sources = AsyncMock(return_value=[]) + protocol.send_command = AsyncMock() + return protocol + + +async def test_controlled_device_links_to_lookin_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test a controlled device links to the lookin device via via_device_id.""" + device = _mocked_device() + remote = _mocked_remote() + protocol = _mocked_protocol(device, remote) + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=DEVICE_ID + ) + entry.add_to_hass(hass) + + subscriptions = MagicMock() + subscriptions.subscribe_event = MagicMock(return_value=MagicMock()) + + with ( + patch(f"{MODULE}.LookInHttpProtocol", return_value=protocol), + patch(f"{MODULE}.LookinUDPSubscriptions", return_value=subscriptions), + patch(f"{MODULE}.start_lookin_udp", AsyncMock(return_value=MagicMock())), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + lookin_device = device_registry.async_get_device_by_identifier( + (DOMAIN, DEVICE_ID), entry.entry_id + ) + assert lookin_device is not None + + controlled_device = device_registry.async_get_device_by_identifier( + (DOMAIN, MEDIA_UUID), entry.entry_id + ) + assert controlled_device is not None + assert controlled_device.via_device_id == lookin_device.id From 7f99b2a3b052b0fdc260835fe19d3e3d41d1091e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 07:53:22 +0200 Subject: [PATCH 006/109] Fix via_device race in enphase_envoy (#177715) --- .../components/enphase_envoy/__init__.py | 24 ++++++++++++-- .../components/enphase_envoy/binary_sensor.py | 25 +++++++++++--- .../components/enphase_envoy/entity.py | 13 ++++++-- .../components/enphase_envoy/number.py | 13 ++++++-- .../components/enphase_envoy/select.py | 13 ++++++-- .../components/enphase_envoy/sensor.py | 31 ++++++++++++++--- .../components/enphase_envoy/switch.py | 19 +++++++++-- tests/components/enphase_envoy/test_init.py | 33 +++++++++++++++++++ 8 files changed, 151 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index 8a1650a4f7c3..90eeaf559323 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -57,16 +57,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> b serial_number=envoy.serial_number, ) + envoy_data = coordinator.envoy.data + # register the ACB aggregate device before the individual batteries reference # it as via_device, so they nest under it in the device hierarchy - if (envoy_data := coordinator.envoy.data) and envoy_data.acb_inventory: + if envoy_data and envoy_data.acb_inventory: device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, f"{envoy.serial_number}_acb")}, manufacturer="Enphase", model="ACB", name=f"ACB {envoy.serial_number}", - via_device=(DOMAIN, envoy.serial_number), + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, envoy.serial_number), config_entry_id=entry.entry_id + ), + ) + + # register the Enpower device before the dry contact relays reference it as + # via_device, so they nest under it in the device hierarchy + if envoy_data and (enpower := envoy_data.enpower): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, enpower.serial_number)}, + manufacturer="Enphase", + model="Enpower", + name=f"Enpower {enpower.serial_number}", + sw_version=str(enpower.firmware_version), + serial_number=enpower.serial_number, + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, envoy.serial_number), config_entry_id=entry.entry_id + ), ) entry.runtime_data = coordinator diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py index 4560a0e83984..ae0de3cc7484 100644 --- a/homeassistant/components/enphase_envoy/binary_sensor.py +++ b/homeassistant/components/enphase_envoy/binary_sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -209,7 +210,11 @@ class EnvoyEnchargeBinarySensorEntity(EnvoyBaseBinarySensorEntity): model="Encharge", name=f"Encharge {serial_number}", sw_version=str(encharge_inventory[self._serial_number].firmware_version), - via_device=(DOMAIN, self.envoy_serial_num), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, self.envoy_serial_num), + config_entry_id=coordinator.config_entry.entry_id, + ), serial_number=serial_number, ) @@ -243,7 +248,11 @@ class EnvoyEnpowerBinarySensorEntity(EnvoyBaseBinarySensorEntity): model="Enpower", name=f"Enpower {enpower.serial_number}", sw_version=str(enpower.firmware_version), - via_device=(DOMAIN, self.envoy_serial_num), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, self.envoy_serial_num), + config_entry_id=coordinator.config_entry.entry_id, + ), serial_number=enpower.serial_number, ) @@ -277,7 +286,11 @@ class EnvoyCollarBinarySensorEntity(EnvoyBaseBinarySensorEntity): model="IQ Meter Collar", name=f"Collar {collar_data.serial_number}", sw_version=str(collar_data.firmware_version), - via_device=(DOMAIN, self.envoy_serial_num), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, self.envoy_serial_num), + config_entry_id=coordinator.config_entry.entry_id, + ), serial_number=collar_data.serial_number, ) @@ -311,7 +324,11 @@ class EnvoyC6CCBinarySensorEntity(EnvoyBaseBinarySensorEntity): model="C6 COMBINER CONTROLLER", name=f"C6 Combiner {c6cc_data.serial_number}", sw_version=str(c6cc_data.firmware_version), - via_device=(DOMAIN, self.envoy_serial_num), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, self.envoy_serial_num), + config_entry_id=coordinator.config_entry.entry_id, + ), serial_number=c6cc_data.serial_number, ) diff --git a/homeassistant/components/enphase_envoy/entity.py b/homeassistant/components/enphase_envoy/entity.py index ed5d10c33792..4e5d066df802 100644 --- a/homeassistant/components/enphase_envoy/entity.py +++ b/homeassistant/components/enphase_envoy/entity.py @@ -8,6 +8,7 @@ from pyenphase import EnvoyData from pyenphase.exceptions import EnvoyError from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -85,7 +86,11 @@ class EnvoyACBAggregateEntity(EnvoyBaseEntity): manufacturer="Enphase", model="ACB", name=f"ACB {self.envoy_serial_num}", - via_device=(DOMAIN, self.envoy_serial_num), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, self.envoy_serial_num), + config_entry_id=coordinator.config_entry.entry_id, + ), ) @@ -117,6 +122,10 @@ class EnvoyACBBatteryEntity(EnvoyBaseEntity): manufacturer="Enphase", model="AC Battery", name=f"AC Battery {serial_number}", - via_device=(DOMAIN, f"{self.envoy_serial_num}_acb"), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, f"{self.envoy_serial_num}_acb"), + config_entry_id=coordinator.config_entry.entry_id, + ), serial_number=serial_number, ) diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py index 803e850d66a0..b39614eeec60 100644 --- a/homeassistant/components/enphase_envoy/number.py +++ b/homeassistant/components/enphase_envoy/number.py @@ -16,6 +16,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -120,7 +121,11 @@ class EnvoyRelayNumberEntity(EnvoyBaseEntity, NumberEntity): model="Dry contact relay", name=self.data.dry_contact_settings[relay_id].load_name, sw_version=str(enpower.firmware_version), - via_device=(DOMAIN, serial_number), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, serial_number), + config_entry_id=coordinator.config_entry.entry_id, + ), ) @property @@ -164,7 +169,11 @@ class EnvoyStorageSettingsNumberEntity(EnvoyBaseEntity, NumberEntity): model="Enpower", name=f"Enpower {self._serial_number}", sw_version=str(enpower.firmware_version), - via_device=(DOMAIN, self.envoy_serial_num), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, self.envoy_serial_num), + config_entry_id=coordinator.config_entry.entry_id, + ), serial_number=self._serial_number, ) else: diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 30bd57f2972f..904c81e8b60f 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -12,6 +12,7 @@ from pyenphase.models.tariff import EnvoyStorageMode, EnvoyStorageSettings from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -187,7 +188,11 @@ class EnvoyRelaySelectEntity(EnvoyBaseEntity, SelectEntity): model="Dry contact relay", name=self.relay.load_name, sw_version=str(enpower.firmware_version), - via_device=(DOMAIN, serial_number), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, serial_number), + config_entry_id=coordinator.config_entry.entry_id, + ), ) @property @@ -232,7 +237,11 @@ class EnvoyStorageSettingsSelectEntity(EnvoyBaseEntity, SelectEntity): model="Enpower", name=f"Enpower {self._serial_number}", sw_version=str(enpower.firmware_version), - via_device=(DOMAIN, self.envoy_serial_num), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, self.envoy_serial_num), + config_entry_id=coordinator.config_entry.entry_id, + ), serial_number=self._serial_number, ) else: diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 649972dc5ebd..baf61e471153 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -50,6 +50,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -1347,7 +1348,11 @@ class EnvoyInverterEntity(EnvoySensorBaseEntity): name=f"Inverter {serial_number}", manufacturer="Enphase", model="Inverter", - via_device=(DOMAIN, self.envoy_serial_num), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, self.envoy_serial_num), + config_entry_id=coordinator.config_entry.entry_id, + ), serial_number=serial_number, ) @@ -1392,7 +1397,11 @@ class EnvoyEnchargeEntity(EnvoySensorBaseEntity): model="Encharge", name=f"Encharge {serial_number}", sw_version=str(encharge_inventory[self._serial_number].firmware_version), - via_device=(DOMAIN, self.envoy_serial_num), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, self.envoy_serial_num), + config_entry_id=coordinator.config_entry.entry_id, + ), serial_number=serial_number, ) @@ -1460,7 +1469,11 @@ class EnvoyEnpowerEntity(EnvoySensorBaseEntity): model="Enpower", name=f"Enpower {enpower_data.serial_number}", sw_version=str(enpower_data.firmware_version), - via_device=(DOMAIN, self.envoy_serial_num), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, self.envoy_serial_num), + config_entry_id=coordinator.config_entry.entry_id, + ), serial_number=enpower_data.serial_number, ) @@ -1565,7 +1578,11 @@ class EnvoyCollarEntity(EnvoySensorBaseEntity): model="IQ Meter Collar", name=f"Collar {collar_data.serial_number}", sw_version=str(collar_data.firmware_version), - via_device=(DOMAIN, self.envoy_serial_num), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, self.envoy_serial_num), + config_entry_id=coordinator.config_entry.entry_id, + ), serial_number=collar_data.serial_number, ) @@ -1599,7 +1616,11 @@ class EnvoyC6CCEntity(EnvoySensorBaseEntity): model="C6 COMBINER CONTROLLER", name=f"C6 Combiner {c6cc_data.serial_number}", sw_version=str(c6cc_data.firmware_version), - via_device=(DOMAIN, self.envoy_serial_num), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, self.envoy_serial_num), + config_entry_id=coordinator.config_entry.entry_id, + ), serial_number=c6cc_data.serial_number, ) diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index adc6c7de04d9..797e32ef33eb 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -11,6 +11,7 @@ from pyenphase.models.tariff import EnvoyStorageSettings from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -135,7 +136,11 @@ class EnvoyEnpowerSwitchEntity(EnvoyBaseEntity, SwitchEntity): model="Enpower", name=f"Enpower {self._serial_number}", sw_version=str(enpower.firmware_version), - via_device=(DOMAIN, self.envoy_serial_num), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, self.envoy_serial_num), + config_entry_id=coordinator.config_entry.entry_id, + ), serial_number=self._serial_number, ) @@ -189,7 +194,11 @@ class EnvoyDryContactSwitchEntity(EnvoyBaseEntity, SwitchEntity): model="Dry contact relay", name=relay.load_name, sw_version=str(enpower.firmware_version), - via_device=(DOMAIN, enpower.serial_number), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, enpower.serial_number), + config_entry_id=coordinator.config_entry.entry_id, + ), ) @property @@ -239,7 +248,11 @@ class EnvoyStorageSettingsSwitchEntity(EnvoyBaseEntity, SwitchEntity): model="Enpower", name=f"Enpower {self._serial_number}", sw_version=str(enpower.firmware_version), - via_device=(DOMAIN, self.envoy_serial_num), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, self.envoy_serial_num), + config_entry_id=coordinator.config_entry.entry_id, + ), serial_number=self._serial_number, ) else: diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index 2df119475e01..ed075aa58b6c 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -475,6 +475,39 @@ async def test_remove_config_entry_device( assert response["success"] +@pytest.mark.parametrize( + ("mock_envoy"), + [ + "envoy_metered_batt_relay", + ], + indirect=["mock_envoy"], +) +@pytest.mark.usefixtures("mock_envoy") +async def test_dry_contact_relay_via_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the via_device chain relay -> Enpower -> Envoy is set up correctly.""" + await setup_integration(hass, config_entry) + + envoy_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "1234"), config_entry.entry_id + ) + assert envoy_device is not None + enpower_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "654321"), config_entry.entry_id + ) + assert enpower_device is not None + relay_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "NC1"), config_entry.entry_id + ) + assert relay_device is not None + + assert enpower_device.via_device_id == envoy_device.id + assert relay_device.via_device_id == enpower_device.id + + @pytest.mark.parametrize("mock_envoy", ["envoy_acb_batt"], indirect=["mock_envoy"]) async def test_remove_config_entry_device_acb( hass: HomeAssistant, From db6aba77ae1c7e4480f17a89406c12dfc65fdb12 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 09:21:02 +0200 Subject: [PATCH 007/109] Improve regression test for ouman_eh_800 via_device linking (#177927) --- tests/components/ouman_eh_800/test_init.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/components/ouman_eh_800/test_init.py b/tests/components/ouman_eh_800/test_init.py index 3f874d0db895..5af405bd63e4 100644 --- a/tests/components/ouman_eh_800/test_init.py +++ b/tests/components/ouman_eh_800/test_init.py @@ -28,12 +28,14 @@ async def test_sub_devices_link_to_main_device( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) entry_id = mock_config_entry.entry_id - main_device = device_registry.async_get_device(identifiers={(DOMAIN, entry_id)}) + main_device = device_registry.async_get_device_by_identifier( + (DOMAIN, entry_id), config_entry_id=entry_id + ) assert main_device is not None for sub_device in (OumanDevice.L1, OumanDevice.L2): - device = device_registry.async_get_device( - identifiers={(DOMAIN, f"{entry_id}_{sub_device}")} + device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{entry_id}_{sub_device}"), config_entry_id=entry_id ) assert device is not None assert device.via_device_id == main_device.id From 92ef9ab02d236c0f6a2dc96ecb7233688acdfe45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frederic=20Mari=C3=ABn?= Date: Mon, 3 Aug 2026 09:21:20 +0200 Subject: [PATCH 008/109] Fix Risco naive `datetime.now()` (#178040) --- homeassistant/components/risco/services.py | 5 ++--- tests/components/risco/test_services.py | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/risco/services.py b/homeassistant/components/risco/services.py index ebbcf4ba0cfd..dc45511c7b73 100644 --- a/homeassistant/components/risco/services.py +++ b/homeassistant/components/risco/services.py @@ -1,13 +1,12 @@ """Services for Risco integration.""" -from datetime import datetime - import voluptuous as vol from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_TIME from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, service +from homeassistant.util import dt as dt_util from .const import DOMAIN, SERVICE_SET_TIME from .models import RiscoConfigEntry @@ -31,7 +30,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: time_to_send = time if time is None: - time_to_send = datetime.now() # pylint: disable=home-assistant-enforce-naive-now + time_to_send = dt_util.now() await local_data.system.set_time(time_to_send) diff --git a/tests/components/risco/test_services.py b/tests/components/risco/test_services.py index 0353aa2f65d9..eb5ac59dae57 100644 --- a/tests/components/risco/test_services.py +++ b/tests/components/risco/test_services.py @@ -1,6 +1,5 @@ """Tests for the Risco services.""" -from datetime import datetime from unittest.mock import patch import pytest @@ -11,6 +10,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_TIME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from homeassistant.util import dt as dt_util from .conftest import TEST_CLOUD_CONFIG @@ -23,7 +23,7 @@ async def test_set_time_service( """Test the set_time service.""" with patch("homeassistant.components.risco.RiscoLocal.set_time") as mock: time_str = "2025-02-21T12:00:00" - time = datetime.fromisoformat(time_str) + time = dt_util.parse_datetime(time_str) data = { ATTR_CONFIG_ENTRY_ID: local_config_entry.entry_id, ATTR_TIME: time_str, @@ -50,7 +50,7 @@ async def test_set_time_service_with_no_time( DOMAIN, SERVICE_SET_TIME, service_data=data, blocking=True ) - mock_set_time.assert_called_once_with(datetime.now()) # pylint: disable=home-assistant-enforce-naive-now + mock_set_time.assert_called_once_with(dt_util.now()) async def test_set_time_service_with_invalid_entry( From 9db545ec7e47aae6845dde2985fb8a92c49300ca Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 09:22:04 +0200 Subject: [PATCH 009/109] Migrate zwave_js to call async_remove_device (#178060) --- homeassistant/components/zwave_js/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 340caab8481c..bd6930be8c21 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -676,10 +676,7 @@ class ControllerEvents: ): # If a device entry is registered with the node ID based identifiers, # just remove the device entry with the DSK identifier. - self.dev_reg.async_update_device( - pre_provisioned_device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + self.dev_reg.async_remove_device(pre_provisioned_device.id) else: # Add the node ID based identifiers to the device entry # with the DSK identifier and remove the DSK identifier. From d3580269baa3bf7ad660821d5982a7bdc4540ea2 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 3 Aug 2026 10:42:51 +0200 Subject: [PATCH 010/109] Add missing codeowner to Midea (#178063) --- CODEOWNERS | 4 ++-- homeassistant/components/midea/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 6111b3cfa3e2..28bd9e6dbefd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1139,8 +1139,8 @@ CLAUDE.md @home-assistant/core /tests/components/metoffice/ @MrHarcombe @avee87 /homeassistant/components/microbees/ @microBeesTech /tests/components/microbees/ @microBeesTech -/homeassistant/components/midea/ @chemelli74 @rokam @wuwentao -/tests/components/midea/ @chemelli74 @rokam @wuwentao +/homeassistant/components/midea/ @chemelli74 @rokam @wuwentao @caibinqing +/tests/components/midea/ @chemelli74 @rokam @wuwentao @caibinqing /homeassistant/components/miele/ @astrandb /tests/components/miele/ @astrandb /homeassistant/components/mikrotik/ @engrbm87 @chemelli74 diff --git a/homeassistant/components/midea/manifest.json b/homeassistant/components/midea/manifest.json index a089db203cff..3e374d45e7c8 100644 --- a/homeassistant/components/midea/manifest.json +++ b/homeassistant/components/midea/manifest.json @@ -1,7 +1,7 @@ { "domain": "midea", "name": "Midea", - "codeowners": ["@chemelli74", "@rokam", "@wuwentao"], + "codeowners": ["@chemelli74", "@rokam", "@wuwentao", "@caibinqing"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/midea", "integration_type": "device", From e01e308b15230b096372e74fd27ec3cfd716be02 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 10:52:25 +0200 Subject: [PATCH 011/109] Migrate mqtt to call async_remove_device (#178055) --- homeassistant/components/mqtt/util.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index db35e8351f0c..557301ea9f98 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -443,12 +443,13 @@ async def async_cleanup_device_registry( device_id and device_id not in device_registry.deleted_devices and config_entry_id + and (device := device_registry.async_get(device_id)) is not None + # Only remove the device if it is owned by the MQTT config entry + and device.config_entry_id == config_entry_id and not er.async_entries_for_device( entity_registry, device_id, include_disabled_entities=False ) and not await device_trigger.async_get_triggers(hass, device_id) and not tag.async_has_tags(hass, device_id) ): - device_registry.async_update_device( - device_id, remove_config_entry_id=config_entry_id - ) + device_registry.async_remove_device(device_id) From 4dd9fef63dde756bfb078d33c882eb5eb1ebd557 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 3 Aug 2026 11:11:05 +0200 Subject: [PATCH 012/109] Add 100% coverage to utils for Mikrotik (#177878) --- tests/components/mikrotik/conftest.py | 8 +- tests/components/mikrotik/test_init.py | 169 +++++++++++++++++++++++-- 2 files changed, 163 insertions(+), 14 deletions(-) diff --git a/tests/components/mikrotik/conftest.py b/tests/components/mikrotik/conftest.py index 6a8b54ec7d93..d98ed2fa7ab3 100644 --- a/tests/components/mikrotik/conftest.py +++ b/tests/components/mikrotik/conftest.py @@ -1,15 +1,19 @@ """Mikrotik test configuration.""" -from collections.abc import Generator +from collections.abc import Callable, Generator from unittest.mock import MagicMock, patch import pytest from . import create_mock_config_entry +from tests.common import MockConfigEntry + +type MockConfigEntryFactory = Callable[..., MockConfigEntry] + @pytest.fixture -def mock_config_entry(): +def mock_config_entry() -> MockConfigEntryFactory: """Create Mikrotik config entries with optional overrides.""" return create_mock_config_entry diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py index baae28984058..3f7c67275325 100644 --- a/tests/components/mikrotik/test_init.py +++ b/tests/components/mikrotik/test_init.py @@ -1,57 +1,181 @@ """Test Mikrotik setup process.""" +from collections.abc import Callable from datetime import timedelta +from typing import Any from unittest.mock import MagicMock, patch from librouteros.exceptions import ConnectionClosed, LibRouterosError +import pytest -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.mikrotik.const import ( + IDENTITY, + MIKROTIK_SERVICES, + ROUTERBOARD, +) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.util import dt as dt_util from . import setup_integration +from .conftest import MockConfigEntryFactory from tests.common import async_fire_time_changed +_BASE_COMMAND_RESPONSES: dict[str, list[dict[str, Any]]] = { + MIKROTIK_SERVICES[IDENTITY]: [{"name": "Mikrotik"}] +} -async def test_successful_config_entry(hass: HomeAssistant, mock_config_entry) -> None: + +def _command_side_effect( + error_cmd: str, error: Exception +) -> Callable[..., list[dict[str, Any]]]: + """Return minimal hub responses, except for one command that raises.""" + + def side_effect(cmd: str, **params: Any) -> list[dict[str, Any]]: + if cmd == error_cmd: + raise error + return _BASE_COMMAND_RESPONSES.get(cmd, []) + + return side_effect + + +async def test_successful_config_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntryFactory +) -> None: """Test config entry successful setup.""" entry = mock_config_entry() await setup_integration(hass, entry, command_responses={}) assert entry.state is ConfigEntryState.LOADED +@pytest.mark.parametrize( + "error", + [ConnectionClosed(), OSError(), TimeoutError()], + ids=["connection_closed", "os_error", "timeout_error"], +) async def test_hub_connection_error( - hass: HomeAssistant, mock_api: MagicMock, mock_config_entry + hass: HomeAssistant, + mock_api: MagicMock, + mock_config_entry: MockConfigEntryFactory, + error: Exception, ) -> None: - """Test setup fails due to connection error.""" + """Test setup retries when the hub can't be reached after connecting.""" entry = mock_config_entry() entry.add_to_hass(hass) - mock_api.side_effect = ConnectionClosed + mock_api.side_effect = error await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_hub_authentication_error( - hass: HomeAssistant, mock_api: MagicMock, mock_config_entry +@pytest.mark.parametrize( + "error", + [ + LibRouterosError("no route to host"), + OSError(), + TimeoutError(), + ConnectionClosed(), + ], + ids=["cannot_connect", "os_error", "timeout_error", "connection_closed"], +) +async def test_hub_connect_error_retries_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntryFactory, + error: Exception, ) -> None: - """Test setup fails due to authentication error.""" + """Test setup retries when the initial connection to the hub fails.""" entry = mock_config_entry() entry.add_to_hass(hass) - mock_api.side_effect = LibRouterosError("invalid user name or password") + with patch("librouteros.connect", side_effect=error): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_hub_login_error_starts_reauth( + hass: HomeAssistant, mock_config_entry: MockConfigEntryFactory +) -> None: + """Test setup starts a reauth flow when the hub rejects the credentials.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + with patch( + "librouteros.connect", + side_effect=LibRouterosError("invalid user name or password"), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_ERROR + assert any(entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + +async def test_optional_command_error_is_suppressed( + hass: HomeAssistant, mock_api: MagicMock, mock_config_entry: MockConfigEntryFactory +) -> None: + """Test setup succeeds when an optional command isn't supported by the hub.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + mock_api.side_effect = _command_side_effect( + MIKROTIK_SERVICES[ROUTERBOARD], LibRouterosError("no such command prefix") + ) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + +async def test_optional_command_unexpected_error_fails_setup( + hass: HomeAssistant, mock_api: MagicMock, mock_config_entry: MockConfigEntryFactory +) -> None: + """Test setup fails when an optional command raises an unexpected error.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + mock_api.side_effect = _command_side_effect( + MIKROTIK_SERVICES[ROUTERBOARD], LibRouterosError("permission denied") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.SETUP_ERROR +async def test_required_command_error_fails_setup( + hass: HomeAssistant, mock_api: MagicMock, mock_config_entry: MockConfigEntryFactory +) -> None: + """Test setup fails on a required command even with a suppressible message.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + mock_api.side_effect = _command_side_effect( + MIKROTIK_SERVICES[IDENTITY], LibRouterosError("no such command prefix") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_ERROR + + +@pytest.mark.parametrize( + "error", + [OSError(), TimeoutError(), ConnectionClosed()], + ids=["os_error", "timeout_error", "connection_closed"], +) async def test_connection_lost_during_refresh_raises_update_failed( - hass: HomeAssistant, mock_config_entry + hass: HomeAssistant, + mock_config_entry: MockConfigEntryFactory, + error: Exception, ) -> None: """Test a lost connection during a scheduled refresh is treated as UpdateFailed. @@ -68,7 +192,7 @@ async def test_connection_lost_during_refresh_raises_update_failed( with patch.object( entry.runtime_data.api, "command", - side_effect=OSError(113, "Host is unreachable"), + side_effect=error, ): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) await hass.async_block_till_done(wait_background_tasks=True) @@ -78,7 +202,28 @@ async def test_connection_lost_during_refresh_raises_update_failed( assert isinstance(coordinator.last_exception, UpdateFailed) -async def test_unload_entry(hass: HomeAssistant, mock_config_entry) -> None: +async def test_hub_reconnect_error_during_refresh_raises_update_failed( + hass: HomeAssistant, mock_config_entry: MockConfigEntryFactory +) -> None: + """Test a failed reconnect during a scheduled refresh is treated as UpdateFailed.""" + entry = mock_config_entry() + await setup_integration(hass, entry, command_responses={}) + assert entry.state is ConfigEntryState.LOADED + + coordinator = entry.runtime_data + + with patch("librouteros.connect", side_effect=OSError()): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done(wait_background_tasks=True) + + assert entry.state is ConfigEntryState.LOADED + assert coordinator.last_update_success is False + assert isinstance(coordinator.last_exception, UpdateFailed) + + +async def test_unload_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntryFactory +) -> None: """Test unloading an entry.""" entry = mock_config_entry() await setup_integration(hass, entry, command_responses={}) From 7cc56108d05d2a4f8f901f9581997bec043e7a6b Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 3 Aug 2026 11:22:21 +0200 Subject: [PATCH 013/109] Update knx-frontend to 2026.8.2.133643 (#178044) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 4f4def15dff0..bf5194e1a7bb 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.18.0", "xknxproject==3.10.0", - "knx-frontend==2026.7.23.145751", + "knx-frontend==2026.8.2.133643", "knx-telegram-store[sqlite,postgres]==0.11.2" ], "single_config_entry": true diff --git a/requirements_all.txt b/requirements_all.txt index dc89cc026209..a23eb646e435 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1435,7 +1435,7 @@ kiwiki-client==0.1.2 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2026.7.23.145751 +knx-frontend==2026.8.2.133643 # homeassistant.components.knx knx-telegram-store[sqlite,postgres]==0.11.2 From 82ee383dc424fb0802e3c2faeea35b09395cc1ec Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 3 Aug 2026 02:23:11 -0700 Subject: [PATCH 014/109] Bump google-genai to 2.16.0 (#177922) --- .../google_generative_ai_conversation/manifest.json | 2 +- requirements_all.txt | 2 +- .../google_generative_ai_conversation/test_conversation.py | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index 073215bfaf68..cd44dca3454d 100644 --- a/homeassistant/components/google_generative_ai_conversation/manifest.json +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["google-genai==1.59.0"] + "requirements": ["google-genai==2.16.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a23eb646e435..59f066dc5854 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1137,7 +1137,7 @@ google-cloud-speech==2.31.1 google-cloud-texttospeech==2.25.1 # homeassistant.components.google_generative_ai_conversation -google-genai==1.59.0 +google-genai==2.16.0 # homeassistant.components.google_health google-health-api==0.6.0 diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index a65f6278e931..8d397de7a79a 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -274,10 +274,14 @@ async def test_function_call( }, "inline_data": None, "media_resolution": None, + "part_metadata": None, "text": None, "thought": None, "thought_signature": None, + "tool_call": None, + "tool_response": None, "video_metadata": None, + "audio_transcription": None, } # Test history conversion for multi-turn conversation From 5966d65865084e3d033bbb1acac0aa83145795d9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 11:33:22 +0200 Subject: [PATCH 015/109] Fix flaky search test (#178062) Co-authored-by: Martin Hjelmare --- tests/components/search/test_init.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index a43c880a90f1..5f8988c689c3 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -269,6 +269,8 @@ async def test_search( ] }, ) + # Scene entities are added by a task, wait for it to finish + await hass.async_block_till_done() # Automations assert await async_setup_component( From 3bc69ac26e2d5bb6a907a013dbf8569950f4fc68 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 3 Aug 2026 11:35:11 +0200 Subject: [PATCH 016/109] Remove redundancy and add typing to tests for Mikrotik (#178050) --- tests/components/mikrotik/conftest.py | 7 ++ tests/components/mikrotik/test_button.py | 5 +- tests/components/mikrotik/test_config_flow.py | 68 +++++++++---------- .../mikrotik/test_device_tracker.py | 39 ++++++----- 4 files changed, 62 insertions(+), 57 deletions(-) diff --git a/tests/components/mikrotik/conftest.py b/tests/components/mikrotik/conftest.py index d98ed2fa7ab3..d5bb30b40467 100644 --- a/tests/components/mikrotik/conftest.py +++ b/tests/components/mikrotik/conftest.py @@ -25,3 +25,10 @@ def mock_api() -> Generator[MagicMock]: with patch("librouteros.connect", return_value=api_instance): yield api_instance + + +@pytest.fixture +def mock_api_error(request: pytest.FixtureRequest) -> Generator[None]: + """Mock librouteros.connect raising the parametrized error.""" + with patch("librouteros.connect", side_effect=request.param): + yield diff --git a/tests/components/mikrotik/test_button.py b/tests/components/mikrotik/test_button.py index a4dbc5827f1b..8c9333ed7c88 100644 --- a/tests/components/mikrotik/test_button.py +++ b/tests/components/mikrotik/test_button.py @@ -10,6 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_mikrotik_entry +from .conftest import MockConfigEntryFactory from tests.common import snapshot_platform @@ -27,7 +28,9 @@ async def test_button_entities_created( async def test_button_press( - hass: HomeAssistant, mock_api: MagicMock, mock_config_entry + hass: HomeAssistant, + mock_api: MagicMock, + mock_config_entry: MockConfigEntryFactory, ) -> None: """Test Mikrotik button entities press.""" await setup_mikrotik_entry( diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py index 923d1301d7d2..605f6496b6c1 100644 --- a/tests/components/mikrotik/test_config_flow.py +++ b/tests/components/mikrotik/test_config_flow.py @@ -1,8 +1,6 @@ """Test Mikrotik setup process.""" -from unittest.mock import patch - -import librouteros +from librouteros.exceptions import ConnectionClosed, TrapError import pytest from homeassistant import config_entries @@ -23,6 +21,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import MockConfigEntryFactory + DEMO_USER_INPUT = { CONF_HOST: "0.0.0.0", CONF_USERNAME: "username", @@ -42,34 +42,11 @@ DEMO_CONFIG_ENTRY = { CONF_DETECTION_TIME: 30, } - -@pytest.fixture(name="api") -def mock_mikrotik_api(): - """Mock an api.""" - with patch("librouteros.connect"): - yield +AUTH_ERROR = TrapError("invalid user name or password") +CONN_ERROR = ConnectionClosed() -@pytest.fixture(name="auth_error") -def mock_api_authentication_error(): - """Mock an api.""" - with patch( - "librouteros.connect", - side_effect=librouteros.exceptions.TrapError("invalid user name or password"), - ): - yield - - -@pytest.fixture(name="conn_error") -def mock_api_connection_error(): - """Mock an api.""" - with patch( - "librouteros.connect", side_effect=librouteros.exceptions.ConnectionClosed - ): - yield - - -async def test_flow_works(hass: HomeAssistant, api) -> None: +async def test_flow_works(hass: HomeAssistant) -> None: """Test config flow.""" result = await hass.config_entries.flow.async_init( @@ -90,7 +67,10 @@ async def test_flow_works(hass: HomeAssistant, api) -> None: assert result["data"][CONF_PORT] == 8278 -async def test_options(hass: HomeAssistant, api, mock_config_entry) -> None: +async def test_options( + hass: HomeAssistant, + mock_config_entry: MockConfigEntryFactory, +) -> None: """Test updating options.""" entry = mock_config_entry(data=DEMO_CONFIG_ENTRY) entry.add_to_hass(hass) @@ -120,8 +100,11 @@ async def test_options(hass: HomeAssistant, api, mock_config_entry) -> None: } +@pytest.mark.parametrize("mock_api_error", [AUTH_ERROR], indirect=True) +@pytest.mark.usefixtures("mock_api_error") async def test_host_already_configured( - hass: HomeAssistant, auth_error, mock_config_entry + hass: HomeAssistant, + mock_config_entry: MockConfigEntryFactory, ) -> None: """Test host already configured.""" @@ -138,7 +121,9 @@ async def test_host_already_configured( assert result["reason"] == "already_configured" -async def test_connection_error(hass: HomeAssistant, conn_error) -> None: +@pytest.mark.parametrize("mock_api_error", [CONN_ERROR], indirect=True) +@pytest.mark.usefixtures("mock_api_error") +async def test_connection_error(hass: HomeAssistant) -> None: """Test error when connection is unsuccessful.""" result = await hass.config_entries.flow.async_init( @@ -151,7 +136,9 @@ async def test_connection_error(hass: HomeAssistant, conn_error) -> None: assert result["errors"] == {"base": "cannot_connect"} -async def test_wrong_credentials(hass: HomeAssistant, auth_error) -> None: +@pytest.mark.parametrize("mock_api_error", [AUTH_ERROR], indirect=True) +@pytest.mark.usefixtures("mock_api_error") +async def test_wrong_credentials(hass: HomeAssistant) -> None: """Test error when credentials are wrong.""" result = await hass.config_entries.flow.async_init( @@ -168,7 +155,10 @@ async def test_wrong_credentials(hass: HomeAssistant, auth_error) -> None: } -async def test_reauth_success(hass: HomeAssistant, api, mock_config_entry) -> None: +async def test_reauth_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntryFactory, +) -> None: """Test we can reauth.""" entry = mock_config_entry(data=DEMO_USER_INPUT) entry.add_to_hass(hass) @@ -193,8 +183,11 @@ async def test_reauth_success(hass: HomeAssistant, api, mock_config_entry) -> No assert result2["reason"] == "reauth_successful" +@pytest.mark.parametrize("mock_api_error", [AUTH_ERROR], indirect=True) +@pytest.mark.usefixtures("mock_api_error") async def test_reauth_failed( - hass: HomeAssistant, auth_error, mock_config_entry + hass: HomeAssistant, + mock_config_entry: MockConfigEntryFactory, ) -> None: """Test reauth fails due to wrong password.""" entry = mock_config_entry(data=DEMO_USER_INPUT) @@ -218,8 +211,11 @@ async def test_reauth_failed( } +@pytest.mark.parametrize("mock_api_error", [CONN_ERROR], indirect=True) +@pytest.mark.usefixtures("mock_api_error") async def test_reauth_failed_conn_error( - hass: HomeAssistant, conn_error, mock_config_entry + hass: HomeAssistant, + mock_config_entry: MockConfigEntryFactory, ) -> None: """Test reauth failed due to connection error.""" entry = mock_config_entry(data=DEMO_USER_INPUT) diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index 29c98ea45197..ecf841b01c64 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -13,6 +13,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.dt import utcnow from . import setup_mikrotik_entry +from .conftest import MockConfigEntryFactory from .const import ( DEVICE_2_WIRELESS, DEVICE_3_DHCP_NUMERIC_NAME, @@ -33,7 +34,7 @@ from tests.common import async_fire_time_changed, patch def mock_device_registry_devices( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mock_config_entry, + mock_config_entry: MockConfigEntryFactory, ) -> None: """Create device registry devices so the device tracker entities are enabled.""" config_entry = mock_config_entry(domain="something_else", data={}) @@ -73,9 +74,8 @@ def mock_command( return {} -async def test_device_trackers( - hass: HomeAssistant, mock_device_registry_devices -) -> None: +@pytest.mark.usefixtures("mock_device_registry_devices") +async def test_device_trackers(hass: HomeAssistant) -> None: """Test device_trackers created by mikrotik.""" # test devices are added from wireless list only @@ -124,7 +124,8 @@ async def test_device_trackers( assert device_2.state == "not_home" -async def test_force_dhcp(hass: HomeAssistant, mock_device_registry_devices) -> None: +@pytest.mark.usefixtures("mock_device_registry_devices") +async def test_force_dhcp(hass: HomeAssistant) -> None: """Test updating hub that supports wireless with forced dhcp method.""" # hub supports wireless by default, force_dhcp is enabled to override @@ -138,9 +139,8 @@ async def test_force_dhcp(hass: HomeAssistant, mock_device_registry_devices) -> assert device_2.state == "home" -async def test_hub_not_support_wireless( - hass: HomeAssistant, mock_device_registry_devices -) -> None: +@pytest.mark.usefixtures("mock_device_registry_devices") +async def test_hub_not_support_wireless(hass: HomeAssistant) -> None: """Test device_trackers created when hub doesn't support wireless.""" await setup_mikrotik_entry(hass, support_wireless=False) @@ -153,9 +153,8 @@ async def test_hub_not_support_wireless( assert device_2.state == "home" -async def test_arp_ping_success( - hass: HomeAssistant, mock_device_registry_devices -) -> None: +@pytest.mark.usefixtures("mock_device_registry_devices") +async def test_arp_ping_success(hass: HomeAssistant) -> None: """Test arp ping devices to confirm they are connected.""" with patch.object( @@ -169,9 +168,8 @@ async def test_arp_ping_success( assert device_2.state == "home" -async def test_arp_ping_timeout( - hass: HomeAssistant, mock_device_registry_devices -) -> None: +@pytest.mark.usefixtures("mock_device_registry_devices") +async def test_arp_ping_timeout(hass: HomeAssistant) -> None: """Test arp ping timeout so devices are shown away.""" with patch.object( mikrotik.coordinator.MikrotikData, "do_arp_ping", return_value=False @@ -184,9 +182,8 @@ async def test_arp_ping_timeout( assert device_2.state == "not_home" -async def test_device_trackers_numerical_name( - hass: HomeAssistant, mock_device_registry_devices -) -> None: +@pytest.mark.usefixtures("mock_device_registry_devices") +async def test_device_trackers_numerical_name(hass: HomeAssistant) -> None: """Test device_trackers created by mikrotik with numerical device name.""" await setup_mikrotik_entry( @@ -202,7 +199,8 @@ async def test_device_trackers_numerical_name( assert device_3.attributes["host_name"] == "123" -async def test_hub_wifiwave2(hass: HomeAssistant, mock_device_registry_devices) -> None: +@pytest.mark.usefixtures("mock_device_registry_devices") +async def test_hub_wifiwave2(hass: HomeAssistant) -> None: """Test device_trackers created when hub supports wifiwave2.""" await setup_mikrotik_entry( @@ -225,7 +223,7 @@ async def test_hub_wifiwave2(hass: HomeAssistant, mock_device_registry_devices) async def test_restoring_devices( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_config_entry, + mock_config_entry: MockConfigEntryFactory, ) -> None: """Test restoring existing device_tracker entities if not detected on startup.""" config_entry = mock_config_entry( @@ -272,7 +270,8 @@ async def test_restoring_devices( assert device_3 is None -async def test_update_failed(hass: HomeAssistant, mock_device_registry_devices) -> None: +@pytest.mark.usefixtures("mock_device_registry_devices") +async def test_update_failed(hass: HomeAssistant) -> None: """Test failing to connect during update.""" await setup_mikrotik_entry(hass) From 6ed71d98edb1887ace6bc9a8d8fb7d22a47931bb Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 3 Aug 2026 11:41:56 +0200 Subject: [PATCH 017/109] Disable check requirements aw on PR against master (#178065) --- .github/workflows/check-requirements-deterministic.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/check-requirements-deterministic.yml b/.github/workflows/check-requirements-deterministic.yml index 7f2456b27960..615c872d242b 100644 --- a/.github/workflows/check-requirements-deterministic.yml +++ b/.github/workflows/check-requirements-deterministic.yml @@ -10,6 +10,8 @@ name: Check requirements (deterministic) on: pull_request: types: [opened, synchronize, reopened] + branches-ignore: + - master paths: - "requirements*.txt" - "**/requirements*.txt" From 11b8161185ea87a50f20eb26c13d67246e6bbee0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:43:10 +0200 Subject: [PATCH 018/109] Bump docker/login-action from 4.5.1 to 4.5.2 (#178052) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 6da3f760b46b..5e3f0b125635 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -342,13 +342,13 @@ jobs: - name: Login to DockerHub if: matrix.registry == 'docker.io/homeassistant' - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -521,7 +521,7 @@ jobs: persist-credentials: false - name: Login to GitHub Container Registry - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: registry: ghcr.io username: ${{ github.repository_owner }} From 0df1e65dbf394552bf7bae0f2b27e13274c90846 Mon Sep 17 00:00:00 2001 From: ccyyy1023 <2637034749@qq.com> Date: Mon, 3 Aug 2026 17:58:18 +0800 Subject: [PATCH 019/109] Fix naive datetime in Z-Wave JS migration backup (#177994) --- homeassistant/components/zwave_js/config_flow.py | 4 ++-- tests/components/zwave_js/test_config_flow.py | 12 +++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index db6fde0f7871..564801960484 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -3,7 +3,6 @@ import asyncio import base64 from contextlib import suppress -from datetime import datetime import logging from pathlib import Path from typing import Any, override @@ -40,6 +39,7 @@ from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from homeassistant.util import dt as dt_util from .addon import get_addon_manager from .const import ( @@ -1629,7 +1629,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): # save the backup to a file just in case self.backup_filepath = Path( self.hass.config.path( - f"zwavejs_nvm_backup_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.bin" # pylint: disable=home-assistant-enforce-naive-now + f"zwavejs_nvm_backup_{dt_util.now().strftime('%Y-%m-%d_%H-%M-%S')}.bin" ) ) try: diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 8088fb13ec36..c5ec67b69af2 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Generator import dataclasses +from datetime import UTC, datetime from ipaddress import ip_address from typing import Any from unittest.mock import AsyncMock, MagicMock, call, patch @@ -993,7 +994,13 @@ async def test_usb_discovery_migration( assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" - with patch("pathlib.Path.write_bytes") as mock_file: + with ( + patch("pathlib.Path.write_bytes") as mock_file, + patch( + "homeassistant.components.zwave_js.config_flow.dt_util.now", + return_value=datetime(2026, 8, 2, 12, 34, 56, tzinfo=UTC), + ), + ): await hass.async_block_till_done() assert client.driver.controller.async_backup_nvm_raw.call_count == 1 assert mock_file.call_count == 1 @@ -1005,6 +1012,9 @@ async def test_usb_discovery_migration( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "instruct_unplug" + assert result["description_placeholders"] == { + "file_path": hass.config.path("zwavejs_nvm_backup_2026-08-02_12-34-56.bin") + } result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) From 6a010d319a8d902d9f4cd7603954324ba1529564 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 12:16:25 +0200 Subject: [PATCH 020/109] Migrate withings to call async_remove_device (#178058) --- homeassistant/components/withings/sensor.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 73b56a8dae35..2967869322e6 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -875,16 +875,10 @@ async def async_setup_entry( if old_devices: device_registry = dr.async_get(hass) for device_id in old_devices: - # Several config entries can share this identifier, each owning its own - # device registry entry, so only remove this entry's own device. - for device in device_registry.devices.get_entries( - identifiers={(DOMAIN, device_id)} + if device := device_registry.async_get_device_by_identifier( + (DOMAIN, device_id), entry.entry_id ): - if device.config_entry_id == entry.entry_id: - device_registry.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) - break + device_registry.async_remove_device(device.id) current_devices.remove(device_id) device_coordinator.async_add_listener(_async_device_listener) From 71dc0c7d114316ec5813ad3d51b0379f844c9550 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 12:18:06 +0200 Subject: [PATCH 021/109] Migrate gatus to call async_remove_device (#178053) --- homeassistant/components/gatus/coordinator.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/gatus/coordinator.py b/homeassistant/components/gatus/coordinator.py index df4ad9545d52..8308861a5610 100644 --- a/homeassistant/components/gatus/coordinator.py +++ b/homeassistant/components/gatus/coordinator.py @@ -70,10 +70,7 @@ class GatusDataUpdateCoordinator(DataUpdateCoordinator[dict[str, EndpointStatus] and identifier[1].removeprefix(f"{self._entry_id}_") in stale_keys for identifier in device.identifiers ): - device_registry.async_update_device( - device.id, - remove_config_entry_id=self._entry_id, - ) + device_registry.async_remove_device(device.id) self._known_endpoint_keys = current_keys return {ep.key: ep for ep in raw_endpoints} From a0a92170d6e59df67a6e23b12039c1813926a943 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 12:18:50 +0200 Subject: [PATCH 022/109] Migrate purpleair to call async_remove_device (#178056) --- homeassistant/components/purpleair/config_flow.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/purpleair/config_flow.py b/homeassistant/components/purpleair/config_flow.py index 767a3ae10280..84742a2b188b 100644 --- a/homeassistant/components/purpleair/config_flow.py +++ b/homeassistant/components/purpleair/config_flow.py @@ -438,9 +438,7 @@ class PurpleAirOptionsFlowHandler(OptionsFlowWithReload): [entity_entry.entity_id for entity_entry in entity_entries], async_device_entity_state_changed, ) - device_registry.async_update_device( - device_id, remove_config_entry_id=self.config_entry.entry_id - ) + device_registry.async_remove_device(device_id) await device_entities_removed_event.wait() # Once we're done, we can cancel the state change tracker callback: From 71e493d7d66e2a800e922229abab576d012cb840 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:30:06 +0200 Subject: [PATCH 023/109] Bump actions/stale from 10.4.0 to 11.0.0 (#178051) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 7675005226b4..5919d558c6d9 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -44,7 +44,7 @@ jobs: # - Issues # - No issues marked as no-stale or help-wanted - name: 60 days stale PRs policy and 90 days stale issue policy - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 + uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 with: repo-token: ${{ steps.token.outputs.token }} remove-stale-when-updated: true From bb149f1cc1c03f06fd72698a995059f49440b41c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 13:02:54 +0200 Subject: [PATCH 024/109] Fix via_device race in anthemav (#177708) --- homeassistant/components/anthemav/__init__.py | 26 +++++++++++++++++-- .../components/anthemav/media_player.py | 16 ++++++++++-- tests/components/anthemav/test_init.py | 7 +++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/anthemav/__init__.py b/homeassistant/components/anthemav/__init__.py index 2185d0ddf4a9..8d14dfbf3ac0 100644 --- a/homeassistant/components/anthemav/__init__.py +++ b/homeassistant/components/anthemav/__init__.py @@ -6,12 +6,21 @@ import anthemav from anthemav.device_error import DeviceError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_MODEL, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, + Platform, +) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import ANTHEMAV_UPDATE_SIGNAL, DEVICE_TIMEOUT_SECONDS +from .const import ANTHEMAV_UPDATE_SIGNAL, DEVICE_TIMEOUT_SECONDS, DOMAIN, MANUFACTURER type AnthemavConfigEntry = ConfigEntry[anthemav.Connection] @@ -43,6 +52,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthemavConfigEntry) -> entry.runtime_data = avr + # Register the zone 1 receiver first so higher zones can link to it as a + # via device when their entities are created. + mac_address = entry.data[CONF_MAC] + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, mac_address)}, + connections={(CONNECTION_NETWORK_MAC, mac_address)}, + name=entry.title, + manufacturer=MANUFACTURER, + model=entry.data[CONF_MODEL], + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @callback diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index 60d1a544f262..dd92592d80c9 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -13,6 +13,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_MAC, CONF_MODEL from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -40,7 +41,13 @@ async def async_setup_entry( async_add_entities( AnthemAVR( - avr.protocol, name, mac_address, model, zone_number, config_entry.entry_id + hass, + avr.protocol, + name, + mac_address, + model, + zone_number, + config_entry.entry_id, ) for zone_number in avr.protocol.zones ) @@ -64,6 +71,7 @@ class AnthemAVR(MediaPlayerEntity): def __init__( self, + hass: HomeAssistant, avr: AVR, name: str, mac_address: str, @@ -85,7 +93,11 @@ class AnthemAVR(MediaPlayerEntity): name=f"Zone {zone_number}", manufacturer=MANUFACTURER, model=model, - via_device=(DOMAIN, mac_address), + via_device_id=dr.async_get_device_id_by_identifier( + hass, + (DOMAIN, mac_address), + config_entry_id=entry_id, + ), ) else: # Zone 1 is the physical receiver that owns the network MAC; higher diff --git a/tests/components/anthemav/test_init.py b/tests/components/anthemav/test_init.py index 883b41bbe5a9..e9d4ce371ee8 100644 --- a/tests/components/anthemav/test_init.py +++ b/tests/components/anthemav/test_init.py @@ -47,8 +47,15 @@ async def test_device_registry( device_entry = device_registry.async_get_device( identifiers={(DOMAIN, "00:00:00:00:00:01")} ) + assert device_entry assert device_entry == snapshot + zone_2_device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, "00:00:00:00:00:01_2"), init_integration.entry_id + ) + assert zone_2_device_entry + assert zone_2_device_entry.via_device_id == device_entry.id + @pytest.mark.parametrize("error", [OSError, DeviceError]) async def test_config_entry_not_ready_when_oserror( From 0e06648d7024606e08e2a3f8c82b770eae2689ce Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 3 Aug 2026 13:02:57 +0200 Subject: [PATCH 025/109] Remove not used login param (#178068) --- homeassistant/auth/__init__.py | 4 ---- homeassistant/auth/models.py | 1 - homeassistant/components/auth/login_flow.py | 11 ++++------- tests/auth/test_init.py | 4 +--- 4 files changed, 5 insertions(+), 15 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index e8a3dd8672ed..88a8b1d54152 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -152,10 +152,6 @@ class AuthManagerFlowManager( cast(Mapping[str, str], result["data"]), ) - if flow.context.get("credential_only"): - result["result"] = credentials - return result - # multi-factor module cannot enabled for new credential # which has not linked to a user yet if auth_provider.support_mfa and not credentials.is_new: diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index 5646d23d461f..a41b03d1074d 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -26,7 +26,6 @@ TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = "long_lived_access_token" class AuthFlowContext(FlowContext, total=False): """Typed context dict for auth flow.""" - credential_only: bool ip_address: IPv4Address | IPv6Address redirect_uri: str diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index bf4cc18ebc68..6594acd9eec4 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -22,8 +22,7 @@ Pass in parameter 'client_id' and 'redirect_url' validate by indieauth. Pass in parameter 'handler' to specify the auth provider to use. Auth providers are identified by type and id. -And optional parameter 'type' has to set as 'link_user' if login flow used for -link credential to exist user. Default 'type' is 'authorize'. +The default 'type' is 'authorize'. { "client_id": "https://hassbian.local:8123/", @@ -54,9 +53,6 @@ Progress the flow. Most flows will be 1 page, but could optionally add extra login challenges, like TFA. Once the flow has finished, the returned step will have type FlowResultType.CREATE_ENTRY and "result" key will contain an authorization code. -The authorization code associated with an authorized user by default, it will -associate with an credential if "type" set to "link_user" in -"/auth/login_flow" { "flow_id": "8f7e42faab604bcab7ac43c44ca34d58", @@ -351,7 +347,9 @@ class LoginFlowIndexView(LoginFlowBaseView): [vol.Any(str, None)], vol.Length(2, 2), vol.Coerce(tuple) ), vol.Required("redirect_uri"): str, - vol.Optional("type", default="authorize"): str, + vol.Optional( + "type", default="authorize" + ): str, # not used, kept for backwards compatibility } ) ) @@ -371,7 +369,6 @@ class LoginFlowIndexView(LoginFlowBaseView): handler, context=AuthFlowContext( ip_address=ip_address(request.remote), # type: ignore[arg-type] - credential_only=data.get("type") == "link_user", redirect_uri=redirect_uri, ), ) diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index f75330af1b5c..dbc36fa295ff 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -285,9 +285,7 @@ async def test_linking_user_to_two_auth_providers( user = await manager.async_get_or_create_user(credential) assert user is not None - step = await manager.login_flow.async_init( - ("insecure_example", "another-provider"), context={"credential_only": True} - ) + step = await manager.login_flow.async_init(("insecure_example", "another-provider")) step = await manager.login_flow.async_configure( step["flow_id"], {"username": "another-user", "password": "another-password"} ) From c9023c6c9809b89f322d2146de6bf396806007f6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 13:04:47 +0200 Subject: [PATCH 026/109] Fix via_device race in sensibo (#177758) --- homeassistant/components/sensibo/__init__.py | 24 +++++- homeassistant/components/sensibo/entity.py | 36 +++++--- tests/components/sensibo/test_entity.py | 90 +++++++++++++++++++- 3 files changed, 134 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/sensibo/__init__.py b/homeassistant/components/sensibo/__init__.py index d0a91db2b630..de2653a29bad 100644 --- a/homeassistant/components/sensibo/__init__.py +++ b/homeassistant/components/sensibo/__init__.py @@ -6,12 +6,17 @@ from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, LOGGER, PLATFORMS from .coordinator import SensiboDataUpdateCoordinator +from .entity import get_device_info from .services import async_setup_services from .util import NoDevicesError, NoUsernameError, async_validate_api @@ -34,6 +39,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: SensiboConfigEntry) -> b await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator + device_registry = dr.async_get(hass) + + def _async_register_devices() -> None: + """Register parent AC devices so motion sensors can resolve via_device_id. + + Registered before the platforms so it runs first on every coordinator + update, ensuring a parent device exists before the (concurrently set up) + motion sensors resolve it, including devices added dynamically at runtime. + """ + for device in coordinator.data.parsed.values(): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, **get_device_info(device) + ) + + _async_register_devices() + entry.async_on_unload(coordinator.async_add_listener(_async_register_devices)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index 9544e0d52cff..8bf81df9ca51 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, Concatenate, override from pysensibo.model import MotionSensor, SensiboDevice from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -14,6 +15,22 @@ from .const import DOMAIN, LOGGER, SENSIBO_ERRORS, TIMEOUT from .coordinator import SensiboDataUpdateCoordinator +def get_device_info(device: SensiboDevice) -> DeviceInfo: + """Return device info for a Sensibo device.""" + return DeviceInfo( + identifiers={(DOMAIN, device.id)}, + name=device.name, + connections={(CONNECTION_NETWORK_MAC, device.mac)}, + manufacturer="Sensibo", + configuration_url="https://home.sensibo.com/", + model=device.model, + sw_version=device.fw_ver, + hw_version=device.fw_type, + suggested_area=device.name, + serial_number=device.serial, + ) + + def async_handle_api_call[_T: SensiboDeviceBaseEntity, **_P]( function: Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]]: @@ -90,18 +107,7 @@ class SensiboDeviceBaseEntity(SensiboBaseEntity): ) -> None: """Initiate Sensibo Device.""" super().__init__(coordinator, device_id) - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.device_data.id)}, - name=self.device_data.name, - connections={(CONNECTION_NETWORK_MAC, self.device_data.mac)}, - manufacturer="Sensibo", - configuration_url="https://home.sensibo.com/", - model=self.device_data.model, - sw_version=self.device_data.fw_ver, - hw_version=self.device_data.fw_type, - suggested_area=self.device_data.name, - serial_number=self.device_data.serial, - ) + self._attr_device_info = get_device_info(self.device_data) class SensiboMotionBaseEntity(SensiboBaseEntity): @@ -120,7 +126,11 @@ class SensiboMotionBaseEntity(SensiboBaseEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, sensor_id)}, name=f"{self.device_data.name} Motion Sensor", - via_device=(DOMAIN, device_id), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, device_id), + config_entry_id=coordinator.config_entry.entry_id, + ), manufacturer="Sensibo", configuration_url="https://home.sensibo.com/", model=sensor_data.model, diff --git a/tests/components/sensibo/test_entity.py b/tests/components/sensibo/test_entity.py index 7c7c298b717b..8b613668836d 100644 --- a/tests/components/sensibo/test_entity.py +++ b/tests/components/sensibo/test_entity.py @@ -1,7 +1,11 @@ """The test for the sensibo entity.""" +from datetime import timedelta +from typing import Any from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory +from pysensibo.model import SensiboData import pytest from syrupy.assertion import SnapshotAssertion @@ -10,13 +14,15 @@ from homeassistant.components.climate import ( DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, ) -from homeassistant.components.sensibo.const import SENSIBO_ERRORS +from homeassistant.components.sensibo.const import DOMAIN, SENSIBO_ERRORS from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr +from tests.common import async_fire_time_changed + async def test_device( hass: HomeAssistant, @@ -36,6 +42,86 @@ async def test_device( assert device_entries == snapshot +@pytest.mark.parametrize("load_platforms", [[Platform.BINARY_SENSOR]]) +@pytest.mark.usefixtures("hass") +async def test_motion_sensor_via_device( + device_registry: dr.DeviceRegistry, + load_int: ConfigEntry, +) -> None: + """Test the motion sensor is linked to its parent device. + + Only the binary sensor platform is loaded so that the parent device is + solely registered during setup, guarding against the child resolving its + via_device_id before the parent exists. + """ + parent_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "ABC999111"), load_int.entry_id + ) + assert parent_device + motion_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "AABBCC"), load_int.entry_id + ) + assert motion_device + assert motion_device.via_device_id == parent_device.id + + +@pytest.mark.parametrize("load_platforms", [[Platform.BINARY_SENSOR]]) +async def test_motion_sensor_via_device_dynamic_add( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + load_int: ConfigEntry, + mock_client: MagicMock, + get_data: tuple[SensiboData, dict[str, Any], dict[str, Any]], + freezer: FrozenDateTimeFactory, +) -> None: + """Test the motion sensor links to its parent when added at runtime. + + Only the binary sensor platform is loaded, so the parent AC device is + registered solely by the integration, never by a climate entity. The parent + is removed and re-added via a coordinator update to exercise the dynamic-add + path, where the motion sensor must still resolve its parent's via_device_id. + """ + new_result = [ + device for device in get_data[2]["result"] if device["id"] != "ABC999111" + ] + new_parsed = {k: v for k, v in get_data[0].parsed.items() if k != "ABC999111"} + mock_client.async_get_devices.return_value = { + "status": "success", + "result": new_result, + } + mock_client.async_get_devices_data.return_value = SensiboData( + new_result, new_parsed + ) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert not device_registry.async_get_device_by_identifier( + (DOMAIN, "ABC999111"), load_int.entry_id + ) + assert not device_registry.async_get_device_by_identifier( + (DOMAIN, "AABBCC"), load_int.entry_id + ) + + mock_client.async_get_devices.return_value = get_data[2] + mock_client.async_get_devices_data.return_value = get_data[0] + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + parent_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "ABC999111"), load_int.entry_id + ) + assert parent_device + motion_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "AABBCC"), load_int.entry_id + ) + assert motion_device + assert motion_device.via_device_id == parent_device.id + + @pytest.mark.parametrize("p_error", SENSIBO_ERRORS) async def test_entity_failed_service_calls( hass: HomeAssistant, From 00eda4caa1d561cbb038509fd2ee0f06e0d7c552 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 13:06:50 +0200 Subject: [PATCH 027/109] Fix via_device race in yale_smart_alarm (#177858) --- .../components/yale_smart_alarm/__init__.py | 10 ++++- .../yale_smart_alarm/coordinator.py | 23 ++++++++++- .../components/yale_smart_alarm/entity.py | 25 ++++++------ .../components/yale_smart_alarm/test_init.py | 38 ++++++++++++++++++- 4 files changed, 81 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py index ec410077fe17..d586bf027c75 100644 --- a/homeassistant/components/yale_smart_alarm/__init__.py +++ b/homeassistant/components/yale_smart_alarm/__init__.py @@ -4,7 +4,7 @@ from homeassistant.components.lock import CONF_DEFAULT_CODE, DOMAIN as LOCK_DOMA from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CODE, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import LOGGER, PLATFORMS from .coordinator import YaleDataUpdateCoordinator @@ -19,6 +19,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator + # Register the alarm panel device so child devices can link to it via + # via_device_id, since the panel is otherwise only registered by a sibling + # entity on a concurrently loaded platform. + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + **coordinator.device_info, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py index f8cd3d726eb1..085cd2298a5c 100644 --- a/homeassistant/components/yale_smart_alarm/coordinator.py +++ b/homeassistant/components/yale_smart_alarm/coordinator.py @@ -10,12 +10,20 @@ from yalesmartalarmclient.exceptions import AuthenticationError from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed if TYPE_CHECKING: from . import YaleConfigEntry -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, YALE_BASE_ERRORS +from .const import ( + DEFAULT_SCAN_INTERVAL, + DOMAIN, + LOGGER, + MANUFACTURER, + MODEL, + YALE_BASE_ERRORS, +) class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): @@ -36,6 +44,19 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) self.locks: list[YaleLock] = [] + @property + def device_info(self) -> DeviceInfo: + """Return device info for the alarm panel.""" + panel_info = self.data["panel_info"] + return DeviceInfo( + identifiers={(DOMAIN, self.config_entry.data[CONF_USERNAME])}, + connections={(CONNECTION_NETWORK_MAC, panel_info["mac"])}, + manufacturer=MANUFACTURER, + model=MODEL, + name=self.config_entry.title, + sw_version=panel_info["version"], + ) + def _yale_setup(self) -> tuple[YaleSmartAlarmClient, list[YaleLock]]: """Set up connection to Yale.""" yale = YaleSmartAlarmClient( diff --git a/homeassistant/components/yale_smart_alarm/entity.py b/homeassistant/components/yale_smart_alarm/entity.py index 2610f54f0a90..5bbe78c5a4fb 100644 --- a/homeassistant/components/yale_smart_alarm/entity.py +++ b/homeassistant/components/yale_smart_alarm/entity.py @@ -3,7 +3,8 @@ from yalesmartalarmclient import YaleLock from homeassistant.const import CONF_USERNAME -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -25,7 +26,11 @@ class YaleEntity(CoordinatorEntity[YaleDataUpdateCoordinator]): manufacturer=MANUFACTURER, model=MODEL, identifiers={(DOMAIN, data["address"])}, - via_device=(DOMAIN, coordinator.config_entry.data[CONF_USERNAME]), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.config_entry.data[CONF_USERNAME]), + config_entry_id=coordinator.config_entry.entry_id, + ), ) @@ -43,7 +48,11 @@ class YaleLockEntity(CoordinatorEntity[YaleDataUpdateCoordinator]): manufacturer=MANUFACTURER, model=MODEL, identifiers={(DOMAIN, lock.sid())}, - via_device=(DOMAIN, coordinator.config_entry.data[CONF_USERNAME]), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.config_entry.data[CONF_USERNAME]), + config_entry_id=coordinator.config_entry.entry_id, + ), ) self.lock_data = lock @@ -56,12 +65,4 @@ class YaleAlarmEntity(CoordinatorEntity[YaleDataUpdateCoordinator], Entity): def __init__(self, coordinator: YaleDataUpdateCoordinator) -> None: """Initialize an Yale device.""" super().__init__(coordinator) - panel_info = coordinator.data["panel_info"] - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.config_entry.data[CONF_USERNAME])}, - manufacturer=MANUFACTURER, - model=MODEL, - name=coordinator.config_entry.title, - connections={(CONNECTION_NETWORK_MAC, panel_info["mac"])}, - sw_version=panel_info["version"], - ) + self._attr_device_info = coordinator.device_info diff --git a/tests/components/yale_smart_alarm/test_init.py b/tests/components/yale_smart_alarm/test_init.py index c2926e857ce5..dc438387d47b 100644 --- a/tests/components/yale_smart_alarm/test_init.py +++ b/tests/components/yale_smart_alarm/test_init.py @@ -2,11 +2,14 @@ from unittest.mock import Mock, patch +import pytest + from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.yale_smart_alarm.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import ENTRY_CONFIG, OPTIONS_CONFIG @@ -44,6 +47,39 @@ async def test_setup_entry( assert entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.parametrize( + ("load_platforms", "child_identifier"), + [ + pytest.param([Platform.LOCK], "1111", id="lock"), + pytest.param([Platform.BINARY_SENSOR], "RF4", id="binary_sensor"), + ], +) +async def test_child_devices_linked_to_panel( + hass: HomeAssistant, + load_config_entry: tuple[MockConfigEntry, Mock], + device_registry: dr.DeviceRegistry, + child_identifier: str, +) -> None: + """Test child devices link to the alarm panel through via_device_id. + + Only a single platform is loaded per case, whose entities never create the + panel device themselves, so the link is established solely by the panel + being registered at setup. Covers both YaleEntity and YaleLockEntity. + """ + config_entry, _ = load_config_entry + + panel_device = device_registry.async_get_device_by_identifier( + (DOMAIN, ENTRY_CONFIG["username"]), config_entry.entry_id + ) + assert panel_device is not None + + child_device = device_registry.async_get_device_by_identifier( + (DOMAIN, child_identifier), config_entry.entry_id + ) + assert child_device is not None + assert child_device.via_device_id == panel_device.id + + async def test_migrate_entry( hass: HomeAssistant, get_client: Mock, From 2a1009111f846c29576d2e66f51330bda6fb73dc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 13:08:32 +0200 Subject: [PATCH 028/109] Update integrations to call async_remove_device (part 2) (#177963) --- homeassistant/components/litterrobot/coordinator.py | 5 +---- homeassistant/components/music_assistant/__init__.py | 4 +--- homeassistant/components/ondilo_ico/coordinator.py | 5 +---- homeassistant/components/overkiz/coordinator.py | 6 +----- homeassistant/components/plugwise/coordinator.py | 4 +--- homeassistant/components/sensibo/coordinator.py | 5 +---- homeassistant/components/smartthings/__init__.py | 4 +--- homeassistant/components/solarlog/coordinator.py | 5 +---- homeassistant/components/tasmota/__init__.py | 4 +--- homeassistant/components/tedee/coordinator.py | 5 +---- homeassistant/components/tplink/coordinator.py | 5 +---- homeassistant/components/trmnl/coordinator.py | 5 +---- homeassistant/components/uptimerobot/coordinator.py | 5 +---- homeassistant/components/watts/coordinator.py | 5 +---- 14 files changed, 14 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/litterrobot/coordinator.py b/homeassistant/components/litterrobot/coordinator.py index ad9b1e2ef610..8cbf0480116d 100644 --- a/homeassistant/components/litterrobot/coordinator.py +++ b/homeassistant/components/litterrobot/coordinator.py @@ -85,10 +85,7 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]): (DOMAIN, device_id), self.config_entry.entry_id ) if device: - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(device.id) self.previous_members = current_members @override diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index d856e3cd7379..788d05f97454 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -183,9 +183,7 @@ async def async_setup_entry( # noqa: C901 if hass_device := dev_reg.async_get_device_by_identifier( (DOMAIN, player_id), entry.entry_id ): - dev_reg.async_update_device( - hass_device.id, remove_config_entry_id=entry.entry_id - ) + dev_reg.async_remove_device(hass_device.id) # register listener for new players def handle_player_added(event: MassEvent) -> None: diff --git a/homeassistant/components/ondilo_ico/coordinator.py b/homeassistant/components/ondilo_ico/coordinator.py index f29491ba8a55..c0fb60bb4386 100644 --- a/homeassistant/components/ondilo_ico/coordinator.py +++ b/homeassistant/components/ondilo_ico/coordinator.py @@ -98,10 +98,7 @@ class OndiloIcoPoolsCoordinator(DataUpdateCoordinator[dict[str, OndiloIcoPoolDat self.config_entry.entry_id, ) if device_entry: - self._device_registry.async_update_device( - device_id=device_entry.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + self._device_registry.async_remove_device(device_entry.id) for pool_id in current_pools: pool_data = data[pool_id] diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py index 4de532489577..7d259cb2ef37 100644 --- a/homeassistant/components/overkiz/coordinator.py +++ b/homeassistant/components/overkiz/coordinator.py @@ -216,11 +216,7 @@ async def on_device_removed( if registered_device := registry.async_get_device_by_identifier( (DOMAIN, base_device_url), coordinator.config_entry.entry_id ): - # Detach only this entry; the registry deletes the device once none remain. - registry.async_update_device( - registered_device.id, - remove_config_entry_id=coordinator.config_entry.entry_id, - ) + registry.async_remove_device(registered_device.id) if event.device_url in coordinator.devices: del coordinator.devices[event.device_url] diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index 2429529336f4..943b3222e221 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -165,9 +165,7 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData (DOMAIN, device_id), self.config_entry.entry_id ) ) is not None: - device_reg.async_update_device( - device_entry.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device_entry.id) LOGGER.debug( "%s %s %s removed from device_registry", DOMAIN, diff --git a/homeassistant/components/sensibo/coordinator.py b/homeassistant/components/sensibo/coordinator.py index 8fdd867b577f..d34256f54941 100644 --- a/homeassistant/components/sensibo/coordinator.py +++ b/homeassistant/components/sensibo/coordinator.py @@ -114,10 +114,7 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]): (DOMAIN, _id), self.config_entry.entry_id ) if device: - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(device.id) self.previous_devices = current_devices return data diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 09cea70fa99f..14cb480a23d4 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -248,9 +248,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) (DOMAIN, device_id), entry.entry_id ) if dev_entry is not None: - device_registry.async_update_device( - dev_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(dev_entry.id) entry.async_on_unload( client.add_device_lifecycle_event_listener( diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py index e6aa88ad45cf..556e0108963e 100644 --- a/homeassistant/components/solarlog/coordinator.py +++ b/homeassistant/components/solarlog/coordinator.py @@ -201,10 +201,7 @@ class SolarLogDeviceDataCoordinator(DataUpdateCoordinator[dict[int, InverterData ), self.config_entry.entry_id, ): - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(device.id) _LOGGER.info("Device removed from device registry: %s", device.id) # add new devices diff --git a/homeassistant/components/tasmota/__init__.py b/homeassistant/components/tasmota/__init__.py index 501b9844b003..9e6153c996f4 100644 --- a/homeassistant/components/tasmota/__init__.py +++ b/homeassistant/components/tasmota/__init__.py @@ -122,9 +122,7 @@ async def _remove_device( return _LOGGER.debug("Removing tasmota from device %s", mac) - device_registry.async_update_device( - device.id, remove_config_entry_id=config_entry.entry_id - ) + device_registry.async_remove_device(device.id) def _update_device( diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index ba194594ab8f..b360f7b53f53 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -151,10 +151,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): if device := device_registry.async_get_device_by_identifier( (DOMAIN, str(lock_id)), self.config_entry.entry_id ): - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(device.id) # add new locks if new_locks := current_locks - self._locks_last_update: diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index 5b326c4287a2..899c49f5cbba 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -110,10 +110,7 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): (DOMAIN, device_id), self.config_entry.entry_id ) if device: - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(device.id) child_coordinator = self._child_coordinators.pop(device_id, None) if child_coordinator: await child_coordinator.async_shutdown() diff --git a/homeassistant/components/trmnl/coordinator.py b/homeassistant/components/trmnl/coordinator.py index e4aa2b93520b..13c144c9a071 100644 --- a/homeassistant/components/trmnl/coordinator.py +++ b/homeassistant/components/trmnl/coordinator.py @@ -62,8 +62,5 @@ class TRMNLCoordinator(DataUpdateCoordinator[dict[int, Device]]): if entry := device_registry.async_get_device_by_identifier( (DOMAIN, str(device_id)), self.config_entry.entry_id ): - device_registry.async_update_device( - device_id=entry.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(entry.id) return new_data diff --git a/homeassistant/components/uptimerobot/coordinator.py b/homeassistant/components/uptimerobot/coordinator.py index a85d23e1178f..da7543089a86 100644 --- a/homeassistant/components/uptimerobot/coordinator.py +++ b/homeassistant/components/uptimerobot/coordinator.py @@ -72,9 +72,6 @@ class UptimeRobotDataUpdateCoordinator( if device := device_registry.async_get_device_by_identifier( (DOMAIN, str(monitor_id)), self.config_entry.entry_id ): - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(device.id) return new_monitors diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index 28982b63ec05..c6f8d272c41f 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -152,10 +152,7 @@ class WattsVisionHubCoordinator(DataUpdateCoordinator[dict[str, Device]]): (DOMAIN, device_id), self.config_entry.entry_id ) if device: - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(device.id) @property def device_ids(self) -> list[str]: From 1a119428954ef732b004d93bd96abf0950fec763 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 13:26:35 +0200 Subject: [PATCH 029/109] Adapt incomfort to set via_device_id in DeviceInfo (#178077) --- homeassistant/components/incomfort/climate.py | 10 +++++-- homeassistant/components/incomfort/entity.py | 10 +++++-- tests/components/incomfort/conftest.py | 12 +++++++- tests/components/incomfort/test_init.py | 30 +++++++++++++++++++ 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index 85f889ac6693..d4909780e07b 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -12,6 +12,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -70,9 +71,12 @@ class InComfortClimate(IncomfortEntity, ClimateEntity): name=f"Thermostat {room.room_no}", ) if coordinator.unique_id: - self._attr_device_info["via_device"] = ( - DOMAIN, - coordinator.config_entry.entry_id, + self._attr_device_info["via_device_id"] = ( + dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.config_entry.entry_id), + config_entry_id=coordinator.config_entry.entry_id, + ) ) @property diff --git a/homeassistant/components/incomfort/entity.py b/homeassistant/components/incomfort/entity.py index 1e0d357e8e08..c5839025a52c 100644 --- a/homeassistant/components/incomfort/entity.py +++ b/homeassistant/components/incomfort/entity.py @@ -2,6 +2,7 @@ from incomfortclient import Heater +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -29,7 +30,10 @@ class IncomfortBoilerEntity(IncomfortEntity): serial_number=heater.serial_no, ) if coordinator.unique_id: - self._attr_device_info["via_device"] = ( - DOMAIN, - coordinator.config_entry.entry_id, + self._attr_device_info["via_device_id"] = ( + dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.config_entry.entry_id), + config_entry_id=coordinator.config_entry.entry_id, + ) ) diff --git a/tests/components/incomfort/conftest.py b/tests/components/incomfort/conftest.py index 6afd89644a5e..b9c76fe351e0 100644 --- a/tests/components/incomfort/conftest.py +++ b/tests/components/incomfort/conftest.py @@ -80,15 +80,25 @@ def mock_entry_options() -> dict[str, Any] | None: return None +@pytest.fixture +def mock_entry_unique_id() -> str | None: + """Mock config entry unique_id for fixture.""" + return None + + @pytest.fixture def mock_config_entry( hass: HomeAssistant, mock_entry_data: dict[str, Any], mock_entry_options: dict[str, Any], + mock_entry_unique_id: str | None, ) -> MockConfigEntry: """Mock a config entry setup for incomfort integration.""" entry = MockConfigEntry( - domain=DOMAIN, data=mock_entry_data, options=mock_entry_options + domain=DOMAIN, + data=mock_entry_data, + options=mock_entry_options, + unique_id=mock_entry_unique_id, ) entry.add_to_hass(hass) return entry diff --git a/tests/components/incomfort/test_init.py b/tests/components/incomfort/test_init.py index 3fdd0c891a5c..c7e61ba88038 100644 --- a/tests/components/incomfort/test_init.py +++ b/tests/components/incomfort/test_init.py @@ -34,6 +34,36 @@ async def test_setup_platforms( assert mock_config_entry.state is ConfigEntryState.LOADED +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("mock_entry_unique_id", ["00:04:a3:de:ad:ff"]) +async def test_device_via_device_links( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that child devices link to the gateway via via_device_id.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + gateway_device = device_registry.async_get_device_by_identifier( + (DOMAIN, mock_config_entry.entry_id), mock_config_entry.entry_id + ) + assert gateway_device is not None + + boiler_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "c0ffeec0ffee"), mock_config_entry.entry_id + ) + assert boiler_device is not None + assert boiler_device.via_device_id == gateway_device.id + + thermostat_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "c0ffeec0ffee_1"), mock_config_entry.entry_id + ) + assert thermostat_device is not None + assert thermostat_device.via_device_id == gateway_device.id + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( "mock_heater_status", [MOCK_HEATER_STATUS | {"serial_no": "c01d00c0ffee"}] From f310afbfde2a07b8e94d482d5fdbb3f0d692ebf7 Mon Sep 17 00:00:00 2001 From: Linkplay2020 <65423368+Linkplay2020@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:39:15 +0800 Subject: [PATCH 030/109] Bump wiim to 0.1.6 (#178070) Co-authored-by: Tao Jiang --- homeassistant/components/wiim/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wiim/manifest.json b/homeassistant/components/wiim/manifest.json index 83d724c308a4..140ac44547d2 100644 --- a/homeassistant/components/wiim/manifest.json +++ b/homeassistant/components/wiim/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["wiim.sdk", "async_upnp_client"], "quality_scale": "bronze", - "requirements": ["wiim==0.1.5"], + "requirements": ["wiim==0.1.6"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 59f066dc5854..db6ad4e499b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3385,7 +3385,7 @@ whois==0.9.27 wiffi==1.1.2 # homeassistant.components.wiim -wiim==0.1.5 +wiim==0.1.6 # homeassistant.components.wirelesstag wirelesstagpy==0.8.1 From de194a6cef5a603e1985a1f23b6bfbf9cd6b6867 Mon Sep 17 00:00:00 2001 From: Matt Zimmerman Date: Mon, 3 Aug 2026 04:40:24 -0700 Subject: [PATCH 031/109] Avoid naive datetime.now() in smarttub tests (#178036) Co-authored-by: Claude Sonnet 5 --- tests/components/smarttub/test_binary_sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/smarttub/test_binary_sensor.py b/tests/components/smarttub/test_binary_sensor.py index 71d3083ec0b4..2396b79e85ba 100644 --- a/tests/components/smarttub/test_binary_sensor.py +++ b/tests/components/smarttub/test_binary_sensor.py @@ -1,6 +1,5 @@ """Test the SmartTub binary sensor platform.""" -from datetime import datetime from unittest.mock import create_autospec import pytest @@ -8,6 +7,7 @@ import smarttub from homeassistant.components.binary_sensor import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util async def test_binary_sensors(spa, setup_entry, hass: HomeAssistant) -> None: @@ -43,8 +43,8 @@ def mock_error(spa): error.title = "Flow Switch Stuck Open" error.description = None error.active = True - error.created_at = datetime.now() # pylint: disable=home-assistant-enforce-naive-now - error.updated_at = datetime.now() # pylint: disable=home-assistant-enforce-naive-now + error.created_at = dt_util.utcnow() + error.updated_at = dt_util.utcnow() error.error_type = "TUB_ERROR" return error From de32db8f4fc5f614f35531d8f2ebbbe555bec58c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 13:42:25 +0200 Subject: [PATCH 032/109] Fix via_device race in melnor (#177733) Co-authored-by: Markus Tuominen <3738613+Markus98@users.noreply.github.com> --- homeassistant/components/melnor/__init__.py | 15 ++++++++++++ homeassistant/components/melnor/entity.py | 7 +++++- tests/components/melnor/test_init.py | 26 +++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/melnor/__init__.py b/homeassistant/components/melnor/__init__.py index 4cc112509d99..312a28b562ae 100644 --- a/homeassistant/components/melnor/__init__.py +++ b/homeassistant/components/melnor/__init__.py @@ -7,7 +7,10 @@ from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher from homeassistant.const import CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH +from .const import DOMAIN from .coordinator import MelnorConfigEntry, MelnorDataUpdateCoordinator PLATFORMS: list[Platform] = [ @@ -54,6 +57,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: MelnorConfigEntry) -> bo await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator + + # Register the parent device up front so zone entities can deterministically + # resolve its via_device_id, regardless of platform setup order. + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, device.mac)}, + connections={(CONNECTION_BLUETOOTH, device.mac)}, + manufacturer="Melnor", + model=device.model, + name=device.name, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/melnor/entity.py b/homeassistant/components/melnor/entity.py index 35e804111f48..ac85df205c85 100644 --- a/homeassistant/components/melnor/entity.py +++ b/homeassistant/components/melnor/entity.py @@ -7,6 +7,7 @@ from melnor_bluetooth.device import Device, Valve from homeassistant.components.number import EntityDescription from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -76,7 +77,11 @@ class MelnorZoneEntity(MelnorBluetoothEntity): identifiers={(DOMAIN, f"{self._device.mac}-zone{self._valve.id}")}, manufacturer="Melnor", name=f"Zone {valve.id + 1}", - via_device=(DOMAIN, self._device.mac), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, self._device.mac), + config_entry_id=coordinator.config_entry.entry_id, + ), ) diff --git a/tests/components/melnor/test_init.py b/tests/components/melnor/test_init.py index 229d1b3cc337..6af51b4dbde8 100644 --- a/tests/components/melnor/test_init.py +++ b/tests/components/melnor/test_init.py @@ -35,3 +35,29 @@ async def test_device_registry( identifiers={(DOMAIN, FAKE_ADDRESS_1)} ) assert device_entry == snapshot + + +async def test_zone_device_via_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that zone devices link to the parent device via via_device_id.""" + entry = mock_config_entry(hass) + + with ( + patch_async_ble_device_from_address(), + patch_melnor_device(), + patch_async_register_callback(), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + + parent_device = device_registry.async_get_device_by_identifier( + (DOMAIN, FAKE_ADDRESS_1), entry.entry_id + ) + zone_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{FAKE_ADDRESS_1}-zone0"), entry.entry_id + ) + + assert parent_device is not None + assert zone_device is not None + assert zone_device.via_device_id == parent_device.id From bd149d66ccd4ae1b6d8686d55f1ad788efc01633 Mon Sep 17 00:00:00 2001 From: John Pettitt Date: Mon, 3 Aug 2026 07:44:50 -0400 Subject: [PATCH 033/109] Avoid naive datetime.now() in Subaru config flow (#177947) --- homeassistant/components/subaru/config_flow.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/subaru/config_flow.py b/homeassistant/components/subaru/config_flow.py index 57d207885f40..7d0041d03f85 100644 --- a/homeassistant/components/subaru/config_flow.py +++ b/homeassistant/components/subaru/config_flow.py @@ -1,7 +1,7 @@ """Config flow for Subaru integration.""" -from datetime import datetime import logging +import time from typing import TYPE_CHECKING, Any, override from subarulink import ( @@ -23,6 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.util import dt as dt_util from .const import CONF_UPDATE_ENABLED, DOMAIN from .coordinator import SubaruConfigEntry @@ -110,10 +111,9 @@ class SubaruConfigFlow(ConfigFlow, domain=DOMAIN): data: contains values provided by the user. """ websession = aiohttp_client.async_get_clientsession(self.hass) - now = datetime.now() # pylint: disable=home-assistant-enforce-naive-now if not data.get(CONF_DEVICE_ID): - data[CONF_DEVICE_ID] = int(now.timestamp()) - date = now.strftime("%Y-%m-%d") + data[CONF_DEVICE_ID] = int(time.time()) + date = dt_util.now().strftime("%Y-%m-%d") device_name = "Home Assistant: Added " + date self.controller = SubaruAPI( From 860a0a97c27119f59a83d75c98c820710b3d6522 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Mon, 3 Aug 2026 13:46:31 +0200 Subject: [PATCH 034/109] Stop using naive now in here_travel_time (#177942) --- homeassistant/components/here_travel_time/coordinator.py | 5 +++-- tests/components/here_travel_time/test_init.py | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py index a839b95e1ad4..1d73b93c89e5 100644 --- a/homeassistant/components/here_travel_time/coordinator.py +++ b/homeassistant/components/here_travel_time/coordinator.py @@ -388,7 +388,8 @@ def build_hass_attribution(sections: list[dict[str, Any]]) -> str | None: def next_datetime(simple_time: time) -> datetime: """Take a time like 08:00:00 and combine it with the current date.""" - combined = datetime.combine(dt_util.start_of_local_day(), simple_time) - if combined < datetime.now(): # pylint: disable=home-assistant-enforce-naive-now + start_of_day = dt_util.start_of_local_day() + combined = datetime.combine(start_of_day, simple_time, start_of_day.tzinfo) + if combined < dt_util.now(): combined = combined + timedelta(days=1) return combined diff --git a/tests/components/here_travel_time/test_init.py b/tests/components/here_travel_time/test_init.py index 3f09a5907ad0..98345add569a 100644 --- a/tests/components/here_travel_time/test_init.py +++ b/tests/components/here_travel_time/test_init.py @@ -1,6 +1,6 @@ """The test for the HERE Travel Time integration.""" -from datetime import datetime +from typing import Any import pytest @@ -32,18 +32,18 @@ from tests.common import MockConfigEntry DEFAULT_OPTIONS, { CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, - CONF_DEPARTURE_TIME: datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + CONF_DEPARTURE_TIME: "08:00:00", }, { CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, - CONF_ARRIVAL_TIME: datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + CONF_ARRIVAL_TIME: "08:00:00", }, { CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, }, ], ) -async def test_unload_entry(hass: HomeAssistant, options) -> None: +async def test_unload_entry(hass: HomeAssistant, options: dict[str, Any]) -> None: """Test that unloading an entry works.""" entry = MockConfigEntry( domain=DOMAIN, From aa5ee6490acf54a7682263b8fcc968962950317b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:47:44 +0200 Subject: [PATCH 035/109] Update hassil to 3.11.0 (#177912) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- homeassistant/components/assist_satellite/manifest.json | 2 +- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/assist_satellite/manifest.json b/homeassistant/components/assist_satellite/manifest.json index 253036290fd9..bff8c3cc670c 100644 --- a/homeassistant/components/assist_satellite/manifest.json +++ b/homeassistant/components/assist_satellite/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/assist_satellite", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["hassil==3.10.0"] + "requirements": ["hassil==3.11.0"] } diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 60a97ce4d772..27727f691b79 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["hassil==3.10.0", "home-assistant-intents==2026.7.30"] + "requirements": ["hassil==3.11.0", "home-assistant-intents==2026.7.30"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 86a5110b6eb4..20af0f536240 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ go2rtc-client==0.4.0 ha-ffmpeg==3.2.2 habluetooth==6.26.5 hass-nabucasa==2.2.0 -hassil==3.10.0 +hassil==3.11.0 home-assistant-bluetooth==2.0.0 home-assistant-frontend==20260729.3 home-assistant-intents==2026.7.30 diff --git a/requirements.txt b/requirements.txt index 5c0c6fd46d07..b291a8420f24 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ cryptography==48.0.1 fnv-hash-fast==2.0.3 ha-ffmpeg==3.2.2 hass-nabucasa==2.2.0 -hassil==3.10.0 +hassil==3.11.0 home-assistant-bluetooth==2.0.0 home-assistant-intents==2026.7.30 httpx==0.28.1 diff --git a/requirements_all.txt b/requirements_all.txt index db6ad4e499b2..9f2fbdb261ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1238,7 +1238,7 @@ hass-splunk==0.1.4 # homeassistant.components.assist_satellite # homeassistant.components.conversation -hassil==3.10.0 +hassil==3.11.0 # homeassistant.components.jewish_calendar hdate[astral]==1.2.1 From 3c2d169c96acbb864fb609442a54f812f8e9bdd8 Mon Sep 17 00:00:00 2001 From: Sankalp Thakur <31366524+sankalpsthakur@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:20:04 +0530 Subject: [PATCH 036/109] Migrate growatt_server off hass.data[DOMAIN] (#177925) --- .../components/growatt_server/__init__.py | 14 +++++++------- homeassistant/components/growatt_server/const.py | 5 ----- tests/components/growatt_server/test_init.py | 8 +++----- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py index abf9118c8fa5..9bb94e9ea58f 100644 --- a/homeassistant/components/growatt_server/__init__.py +++ b/homeassistant/components/growatt_server/__init__.py @@ -23,7 +23,6 @@ Error handling pattern for reauth: → raise ConfigEntryAuthFailed - All other errors → ConfigEntryError (setup) or UpdateFailed (coordinator) """ -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from collections.abc import Mapping import datetime @@ -49,7 +48,6 @@ from homeassistant.helpers.typing import ConfigType from .const import ( AUTH_API_TOKEN, AUTH_PASSWORD, - CACHED_API_KEY, CONF_AUTH_TYPE, CONF_PLANT_ID, DEFAULT_PLANT_ID, @@ -70,6 +68,11 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +# Temporary handoff of authenticated Classic API sessions from async_migrate_entry +# to async_setup_entry. Avoids a second login() during the same startup, which +# would hit Growatt's 5-minute per-endpoint rate limit. Popped after first use. +_CACHED_APIS: dict[str, growattServer.GrowattApi] = {} + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Growatt Server integration.""" @@ -196,8 +199,7 @@ async def async_migrate_entry( ) # Cache the logged-in API instance for reuse in async_setup_entry() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][f"{CACHED_API_KEY}{config_entry.entry_id}"] = api + _CACHED_APIS[config_entry.entry_id] = api _LOGGER.info( "Migrated config entry to use specific plant_id '%s'", @@ -347,9 +349,7 @@ async def async_setup_entry( # Check if migration cached an authenticated API instance for us to reuse. # This avoids calling login() twice (once in migration, once here) which # would trigger rate limiting. - cached_api = hass.data.get(DOMAIN, {}).pop( - f"{CACHED_API_KEY}{config_entry.entry_id}", None - ) + cached_api = _CACHED_APIS.pop(config_entry.entry_id, None) if cached_api: # Reuse the logged-in API instance from migration (rate limit optimization) diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index 02c40a17aaef..a3478c418ed4 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -55,11 +55,6 @@ BATT_MODE_LOAD_FIRST = 0 BATT_MODE_BATTERY_FIRST = 1 BATT_MODE_GRID_FIRST = 2 -# Internal key prefix for caching authenticated API instance -# Used to pass logged-in session from async_migrate_entry to async_setup_entry -# to avoid double login() calls that trigger API rate limiting -CACHED_API_KEY = "_cached_api_" - # Supported device types for coordinator creation SUPPORTED_DEVICE_TYPES = ["inverter", "tlx", "storage", "mix", "min", "sph"] diff --git a/tests/components/growatt_server/test_init.py b/tests/components/growatt_server/test_init.py index 2519ea019d32..f2499de6fd28 100644 --- a/tests/components/growatt_server/test_init.py +++ b/tests/components/growatt_server/test_init.py @@ -10,10 +10,10 @@ import pytest import requests from syrupy.assertion import SnapshotAssertion +from homeassistant.components.growatt_server import _CACHED_APIS from homeassistant.components.growatt_server.const import ( AUTH_API_TOKEN, AUTH_PASSWORD, - CACHED_API_KEY, CONF_AUTH_TYPE, CONF_PLANT_ID, DEFAULT_PLANT_ID, @@ -733,10 +733,8 @@ async def test_setup_reuses_cached_api_from_migration( # This confirms setup did NOT resolve plant_id again (optimization working) mock_growatt_classic_api.plant_list.assert_called_once_with(123456) - # Verify the cached API was removed after use (should not be in hass.data anymore) - assert f"{CACHED_API_KEY}{mock_config_entry.entry_id}" not in hass.data.get( - DOMAIN, {} - ) + # Verify the cached API was removed after use (one-time handoff) + assert mock_config_entry.entry_id not in _CACHED_APIS async def test_migrate_failure_returns_false( From de40c4e667435bc514c1f0a4d1fe280276975b49 Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Mon, 3 Aug 2026 13:50:18 +0200 Subject: [PATCH 037/109] Remove hugo-vrijswijk from `backblaze_b2` codeowners (#178067) --- CODEOWNERS | 4 ++-- homeassistant/components/backblaze_b2/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 28bd9e6dbefd..6399a23dcb90 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -215,8 +215,8 @@ CLAUDE.md @home-assistant/core /homeassistant/components/azure_service_bus/ @hfurubotten /homeassistant/components/azure_storage/ @zweckj /tests/components/azure_storage/ @zweckj -/homeassistant/components/backblaze_b2/ @hugo-vrijswijk @ElCruncharino -/tests/components/backblaze_b2/ @hugo-vrijswijk @ElCruncharino +/homeassistant/components/backblaze_b2/ @ElCruncharino +/tests/components/backblaze_b2/ @ElCruncharino /homeassistant/components/backup/ @home-assistant/core /tests/components/backup/ @home-assistant/core /homeassistant/components/baf/ @bdraco @jfroy diff --git a/homeassistant/components/backblaze_b2/manifest.json b/homeassistant/components/backblaze_b2/manifest.json index 13d3521519b8..4fca7a86aefb 100644 --- a/homeassistant/components/backblaze_b2/manifest.json +++ b/homeassistant/components/backblaze_b2/manifest.json @@ -1,7 +1,7 @@ { "domain": "backblaze_b2", "name": "Backblaze B2", - "codeowners": ["@hugo-vrijswijk", "@ElCruncharino"], + "codeowners": ["@ElCruncharino"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/backblaze_b2", "integration_type": "service", From 603b8fbf78630c5694fb66343130d9d55c3427a3 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Mon, 3 Aug 2026 21:50:36 +1000 Subject: [PATCH 038/109] Add UPL-1.0 to OSI approved SPDX licenses (#177940) Signed-off-by: Avi Miller --- script/licenses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/script/licenses.py b/script/licenses.py index 0d4dac8d84b7..ae9279728336 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -90,6 +90,7 @@ OSI_APPROVED_LICENSES_SPDX = { "PSF-2.0", "Python-2.0", "Unlicense", + "UPL-1.0", "Zlib", "ZPL-2.1", } From 5c58ecd17a145a4c025bc4c8a50cc3e09cda61ab Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 13:51:22 +0200 Subject: [PATCH 039/109] Adapt tado to set via_device_id in DeviceInfo (#178079) --- homeassistant/components/tado/entity.py | 13 +++-- tests/components/tado/fixtures/devices.json | 13 +++++ .../tado/snapshots/test_binary_sensor.ambr | 51 +++++++++++++++++++ .../tado/snapshots/test_diagnostics.ambr | 14 +++++ tests/components/tado/test_init.py | 21 ++++++++ 5 files changed, 108 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tado/entity.py b/homeassistant/components/tado/entity.py index bb92880d99b4..2771d86d8e94 100644 --- a/homeassistant/components/tado/entity.py +++ b/homeassistant/components/tado/entity.py @@ -2,6 +2,7 @@ import logging +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -28,11 +29,15 @@ class TadoDeviceEntity(TadoCoordinatorEntity): self._device_info = device_info self.device_name = device_info["serialNo"] self.device_id = device_info["shortSerialNo"] - via_device: tuple[str, str] | None = None + via_device_id: str | None = None if device_info["deviceType"] not in TADO_BRIDGE_MODELS: for device in coordinator.data["device"].values(): if device["deviceType"] in TADO_BRIDGE_MODELS: - via_device = (DOMAIN, device["shortSerialNo"]) + via_device_id = dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, device["shortSerialNo"]), + config_entry_id=coordinator.config_entry.entry_id, + ) break self._attr_device_info = DeviceInfo( @@ -43,8 +48,8 @@ class TadoDeviceEntity(TadoCoordinatorEntity): sw_version=device_info["currentFwVersion"], model=device_info["deviceType"], ) - if via_device: - self._attr_device_info["via_device"] = via_device + if via_device_id: + self._attr_device_info["via_device_id"] = via_device_id class TadoHomeEntity(TadoCoordinatorEntity): diff --git a/tests/components/tado/fixtures/devices.json b/tests/components/tado/fixtures/devices.json index a9313ae051b2..144790c94ec3 100644 --- a/tests/components/tado/fixtures/devices.json +++ b/tests/components/tado/fixtures/devices.json @@ -34,5 +34,18 @@ "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] }, "childLockEnabled": false + }, + { + "deviceType": "IB01", + "currentFwVersion": "62.7", + "characteristics": { + "capabilities": [] + }, + "serialNo": "IB1234", + "shortSerialNo": "IB1234", + "connectionState": { + "value": true, + "timestamp": "2020-03-23T18:30:07.377Z" + } } ] diff --git a/tests/components/tado/snapshots/test_binary_sensor.ambr b/tests/components/tado/snapshots/test_binary_sensor.ambr index c5dda35039e1..29d927509d15 100644 --- a/tests/components/tado/snapshots/test_binary_sensor.ambr +++ b/tests/components/tado/snapshots/test_binary_sensor.ambr @@ -869,6 +869,57 @@ 'state': 'off', }) # --- +# name: test_entities[binary_sensor.ib1234_connection_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.ib1234_connection_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Connection state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connection state', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'connection_state', + 'unique_id': 'connection state IB1234 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.ib1234_connection_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'connectivity', + : 'IB1234 Connection state', + }), + 'context': , + 'entity_id': 'binary_sensor.ib1234_connection_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_entities[binary_sensor.second_water_heater_second_water_heater_connectivity-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/tado/snapshots/test_diagnostics.ambr b/tests/components/tado/snapshots/test_diagnostics.ambr index 1125cc777065..1ea74be559ed 100644 --- a/tests/components/tado/snapshots/test_diagnostics.ambr +++ b/tests/components/tado/snapshots/test_diagnostics.ambr @@ -3,6 +3,20 @@ dict({ 'data': dict({ 'device': dict({ + 'IB1234': dict({ + 'characteristics': dict({ + 'capabilities': list([ + ]), + }), + 'connectionState': dict({ + 'timestamp': '2020-03-23T18:30:07.377Z', + 'value': True, + }), + 'currentFwVersion': '62.7', + 'deviceType': 'IB01', + 'serialNo': 'IB1234', + 'shortSerialNo': 'IB1234', + }), 'WR1': dict({ 'accessPointWiFi': dict({ 'ssid': 'tado8480', diff --git a/tests/components/tado/test_init.py b/tests/components/tado/test_init.py index 10acd8eef594..afbbf8a25eac 100644 --- a/tests/components/tado/test_init.py +++ b/tests/components/tado/test_init.py @@ -6,10 +6,12 @@ import time from unittest.mock import patch from PyTado.http import Http +import pytest from homeassistant.components.tado import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -32,6 +34,25 @@ async def test_v1_migration(hass: HomeAssistant) -> None: assert CONF_USERNAME not in entry.data +@pytest.mark.usefixtures("init_integration") +async def test_device_via_device_links( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test that child devices link to the bridge via via_device_id.""" + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + + bridge_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "IB1234"), config_entry.entry_id + ) + assert bridge_device is not None + + child_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "WR1"), config_entry.entry_id + ) + assert child_device is not None + assert child_device.via_device_id == bridge_device.id + + async def test_refresh_token_threading_lock(hass: HomeAssistant) -> None: """Test that threading.Lock in Http._refresh_token serializes concurrent calls.""" From 80484d218f16cdadf7f1ec2db1ff1aba25475609 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 14:09:02 +0200 Subject: [PATCH 040/109] Fix via_device race in arcam_fmj (#178073) --- .../components/arcam_fmj/__init__.py | 12 ++++++++ .../components/arcam_fmj/coordinator.py | 3 -- tests/components/arcam_fmj/test_init.py | 30 +++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 tests/components/arcam_fmj/test_init.py diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py index f389bc55a2b9..90d58791a57a 100644 --- a/homeassistant/components/arcam_fmj/__init__.py +++ b/homeassistant/components/arcam_fmj/__init__.py @@ -10,6 +10,7 @@ from arcam.fmj.client import Client from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from .const import DEFAULT_SCAN_INTERVAL from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator, ArcamFmjRuntimeData @@ -29,6 +30,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ArcamFmjConfigEntry) -> coordinator = ArcamFmjCoordinator(hass, entry, client, zone) coordinators[zone] = coordinator + # Register the zone 1 device before forwarding platforms so the other zones' + # entities can link to it via via_device_id. + device_registry = dr.async_get(hass) + zone1_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + **coordinators[1].device_info, + ) + for zone, coordinator in coordinators.items(): + if zone != 1: + coordinator.device_info["via_device_id"] = zone1_device.id + entry.runtime_data = ArcamFmjRuntimeData(client, coordinators) entry.async_create_background_task( diff --git a/homeassistant/components/arcam_fmj/coordinator.py b/homeassistant/components/arcam_fmj/coordinator.py index 2c4cf7a0811a..65a6457def4b 100644 --- a/homeassistant/components/arcam_fmj/coordinator.py +++ b/homeassistant/components/arcam_fmj/coordinator.py @@ -70,9 +70,6 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]): ) self.zone_unique_id = f"{unique_id}-{zone}" - if zone != 1: - self.device_info["via_device"] = (DOMAIN, unique_id) - @override async def _async_update_data(self) -> None: """Fetch data for manual refresh.""" diff --git a/tests/components/arcam_fmj/test_init.py b/tests/components/arcam_fmj/test_init.py new file mode 100644 index 000000000000..2c7d31087ddf --- /dev/null +++ b/tests/components/arcam_fmj/test_init.py @@ -0,0 +1,30 @@ +"""Tests for the Arcam FMJ config entry setup.""" + +import pytest + +from homeassistant.components.arcam_fmj.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import MOCK_UUID + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("player_setup") +async def test_device_via_device_links( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the zone 2 device links to the zone 1 device via via_device_id.""" + zone1_device = device_registry.async_get_device_by_identifier( + (DOMAIN, MOCK_UUID), mock_config_entry.entry_id + ) + assert zone1_device is not None + + zone2_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{MOCK_UUID}-2"), mock_config_entry.entry_id + ) + assert zone2_device is not None + assert zone2_device.via_device_id == zone1_device.id From 54ea899f3f59779cb6dec401c3f6dbc8c6524dc5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 14:09:25 +0200 Subject: [PATCH 041/109] Update integrations to call async_remove_device (part 1) (#177962) --- .../components/alexa_devices/coordinator.py | 5 +---- homeassistant/components/aqvify/coordinator.py | 5 +---- homeassistant/components/bosch_shc/entity.py | 2 +- homeassistant/components/comelit/coordinator.py | 5 +---- homeassistant/components/comelit/utils.py | 17 +++++------------ homeassistant/components/duco/sensor.py | 5 +---- homeassistant/components/freshr/coordinator.py | 5 +---- homeassistant/components/fyta/coordinator.py | 5 +---- .../components/home_connect/coordinator.py | 5 +---- homeassistant/components/homee/__init__.py | 5 +---- homeassistant/components/hue/v1/helpers.py | 4 +--- .../husqvarna_automower/coordinator.py | 5 +---- homeassistant/components/imou/coordinator.py | 5 +---- .../libre_hardware_monitor/coordinator.py | 5 +---- 14 files changed, 18 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index 1dcf47561229..4ff28af4d057 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -240,10 +240,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): (DOMAIN, serial_num), self.config_entry.entry_id ) if device: - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(device.id) async def _async_remove_routine_stale( self, diff --git a/homeassistant/components/aqvify/coordinator.py b/homeassistant/components/aqvify/coordinator.py index d1af79a5c647..c347ac83c5ab 100644 --- a/homeassistant/components/aqvify/coordinator.py +++ b/homeassistant/components/aqvify/coordinator.py @@ -131,10 +131,7 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]): self.config_entry.entry_id, ) if device: - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(device.id) self.previous_devices = current_devices device_data = {} diff --git a/homeassistant/components/bosch_shc/entity.py b/homeassistant/components/bosch_shc/entity.py index b43a07741057..ae719deb95ae 100644 --- a/homeassistant/components/bosch_shc/entity.py +++ b/homeassistant/components/bosch_shc/entity.py @@ -21,7 +21,7 @@ async def async_remove_devices( (DOMAIN, entity.device_id), entry_id ) if device is not None: - dev_registry.async_update_device(device.id, remove_config_entry_id=entry_id) + dev_registry.async_remove_device(device.id) class SHCBaseEntity(Entity): diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index 4086be646f1c..a4dbc9957c67 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -153,10 +153,7 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]): (DOMAIN, identifier), self.config_entry.entry_id ) if device: - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(device.id) class ComelitSerialBridge(ComelitBaseCoordinator[T]): diff --git a/homeassistant/components/comelit/utils.py b/homeassistant/components/comelit/utils.py index 6a1d13e2bc70..9c59e36a3481 100644 --- a/homeassistant/components/comelit/utils.py +++ b/homeassistant/components/comelit/utils.py @@ -68,13 +68,13 @@ async def cleanup_stale_entity( identifiers.append(f"{config_entry.entry_id}-{device.type}-{device.index}") if len(identifiers) > 0: - _async_remove_state_config_entry_from_devices(hass, identifiers, config_entry) + _async_remove_stale_devices(hass, identifiers, config_entry) -def _async_remove_state_config_entry_from_devices( +def _async_remove_stale_devices( hass: HomeAssistant, identifiers: list[str], config_entry: ConfigEntry ) -> None: - """Remove config entry from device.""" + """Remove stale devices.""" device_registry = dr.async_get(hass) for identifier in identifiers: @@ -82,15 +82,8 @@ def _async_remove_state_config_entry_from_devices( (DOMAIN, identifier), config_entry.entry_id ) if device: - LOGGER.info( - "Removing config entry %s from device %s", - config_entry.title, - device.name, - ) - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=config_entry.entry_id, - ) + LOGGER.info("Removing device %s", device.name) + device_registry.async_remove_device(device.id) def bridge_api_call[_T: ComelitBridgeBaseEntity, **_P]( diff --git a/homeassistant/components/duco/sensor.py b/homeassistant/components/duco/sensor.py index b15a489cef2e..2c157d473e00 100644 --- a/homeassistant/components/duco/sensor.py +++ b/homeassistant/components/duco/sensor.py @@ -262,10 +262,7 @@ async def async_setup_entry( (DOMAIN, f"{mac}_{node_id}"), entry.entry_id ) if device: - device_reg.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - ) + device_reg.async_remove_device(device.id) known_nodes.difference_update(stale_node_ids) known_box_sensors.difference_update( { diff --git a/homeassistant/components/freshr/coordinator.py b/homeassistant/components/freshr/coordinator.py index 7be8fbf13944..557e0c4b4e18 100644 --- a/homeassistant/components/freshr/coordinator.py +++ b/homeassistant/components/freshr/coordinator.py @@ -88,10 +88,7 @@ class FreshrDevicesCoordinator(DataUpdateCoordinator[dict[str, DeviceSummary]]): if device := device_registry.async_get_device_by_identifier( (DOMAIN, device_id), self.config_entry.entry_id ): - device_registry.async_update_device( - device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(device.id) return current diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py index 5ed5d6a538d0..71cff99c6adf 100644 --- a/homeassistant/components/fyta/coordinator.py +++ b/homeassistant/components/fyta/coordinator.py @@ -101,10 +101,7 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, Plant]]): (DOMAIN, f"{self.config_entry.entry_id}-{plant_id}"), self.config_entry.entry_id, ): - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(device.id) _LOGGER.debug("Device removed from device registry: %s", device.id) # add new devices diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 8028fdd14edc..7e85e2957db7 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -368,10 +368,7 @@ class HomeConnectApplianceCoordinator(DataUpdateCoordinator[HomeConnectAppliance (DOMAIN, self.data.info.ha_id), self._config_entry.entry_id ) if device: - self.device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self._config_entry.entry_id, - ) + self.device_registry.async_remove_device(device.id) for ( listener, context, diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 9c7ec87def93..0b8b8f2a707c 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -117,10 +117,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo ) if device: _LOGGER.info("Removing device %s", device.name) - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device.id) homee.add_nodes_listener(_remove_node_callback) diff --git a/homeassistant/components/hue/v1/helpers.py b/homeassistant/components/hue/v1/helpers.py index 1cf5bc45c55d..c5abd58341d7 100644 --- a/homeassistant/components/hue/v1/helpers.py +++ b/homeassistant/components/hue/v1/helpers.py @@ -25,9 +25,7 @@ async def remove_devices(bridge, api_ids, current): (DOMAIN, entity.device_id), bridge.config_entry.entry_id ) if device is not None: - dev_registry.async_update_device( - device.id, remove_config_entry_id=bridge.config_entry.entry_id - ) + dev_registry.async_remove_device(device.id) for item_id in removed_items: del current[item_id] diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 91b460bb7a64..1fe1c7b070e7 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -234,10 +234,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): (DOMAIN, mower_id), self.config_entry.entry_id ) if dev is not None: - device_registry.async_update_device( - device_id=dev.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(dev.id) new_devices = current_devices - registered_devices if new_devices: diff --git a/homeassistant/components/imou/coordinator.py b/homeassistant/components/imou/coordinator.py index 594e35318a26..01b09cc344b2 100644 --- a/homeassistant/components/imou/coordinator.py +++ b/homeassistant/components/imou/coordinator.py @@ -152,10 +152,7 @@ class ImouDataUpdateCoordinator(DataUpdateCoordinator[None]): if device := device_registry.async_get_device_by_identifier( (DOMAIN, device_key), self.config_entry.entry_id ): - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(device.id) if new_keys := current_keys - known_keys: _LOGGER.debug("New Imou device(s) found: %s", ", ".join(new_keys)) diff --git a/homeassistant/components/libre_hardware_monitor/coordinator.py b/homeassistant/components/libre_hardware_monitor/coordinator.py index 79d8a5906773..4f74e94138ed 100644 --- a/homeassistant/components/libre_hardware_monitor/coordinator.py +++ b/homeassistant/components/libre_hardware_monitor/coordinator.py @@ -136,9 +136,6 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor _LOGGER.debug( "Removing device: %s", self._previous_devices[device_id] ) - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self._entry_id, - ) + device_registry.async_remove_device(device.id) self._previous_devices = detected_devices From 54a17f3ed8c47179e83eda53e73fbd4eb083f044 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 14:09:40 +0200 Subject: [PATCH 042/109] Make device trackers create their own devices (#178003) --- .../components/device_tracker/entity.py | 51 +++-- .../components/device_tracker/test_entity.py | 182 +++++++++++++++++- .../mikrotik/test_device_tracker.py | 4 +- .../unifi/snapshots/test_device_tracker.ambr | 6 +- 4 files changed, 201 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/device_tracker/entity.py b/homeassistant/components/device_tracker/entity.py index ffa8a1197fc6..c813c5f2a8f9 100644 --- a/homeassistant/components/device_tracker/entity.py +++ b/homeassistant/components/device_tracker/entity.py @@ -646,13 +646,13 @@ class ScannerEntity( @override def entity_registry_enabled_default(self) -> bool: """Return if entity is enabled by default.""" - # If mac_address is None, we can never find a device entry. + # If mac_address is None, we can never find a matching device. return ( - # Do not disable if we won't activate our attach to device logic + # Do not disable if we won't activate our own device registration logic self.mac_address is None or self.device_info is not None - # Disable if we automatically attach but there is no device - or self.find_device_entry() is not None + # Disable if the tracked device is not known to any integration + or self._async_mac_address_registered() ) @callback @@ -681,12 +681,14 @@ class ScannerEntity( ) @callback - def find_device_entry(self) -> dr.DeviceEntry | None: - """Return device entry.""" + def _async_mac_address_registered(self) -> bool: + """Return if a device with the entity's MAC address is registered.""" assert self.mac_address is not None - return dr.async_get(self.hass).async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)} + return bool( + dr.async_get(self.hass).async_get_devices( + connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)} + ) ) @override @@ -697,7 +699,7 @@ class ScannerEntity( not self.registry_entry or not self.platform.config_entry or not self.mac_address - or (device_entry := self.find_device_entry()) is None + or not self._async_mac_address_registered() # Entities should not have a device info. We opt them out # of this logic if they do. or self.device_info @@ -707,27 +709,18 @@ class ScannerEntity( await super().async_internal_added_to_hass() return - dev_reg = dr.async_get(self.hass) - # find_device_entry may return a synthesized pre-migration composite whose id is - # not a real device and can't be assigned to an entity; resolve it to the split - # owned by this config entry so we attach to a concrete device. - if device_entry.id not in dev_reg.devices: - device_entry = next( - ( - split - for split in dev_reg.async_get_devices_for_composite_device_id( - device_entry.id - ) - if split.config_entry_id == self.platform.config_entry.entry_id - ), - None, - ) + # Register our own device now that the tracked device is known to another + # integration; matching the split of a pre-migration composite device prunes + # the identifiers and connections copied from the composite. + device_entry = dr.async_get(self.hass).async_get_or_create( + config_entry_id=self.platform.config_entry.entry_id, + config_subentry_id=self.registry_entry.config_subentry_id, + connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}, + default_name=self.hostname or self.mac_address, + ) - # Attach entry to device - if ( - device_entry is not None - and self.registry_entry.device_id != device_entry.id - ): + # Link the entity's registry entry to the device + if self.registry_entry.device_id != device_entry.id: self.registry_entry = er.async_get(self.hass).async_update_entity( self.entity_id, device_id=device_entry.id ) diff --git a/tests/components/device_tracker/test_entity.py b/tests/components/device_tracker/test_entity.py index c2ffa6bcfe4a..b4edf4d1c30d 100644 --- a/tests/components/device_tracker/test_entity.py +++ b/tests/components/device_tracker/test_entity.py @@ -24,7 +24,12 @@ from homeassistant.components.device_tracker import ( TrackingType, ) from homeassistant.components.zone import ATTR_PASSIVE, ATTR_RADIUS -from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow +from homeassistant.config_entries import ( + ConfigEntry, + ConfigEntryState, + ConfigFlow, + ConfigSubentryData, +) from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_FRIENDLY_NAME, @@ -117,6 +122,7 @@ async def create_mock_platform( hass: HomeAssistant, config_entry: MockConfigEntry, entities: list[Entity], + config_subentry_id: str | None = None, ) -> MockConfigEntry: """Create a device tracker platform with the specified entities.""" @@ -126,7 +132,7 @@ async def create_mock_platform( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test event platform via config entry.""" - async_add_entities(entities) + async_add_entities(entities, config_subentry_id=config_subentry_id) mock_platform( hass, @@ -1292,7 +1298,7 @@ async def test_scanner_entity_state( ATTR_IP: ip_address, ATTR_MAC: mac_address, ATTR_HOST_NAME: hostname, - ATTR_FRIENDLY_NAME: "Device from other integration", + ATTR_FRIENDLY_NAME: hostname, } assert entity_state.state == STATE_NOT_HOME @@ -1664,10 +1670,10 @@ async def test_scanner_entity_composite_device_without_own_split( entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: - """A composite with no split owned by the scanner's config entry attaches nothing. + """A composite with no split owned by the scanner's config entry. - The composite id is not a real device and can't be assigned to an entity, so with no - split to resolve to the entity is added without a device instead of raising. + The tracked device is known to other integrations, so the scanner registers + its own device instead of attaching to another config entry's split. """ mac = TEST_MAC_ADDRESS other_entry_1 = MockConfigEntry(domain="other_1") @@ -1696,10 +1702,170 @@ async def test_scanner_entity_composite_device_without_own_split( scanner_entity.entity_id = "device_tracker.composite_scanner" await create_mock_platform(hass, config_entry, [scanner_entity]) - # Added without a device rather than raising on the un-assignable composite id + # The scanner registers its own device instead of using a foreign split entity_entry = entity_registry.async_get("device_tracker.composite_scanner") assert entity_entry is not None - assert entity_entry.device_id is None + assert entity_entry.device_id is not None + own_device = device_registry.async_get(entity_entry.device_id) + assert own_device is not None + assert own_device.config_entry_id == config_entry.entry_id + + +async def test_scanner_entity_attaches_to_own_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the scanner reuses its config entry's device when several share the MAC.""" + mac = TEST_MAC_ADDRESS + other_entry = MockConfigEntry(domain="other") + other_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={("other", "x")}, + ) + own_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + ) + + scanner_entity = MockScannerEntity(mac_address=mac, unique_id=f"{mac}_scanner") + scanner_entity.entity_id = "device_tracker.shared_mac_scanner" + await create_mock_platform(hass, config_entry, [scanner_entity]) + + entity_entry = entity_registry.async_get("device_tracker.shared_mac_scanner") + assert entity_entry is not None + assert entity_entry.device_id == own_device.id + + +@pytest.mark.parametrize( + ("hostname", "expected_device_name"), + [ + pytest.param("tracked-host", "tracked-host", id="hostname"), + pytest.param(None, TEST_MAC_ADDRESS, id="no-hostname-falls-back-to-mac"), + ], +) +async def test_scanner_entity_registers_own_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + hostname: str | None, + expected_device_name: str, +) -> None: + """Test the scanner registers its own device when the MAC is known elsewhere.""" + mac = TEST_MAC_ADDRESS + foreign_ids = set() + for domain in ("other_1", "other_2"): + entry = MockConfigEntry(domain=domain) + entry.add_to_hass(hass) + foreign_ids.add( + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={(domain, "x")}, + ).id + ) + + scanner_entity = MockScannerEntity( + mac_address=mac, hostname=hostname, unique_id=f"{mac}_scanner" + ) + scanner_entity.entity_id = "device_tracker.shared_mac_scanner" + await create_mock_platform(hass, config_entry, [scanner_entity]) + + entity_entry = entity_registry.async_get("device_tracker.shared_mac_scanner") + assert entity_entry is not None + assert entity_entry.device_id is not None + assert entity_entry.device_id not in foreign_ids + own_device = device_registry.async_get(entity_entry.device_id) + assert own_device is not None + assert own_device.config_entry_id == config_entry.entry_id + assert own_device.connections == {(dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac))} + assert own_device.name == expected_device_name + + +async def test_scanner_entity_own_device_in_subentry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the scanner's own device is created in the entity's config subentry.""" + mac = TEST_MAC_ADDRESS + subentry_id = "mock-subentry-id" + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + subentries_data=( + ConfigSubentryData( + data={}, + subentry_id=subentry_id, + subentry_type="test", + title="Mock subentry", + unique_id=None, + ), + ), + ) + config_entry.add_to_hass(hass) + other_entry = MockConfigEntry(domain="other") + other_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={("other", "x")}, + ) + + scanner_entity = MockScannerEntity(mac_address=mac, unique_id=f"{mac}_scanner") + scanner_entity.entity_id = "device_tracker.subentry_scanner" + await create_mock_platform( + hass, config_entry, [scanner_entity], config_subentry_id=subentry_id + ) + + entity_entry = entity_registry.async_get("device_tracker.subentry_scanner") + assert entity_entry is not None + assert entity_entry.config_subentry_id == subentry_id + assert entity_entry.device_id is not None + own_device = device_registry.async_get(entity_entry.device_id) + assert own_device is not None + assert own_device.config_entry_id == config_entry.entry_id + assert own_device.config_subentry_id == subentry_id + + +async def test_scanner_entity_prunes_composite_identifiers( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test re-registration prunes composite relics from the scanner's split device. + + Splits of a pre-migration composite keep the identifiers and connections copied + from the composite until the owning config entry re-registers the device; the + scanner's registration provides only the MAC connection, dropping the rest. + """ + mac = TEST_MAC_ADDRESS + own_split = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={("other", "copied-identifier")}, + ) + device_registry.devices[own_split.id] = attr.evolve( + own_split, + composite_device_id="composite00000000000000000000000", + has_composite_identifiers=True, + ) + + scanner_entity = MockScannerEntity(mac_address=mac, unique_id=f"{mac}_scanner") + scanner_entity.entity_id = "device_tracker.composite_scanner" + await create_mock_platform(hass, config_entry, [scanner_entity]) + + entity_entry = entity_registry.async_get("device_tracker.composite_scanner") + assert entity_entry is not None + assert entity_entry.device_id == own_split.id + device = device_registry.async_get(own_split.id) + assert device is not None + assert device.identifiers == set() + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac))} async def test_connected_device_registered( diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index ecf841b01c64..fb835c6fa512 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -193,7 +193,7 @@ async def test_device_trackers_numerical_name(hass: HomeAssistant) -> None: device_3 = hass.states.get("device_tracker.123") assert device_3 assert device_3.state == "home" - assert device_3.attributes["friendly_name"] == "Device 2 123" + assert device_3.attributes["friendly_name"] == "123 123" assert device_3.attributes["ip"] == "0.0.0.3" assert device_3.attributes["mac"] == "00:00:00:00:00:03" assert device_3.attributes["host_name"] == "123" @@ -214,7 +214,7 @@ async def test_hub_wifiwave2(hass: HomeAssistant) -> None: device_4 = hass.states.get("device_tracker.device_4") assert device_4 assert device_4.state == "home" - assert device_4.attributes["friendly_name"] == "Device 3 Device_4" + assert device_4.attributes["friendly_name"] == "Device_4 Device_4" assert device_4.attributes["ip"] == "0.0.0.4" assert device_4.attributes["mac"] == "00:00:00:00:00:04" assert device_4.attributes["host_name"] == "Device_4" diff --git a/tests/components/unifi/snapshots/test_device_tracker.ambr b/tests/components/unifi/snapshots/test_device_tracker.ambr index 753021c1c5d4..bfdb998d0265 100644 --- a/tests/components/unifi/snapshots/test_device_tracker.ambr +++ b/tests/components/unifi/snapshots/test_device_tracker.ambr @@ -41,7 +41,7 @@ # name: test_entity_and_device_data[site_payload0-device_payload0-client_payload0][device_tracker.switch_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Device 6 Switch 1', + : '00:00:00:00:01:01 Switch 1', : list([ 'zone.home', ]), @@ -100,7 +100,7 @@ # name: test_entity_and_device_data[site_payload0-device_payload0-client_payload0][device_tracker.wd_client_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Device 1 wd_client_1', + : 'wd_client_1 wd_client_1', : 'wd_client_1', : list([ ]), @@ -158,7 +158,7 @@ # name: test_entity_and_device_data[site_payload0-device_payload0-client_payload0][device_tracker.ws_client_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Device 0 ws_client_1', + : 'ws_client_1 ws_client_1', : 'ws_client_1', : list([ ]), From de0aaa5a7f92dd4232edb26b04cf6151d48b0ec2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 14:19:43 +0200 Subject: [PATCH 043/109] Adapt notion to set via_device_id in DeviceInfo (#178078) --- homeassistant/components/notion/entity.py | 8 ++- tests/components/notion/test_init.py | 63 +++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/components/notion/test_init.py diff --git a/homeassistant/components/notion/entity.py b/homeassistant/components/notion/entity.py index d8179aca51b8..0d1a9fdeee16 100644 --- a/homeassistant/components/notion/entity.py +++ b/homeassistant/components/notion/entity.py @@ -50,7 +50,13 @@ class NotionEntity(CoordinatorEntity[NotionDataUpdateCoordinator]): ) if bridge := self._async_get_bridge(bridge_id): - self._attr_device_info["via_device"] = (DOMAIN, bridge.hardware_id) + self._attr_device_info["via_device_id"] = ( + dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, bridge.hardware_id), + config_entry_id=self.coordinator.config_entry.entry_id, + ) + ) self._attr_extra_state_attributes = {} self._attr_unique_id = listener_id diff --git a/tests/components/notion/test_init.py b/tests/components/notion/test_init.py new file mode 100644 index 000000000000..9706e877d21f --- /dev/null +++ b/tests/components/notion/test_init.py @@ -0,0 +1,63 @@ +"""Test Notion setup.""" + +from copy import deepcopy +from typing import Any + +from aionotion.listener.models import ListenerKind +import pytest + +from homeassistant.components.notion.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + +# The id of the bridge in the bridge fixture; the sensor must reference it to link. +BRIDGE_ID = 12345 +BRIDGE_HARDWARE_ID = "0x0000000000000012" +SENSOR_HARDWARE_ID = "0x0000000000000034" + + +@pytest.fixture(name="data_bridge") +def data_bridge_fixture(data_bridge: dict[str, Any]) -> dict[str, Any]: + """Give the bridge a hardware id distinct from the sensor.""" + data = deepcopy(data_bridge) + data["base_stations"][0]["hardware_id"] = BRIDGE_HARDWARE_ID + return data + + +@pytest.fixture(name="data_sensor") +def data_sensor_fixture(data_sensor: dict[str, Any]) -> dict[str, Any]: + """Link the sensor to the bridge and give it a distinct hardware id.""" + data = deepcopy(data_sensor) + data["sensors"][0]["hardware_id"] = SENSOR_HARDWARE_ID + data["sensors"][0]["bridge"]["id"] = BRIDGE_ID + data["sensors"][0]["bridge"]["hardware_id"] = BRIDGE_HARDWARE_ID + return data + + +@pytest.fixture(name="data_listener") +def data_listener_fixture(data_listener: dict[str, Any]) -> dict[str, Any]: + """Use a listener kind that maps to an entity so a sensor device is created.""" + data = deepcopy(data_listener) + data["listeners"][0]["definition_id"] = ListenerKind.HINGED_WINDOW.value + return data + + +@pytest.mark.usefixtures("setup_config_entry") +async def test_device_via_device_links( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that a sensor device links to its bridge via via_device_id.""" + bridge_device = device_registry.async_get_device_by_identifier( + (DOMAIN, BRIDGE_HARDWARE_ID), config_entry.entry_id + ) + assert bridge_device is not None + + sensor_device = device_registry.async_get_device_by_identifier( + (DOMAIN, SENSOR_HARDWARE_ID), config_entry.entry_id + ) + assert sensor_device is not None + assert sensor_device.via_device_id == bridge_device.id From 6a9b0e7b681dcdba71500faa9128644651f37f54 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 14:20:20 +0200 Subject: [PATCH 044/109] Fix via_device race in freebox (#177716) --- homeassistant/components/freebox/__init__.py | 8 +++++++- homeassistant/components/freebox/entity.py | 6 +++++- homeassistant/components/freebox/router.py | 1 + homeassistant/components/freebox/sensor.py | 8 +++++--- tests/components/freebox/test_init.py | 21 ++++++++++++++++++++ 5 files changed, 39 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/freebox/__init__.py b/homeassistant/components/freebox/__init__.py index b3163741cdc9..6c5a66e284f4 100644 --- a/homeassistant/components/freebox/__init__.py +++ b/homeassistant/components/freebox/__init__.py @@ -8,7 +8,7 @@ from freebox_api.exceptions import HttpRequestError from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_time_interval from .const import DOMAIN, PLATFORMS @@ -96,6 +96,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: FreeboxConfigEntry) -> b entry.runtime_data = router + # Register the router device up front so child devices (home devices, disks) + # can deterministically resolve it as their via_device on any platform. + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, **router.device_info + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def async_close_connection(event: Event) -> None: diff --git a/homeassistant/components/freebox/entity.py b/homeassistant/components/freebox/entity.py index 6b2b32a61b41..ddc694014e9e 100644 --- a/homeassistant/components/freebox/entity.py +++ b/homeassistant/components/freebox/entity.py @@ -54,7 +54,11 @@ class FreeboxHomeEntity(Entity): model=self._model, name=node["label"].strip(), sw_version=self._firmware, - via_device=(DOMAIN, router.mac), + via_device_id=dr.async_get_device_id_by_identifier( + router.hass, + (DOMAIN, router.mac), + config_entry_id=router.config_entry.entry_id, + ), ) async def async_update_signal(self) -> None: diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index dc88284824fd..dfc1bfa58331 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -108,6 +108,7 @@ class FreeboxRouter: ) -> None: """Initialize a Freebox router.""" self.hass = hass + self.config_entry = entry self._host = entry.data[CONF_HOST] self._port = entry.data[CONF_PORT] diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index dfaa036a1b09..6f28dbbbb540 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -17,6 +17,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -246,9 +247,10 @@ class FreeboxDiskSensor(FreeboxSensor): model=disk["model"], name=f"Disk {disk['id']}", sw_version=disk["firmware"], - via_device=( - DOMAIN, - router.mac, + via_device_id=dr.async_get_device_id_by_identifier( + router.hass, + (DOMAIN, router.mac), + config_entry_id=router.config_entry.entry_id, ), ) diff --git a/tests/components/freebox/test_init.py b/tests/components/freebox/test_init.py index f1ef3e6f308d..60d5cf1f1b43 100644 --- a/tests/components/freebox/test_init.py +++ b/tests/components/freebox/test_init.py @@ -169,6 +169,27 @@ async def test_unique_id_migration( ) +@pytest.mark.usefixtures("router") +async def test_home_device_via_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test home devices are linked to the router device via via_device_id.""" + entry = await setup_platform(hass, BINARY_SENSOR_DOMAIN) + + router_device = device_registry.async_get_device_by_identifier( + (DOMAIN, MOCK_MAC), entry.entry_id + ) + assert router_device is not None + + pir_node_id = 26 # Détecteur from fixture + pir_device = device_registry.async_get_device_by_identifier( + (DOMAIN, pir_node_id), entry.entry_id + ) + assert pir_device is not None + assert pir_device.via_device_id == router_device.id + + async def test_home_device_label_sync( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From b02d4cbde524696a0e04566041dc475a9d20fd62 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 14:27:59 +0200 Subject: [PATCH 045/109] Adapt lutron_caseta to set via_device_id in DeviceInfo (#177731) Co-authored-by: Markus Tuominen <3738613+Markus98@users.noreply.github.com> --- .../components/lutron_caseta/__init__.py | 26 +++++++++++++++---- .../components/lutron_caseta/binary_sensor.py | 25 ++++++++++++------ .../components/lutron_caseta/button.py | 5 ++-- .../components/lutron_caseta/cover.py | 2 +- .../components/lutron_caseta/entity.py | 12 +++++++-- homeassistant/components/lutron_caseta/fan.py | 4 ++- .../components/lutron_caseta/light.py | 8 +++--- .../components/lutron_caseta/models.py | 1 + .../components/lutron_caseta/switch.py | 14 +++++----- .../lutron_caseta/test_diagnostics.py | 4 +-- tests/components/lutron_caseta/test_init.py | 21 +++++++++++++++ 11 files changed, 92 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 0ee597d0ec68..2ad7a7c17264 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -217,7 +217,7 @@ async def async_setup_entry( # Store this bridge (keyed by entry_id) so it can be retrieved by the # platforms we're setting up. - entry.runtime_data = LutronCasetaData(bridge, bridge_device, keypad_data) + entry.runtime_data = LutronCasetaData(bridge, bridge_device, keypad_data, entry_id) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -236,7 +236,6 @@ def _async_register_bridge_device( manufacturer=MANUFACTURER, identifiers={(DOMAIN, bridge_device["serial"])}, model=f"{bridge_device['model']} ({bridge_device['type']})", - via_device=(DOMAIN, bridge_device["serial"]), configuration_url="https://device-login.lutron.com", ) @@ -244,7 +243,15 @@ def _async_register_bridge_device( if area != UNASSIGNED_AREA: device_args["suggested_area"] = area - device_registry.async_get_or_create(**device_args, config_entry_id=config_entry_id) + device = device_registry.async_get_or_create( + **device_args, config_entry_id=config_entry_id + ) + if device.via_device_id is not None: + # Existing installations may still have the bridge device linked to + # itself via via_device_id, from when it was (incorrectly) registered + # as its own via device. Clear it explicitly since async_get_or_create + # above leaves via_device_id untouched when it's not passed. + device_registry.async_update_device(device.id, via_device_id=None) @callback @@ -280,7 +287,12 @@ def _async_setup_keypads( if not (keypad := keypads.get(keypad_lutron_device_id)): # First time seeing this keypad, build keypad data and store in keypads keypad = keypads[keypad_lutron_device_id] = _async_build_lutron_keypad( - bridge, bridge_device, bridge_keypad, keypad_lutron_device_id + hass, + config_entry_id, + bridge, + bridge_device, + bridge_keypad, + keypad_lutron_device_id, ) # Register the keypad device @@ -353,6 +365,8 @@ def _async_build_trigger_schemas( @callback def _async_build_lutron_keypad( + hass: HomeAssistant, + config_entry_id: str, bridge: Smartbridge, bridge_device: dict[str, Any], bridge_keypad: dict[str, Any], @@ -367,7 +381,9 @@ def _async_build_lutron_keypad( manufacturer=MANUFACTURER, identifiers={(DOMAIN, keypad_serial)}, model=f"{bridge_keypad['model']} ({bridge_keypad['type']})", - via_device=(DOMAIN, bridge_device["serial"]), + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, bridge_device["serial"]), config_entry_id=config_entry_id + ), ) if area_name != UNASSIGNED_AREA: device_info["suggested_area"] = area_name diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index c137137aa9bf..397f1c2023eb 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.const import ATTR_SUGGESTED_AREA, EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -40,12 +41,12 @@ async def async_setup_entry( bridge = data.bridge occupancy_groups = bridge.occupancy_groups async_add_entities( - LutronOccupancySensor(occupancy_group, data) + LutronOccupancySensor(hass, occupancy_group, data) for occupancy_group in occupancy_groups.values() ) async_add_entities( ( - LutronCasetaBatterySensor(device, data) + LutronCasetaBatterySensor(hass, device, data) for device in bridge.get_devices_by_domain(COVER_DOMAIN) ), update_before_add=True, @@ -57,9 +58,11 @@ class LutronOccupancySensor(LutronCasetaEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.OCCUPANCY - def __init__(self, device, data): + def __init__( + self, hass: HomeAssistant, device: dict[str, Any], data: LutronCasetaData + ) -> None: """Init an occupancy sensor.""" - super().__init__(device, data) + super().__init__(hass, device, data) area = area_name_from_id(self._smartbridge.areas, device["area"]) name = f"{area} {device['device_name']}" self._attr_name = name @@ -67,8 +70,12 @@ class LutronOccupancySensor(LutronCasetaEntity, BinarySensorEntity): identifiers={(DOMAIN, self.unique_id)}, manufacturer=MANUFACTURER, model="Lutron Occupancy", - name=self.name, - via_device=(DOMAIN, self._bridge_device["serial"]), + name=name, + via_device_id=dr.async_get_device_id_by_identifier( + hass, + (DOMAIN, self._bridge_device["serial"]), + config_entry_id=data.config_entry_id, + ), configuration_url=CONFIG_URL, entry_type=DeviceEntryType.SERVICE, ) @@ -116,9 +123,11 @@ class LutronCasetaBatterySensor(LutronCasetaEntity, BinarySensorEntity): _attr_has_entity_name = True _attr_should_poll = True - def __init__(self, device: dict[str, Any], data: LutronCasetaData) -> None: + def __init__( + self, hass: HomeAssistant, device: dict[str, Any], data: LutronCasetaData + ) -> None: """Initialize the battery sensor.""" - super().__init__(device, data) + super().__init__(hass, device, data) # The base entity sets the shade name; remove it so the battery device # class provides the sensor name. if hasattr(self, "_attr_name"): diff --git a/homeassistant/components/lutron_caseta/button.py b/homeassistant/components/lutron_caseta/button.py index 934a97bb4c97..781152315fb9 100644 --- a/homeassistant/components/lutron_caseta/button.py +++ b/homeassistant/components/lutron_caseta/button.py @@ -56,7 +56,7 @@ async def async_setup_entry( # The entities will be nested inside the keypad device entities.append( LutronCasetaButton( - device, data, full_name, enabled_default, parent_device_info + hass, device, data, full_name, enabled_default, parent_device_info ), ) @@ -68,6 +68,7 @@ class LutronCasetaButton(LutronCasetaEntity, ButtonEntity): def __init__( self, + hass: HomeAssistant, device: dict[str, Any], data: LutronCasetaData, full_name: str, @@ -75,7 +76,7 @@ class LutronCasetaButton(LutronCasetaEntity, ButtonEntity): device_info: DeviceInfo, ) -> None: """Init a button entity.""" - super().__init__(device, data) + super().__init__(hass, device, data) self._attr_entity_registry_enabled_default = enabled_default self._attr_name = full_name self._attr_device_info = device_info diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index 96a2c616e10a..2c689ee67a66 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -183,7 +183,7 @@ async def async_setup_entry( # default to standard LutronCasetaCover type if the # pylutron type is not yet mapped PYLUTRON_TYPE_TO_CLASSES.get(cover_device["type"], LutronCasetaShade)( - cover_device, data + hass, cover_device, data ) for cover_device in cover_devices ) diff --git a/homeassistant/components/lutron_caseta/entity.py b/homeassistant/components/lutron_caseta/entity.py index 0380032bbd83..09c9bb6efcbf 100644 --- a/homeassistant/components/lutron_caseta/entity.py +++ b/homeassistant/components/lutron_caseta/entity.py @@ -4,6 +4,8 @@ import logging from typing import Any, override from homeassistant.const import ATTR_SUGGESTED_AREA +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -19,7 +21,9 @@ class LutronCasetaEntity(Entity): _attr_should_poll = False - def __init__(self, device: dict[str, Any], data: LutronCasetaData) -> None: + def __init__( + self, hass: HomeAssistant, device: dict[str, Any], data: LutronCasetaData + ) -> None: """Set up the base class. [:param]device the device metadata @@ -54,7 +58,11 @@ class LutronCasetaEntity(Entity): manufacturer=MANUFACTURER, model=f"{device['model']} ({device['type']})", name=full_name, - via_device=(DOMAIN, self._bridge_device["serial"]), + via_device_id=dr.async_get_device_id_by_identifier( + hass, + (DOMAIN, self._bridge_device["serial"]), + config_entry_id=data.config_entry_id, + ), configuration_url=CONFIG_URL, ) if area != UNASSIGNED_AREA: diff --git a/homeassistant/components/lutron_caseta/fan.py b/homeassistant/components/lutron_caseta/fan.py index 9f2272e0605a..eb224c357481 100644 --- a/homeassistant/components/lutron_caseta/fan.py +++ b/homeassistant/components/lutron_caseta/fan.py @@ -36,7 +36,9 @@ async def async_setup_entry( data = config_entry.runtime_data bridge = data.bridge fan_devices = bridge.get_devices_by_domain(FAN_DOMAIN) - async_add_entities(LutronCasetaFan(fan_device, data) for fan_device in fan_devices) + async_add_entities( + LutronCasetaFan(hass, fan_device, data) for fan_device in fan_devices + ) class LutronCasetaFan(LutronCasetaUpdatableEntity, FanEntity): diff --git a/homeassistant/components/lutron_caseta/light.py b/homeassistant/components/lutron_caseta/light.py index c5340c22abff..8a6451149d71 100644 --- a/homeassistant/components/lutron_caseta/light.py +++ b/homeassistant/components/lutron_caseta/light.py @@ -77,7 +77,7 @@ async def async_setup_entry( bridge = data.bridge light_devices = bridge.get_devices_by_domain(LIGHT_DOMAIN) async_add_entities( - LutronCasetaLight(light_device, data) for light_device in light_devices + LutronCasetaLight(hass, light_device, data) for light_device in light_devices ) @@ -90,9 +90,11 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, LightEntity): _attr_supported_features = LightEntityFeature.TRANSITION _prev_brightness: int | None = None - def __init__(self, light: dict[str, Any], data: LutronCasetaData) -> None: + def __init__( + self, hass: HomeAssistant, light: dict[str, Any], data: LutronCasetaData + ) -> None: """Initialize the light and set the supported color modes.""" - super().__init__(light, data) + super().__init__(hass, light, data) self._attr_min_color_temp_kelvin = self._get_min_color_temp_kelvin(light) self._attr_max_color_temp_kelvin = self._get_max_color_temp_kelvin(light) diff --git a/homeassistant/components/lutron_caseta/models.py b/homeassistant/components/lutron_caseta/models.py index 73f399ad8fa4..722f1c0bf016 100644 --- a/homeassistant/components/lutron_caseta/models.py +++ b/homeassistant/components/lutron_caseta/models.py @@ -19,6 +19,7 @@ class LutronCasetaData: bridge: Smartbridge bridge_device: dict[str, Any] keypad_data: LutronKeypadData + config_entry_id: str @dataclass diff --git a/homeassistant/components/lutron_caseta/switch.py b/homeassistant/components/lutron_caseta/switch.py index edad464fc741..c110c95ad3a5 100644 --- a/homeassistant/components/lutron_caseta/switch.py +++ b/homeassistant/components/lutron_caseta/switch.py @@ -27,11 +27,11 @@ async def async_setup_entry( bridge = data.bridge switch_devices = bridge.get_devices_by_domain(SWITCH_DOMAIN) entities: list[LutronCasetaLight | LutronCasetaSmartAwaySwitch] = [ - LutronCasetaLight(switch_device, data) for switch_device in switch_devices + LutronCasetaLight(hass, switch_device, data) for switch_device in switch_devices ] if bridge.smart_away_state != "": - entities.append(LutronCasetaSmartAwaySwitch(data)) + entities.append(LutronCasetaSmartAwaySwitch(hass, data)) async_add_entities(entities) @@ -39,10 +39,12 @@ async def async_setup_entry( class LutronCasetaLight(LutronCasetaUpdatableEntity, SwitchEntity): """Representation of a Lutron Caseta switch.""" - def __init__(self, device, data): + def __init__( + self, hass: HomeAssistant, device: dict[str, Any], data: LutronCasetaData + ) -> None: """Init a button entity.""" - super().__init__(device, data) + super().__init__(hass, device, data) self._enabled_default = True if "parent_device" not in device: @@ -78,7 +80,7 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, SwitchEntity): class LutronCasetaSmartAwaySwitch(LutronCasetaEntity, SwitchEntity): """Representation of Lutron Caseta Smart Away.""" - def __init__(self, data: LutronCasetaData) -> None: + def __init__(self, hass: HomeAssistant, data: LutronCasetaData) -> None: """Init a switch entity.""" device = { "device_id": "smart_away", @@ -88,7 +90,7 @@ class LutronCasetaSmartAwaySwitch(LutronCasetaEntity, SwitchEntity): "area": data.bridge_device["area"], "serial": data.bridge_device["serial"], } - super().__init__(device, data) + super().__init__(hass, device, data) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, data.bridge_device["serial"])}, ) diff --git a/tests/components/lutron_caseta/test_diagnostics.py b/tests/components/lutron_caseta/test_diagnostics.py index 089ea2463d39..2b232d830173 100644 --- a/tests/components/lutron_caseta/test_diagnostics.py +++ b/tests/components/lutron_caseta/test_diagnostics.py @@ -231,7 +231,7 @@ async def test_diagnostics( "model": "RRST-W3RL-XX (SunnataKeypad)", "name": "Hallway Main Stairs Position 1 Keypad", "suggested_area": "Hallway", - "via_device": ["lutron_caseta", 1234], + "via_device_id": ANY, }, "dr_device_id": ANY, "lutron_device_id": "1355", @@ -250,7 +250,7 @@ async def test_diagnostics( "model": "PJ2-3BRL-GXX-X01 (Pico3ButtonRaiseLower)", "name": "Dining Room Pico", "suggested_area": "Dining Room", - "via_device": ["lutron_caseta", 1234], + "via_device_id": ANY, }, "dr_device_id": ANY, "lutron_device_id": "9", diff --git a/tests/components/lutron_caseta/test_init.py b/tests/components/lutron_caseta/test_init.py index 7e509acbf62f..2882e25aeec0 100644 --- a/tests/components/lutron_caseta/test_init.py +++ b/tests/components/lutron_caseta/test_init.py @@ -5,8 +5,10 @@ from unittest.mock import patch import pytest from homeassistant.components import lutron_caseta +from homeassistant.components.lutron_caseta.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import MockBridge, async_setup_integration, make_mock_entry @@ -52,3 +54,22 @@ async def test_cannot_connect( ) assert mock_entry.state is ConfigEntryState.SETUP_RETRY assert "Connection failed to 1.1.1.1" in caplog.text + + +async def test_keypad_device_via_device_id( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test the keypad device is linked to the bridge device via via_device_id.""" + mock_entry = await async_setup_integration(hass, MockBridge) + + bridge_device = device_registry.async_get_device_by_identifier( + (DOMAIN, 1234), mock_entry.entry_id + ) + keypad_device = device_registry.async_get_device_by_identifier( + (DOMAIN, 66286451), mock_entry.entry_id + ) + + assert bridge_device is not None + assert keypad_device is not None + assert keypad_device.via_device_id == bridge_device.id + assert bridge_device.via_device_id is None From 30071695c227c848f07194646fba219315b7b16a Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 3 Aug 2026 14:46:16 +0200 Subject: [PATCH 046/109] Add switch platform to Mikrotik (#177245) --- homeassistant/components/mikrotik/__init__.py | 5 +- homeassistant/components/mikrotik/const.py | 4 + .../components/mikrotik/coordinator.py | 42 +++++++ homeassistant/components/mikrotik/entity.py | 20 ++-- homeassistant/components/mikrotik/icons.json | 8 ++ .../components/mikrotik/strings.json | 8 ++ homeassistant/components/mikrotik/switch.py | 83 ++++++++++++++ tests/components/mikrotik/__init__.py | 3 + tests/components/mikrotik/const.py | 27 +++++ .../mikrotik/snapshots/test_switch.ambr | 103 ++++++++++++++++++ tests/components/mikrotik/test_switch.py | 83 ++++++++++++++ 11 files changed, 376 insertions(+), 10 deletions(-) create mode 100644 homeassistant/components/mikrotik/switch.py create mode 100644 tests/components/mikrotik/snapshots/test_switch.ambr create mode 100644 tests/components/mikrotik/test_switch.py diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index 60fbd0dda320..53f56766bb3f 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -20,6 +20,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR, + Platform.SWITCH, Platform.UPDATE, ] @@ -43,8 +44,6 @@ async def async_setup_entry( config_entry.runtime_data = coordinator - await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -55,6 +54,8 @@ async def async_setup_entry( sw_version=coordinator.firmware, ) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + return True diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index 310578b739af..9cd9a587a232 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -25,10 +25,12 @@ DHCP: Final = "dhcp" FIRMWARE_UPDATE: Final = "fw-update" HEALTH: Final = "health" IDENTITY: Final = "identity" +INTERFACE: Final = "interface" IS_CAPSMAN: Final = "is_capsman" IS_WIFI: Final = "is_wifi" IS_WIFIWAVE2: Final = "is_wifiwave2" IS_WIRELESS: Final = "is_wireless" +POE: Final = "poe" REBOOT: Final = "reboot" RESOURCE: Final = "resource" ROUTERBOARD_UPDATE: Final = "routerboard-update" @@ -48,10 +50,12 @@ MIKROTIK_SERVICES: Final = { FIRMWARE_UPDATE: "/system/package/update/install", HEALTH: "/system/health/print", IDENTITY: "/system/identity/getall", + INTERFACE: "/interface/print", IS_CAPSMAN: "/caps-man/interface/print", IS_WIFI: "/interface/wifi/print", IS_WIFIWAVE2: "/interface/wifiwave2/print", IS_WIRELESS: "/interface/wireless/print", + POE: "/interface/ethernet/poe/print", REBOOT: "/system/reboot", RESOURCE: "/system/resource/print", ROUTERBOARD_UPDATE: "/system/routerboard/upgrade", diff --git a/homeassistant/components/mikrotik/coordinator.py b/homeassistant/components/mikrotik/coordinator.py index 347f4d655224..97e2c0aa388a 100644 --- a/homeassistant/components/mikrotik/coordinator.py +++ b/homeassistant/components/mikrotik/coordinator.py @@ -32,12 +32,14 @@ from .const import ( DOMAIN, HEALTH, IDENTITY, + INTERFACE, IS_CAPSMAN, IS_WIFI, IS_WIFIWAVE2, IS_WIRELESS, MIKROTIK_SERVICES, NAME, + POE, RESOURCE, ROUTERBOARD, UPDATE, @@ -77,6 +79,7 @@ class MikrotikData: self.serial_number: str = "" self.sensors: dict[str, Any] = {} self.system: dict[str, Any] = {} + self.interfaces: list[dict[str, Any]] = [] def _get_system_details(self, during_setup: bool = False) -> None: """Retrieve system and routerboard details from Mikrotik API.""" @@ -92,6 +95,43 @@ class MikrotikData: or [{}] )[0] + def _get_interfaces_details(self) -> None: + """Get interfaces details.""" + all_interfs = self.command(MIKROTIK_SERVICES[INTERFACE]) + + fields = { + ".id", + "name", + "type", + "mac-address", + "running", + "disabled", + } + + poe_interfs = self.command(MIKROTIK_SERVICES[POE], suppress_errors=True) or [] + + poe_by_id = { + poe_interf[".id"]: poe_interf.get("poe-out", "off") + for poe_interf in poe_interfs + } + + existing_by_id = {interf[".id"]: interf for interf in self.interfaces} + + interfaces = [] + for interf in all_interfs: + if interf.get("type") == "loopback": + continue + data = {key: interf.get(key) for key in fields} + if (poe_out := poe_by_id.get(data[".id"])) is not None: + data["poe-out"] = poe_out + if existing := existing_by_id.get(data[".id"]): + existing.update(data) + interfaces.append(existing) + else: + interfaces.append(data) + + self.interfaces = interfaces + @staticmethod def load_mac(devices: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: """Load dictionary using MAC address as key.""" @@ -198,6 +238,8 @@ class MikrotikData: self.command(MIKROTIK_SERVICES[RESOURCE], suppress_errors=True) or [] ) + self._get_interfaces_details() + if not device_list: return diff --git a/homeassistant/components/mikrotik/entity.py b/homeassistant/components/mikrotik/entity.py index 1f9041a7accd..095dd01a8e84 100644 --- a/homeassistant/components/mikrotik/entity.py +++ b/homeassistant/components/mikrotik/entity.py @@ -2,13 +2,13 @@ from yarl import URL -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify from .const import DOMAIN -from .coordinator import MikrotikDataUpdateCoordinator +from .coordinator import MikrotikConfigEntry, MikrotikDataUpdateCoordinator class MikrotikBaseEntity(CoordinatorEntity[MikrotikDataUpdateCoordinator]): @@ -27,10 +27,10 @@ class MikrotikBaseEntity(CoordinatorEntity[MikrotikDataUpdateCoordinator]): self._serial = coordinator.api.serial_number - def _base_device_info(self) -> DeviceInfo: + def _base_device_info(self) -> dr.DeviceInfo: """Return the device info fields shared by all Mikrotik devices.""" coordinator = self.coordinator - return DeviceInfo( + return dr.DeviceInfo( configuration_url=URL.build( scheme="http", host=coordinator.host, @@ -52,7 +52,7 @@ class MikrotikEntity(MikrotikBaseEntity): ) -> None: """Initialize the entity.""" super().__init__(coordinator, description) - self._attr_device_info = DeviceInfo( + self._attr_device_info = dr.DeviceInfo( **self._base_device_info(), identifiers={(DOMAIN, self._serial)}, name=coordinator.hostname, @@ -65,6 +65,7 @@ class MikrotikDeviceEntity(MikrotikBaseEntity): def __init__( self, + config_entry: MikrotikConfigEntry, coordinator: MikrotikDataUpdateCoordinator, description: EntityDescription, interface: dict, @@ -75,12 +76,15 @@ class MikrotikDeviceEntity(MikrotikBaseEntity): name = interface.get("name") ident = f"{slugify(interface.get('mac-address'))}_{name}" - self._attr_device_info = DeviceInfo( + self._attr_device_info = dr.DeviceInfo( **self._base_device_info(), identifiers={(DOMAIN, ident)}, name=name, - via_device=(DOMAIN, coordinator.api.serial_number), + via_device_id=dr.async_get_device_id_by_identifier( + config_entry.runtime_data.hass, + (DOMAIN, coordinator.api.serial_number), + config_entry_id=config_entry.entry_id, + ), ) self._attr_unique_id = ident - self._attr_name = name self._interface = interface diff --git a/homeassistant/components/mikrotik/icons.json b/homeassistant/components/mikrotik/icons.json index 39724f156b95..0a416c4a37b4 100644 --- a/homeassistant/components/mikrotik/icons.json +++ b/homeassistant/components/mikrotik/icons.json @@ -18,6 +18,14 @@ "memory_usage": { "default": "mdi:memory" } + }, + "switch": { + "ether": { + "default": "mdi:lan" + }, + "wlan": { + "default": "mdi:wifi" + } } } } diff --git a/homeassistant/components/mikrotik/strings.json b/homeassistant/components/mikrotik/strings.json index 3e0bee9422b8..25137d88d3ea 100644 --- a/homeassistant/components/mikrotik/strings.json +++ b/homeassistant/components/mikrotik/strings.json @@ -61,6 +61,14 @@ "name": "Memory usage" } }, + "switch": { + "ether": { + "name": "Ethernet" + }, + "wlan": { + "name": "WLAN" + } + }, "update": { "firmware_update": { "name": "RouterOS" diff --git a/homeassistant/components/mikrotik/switch.py b/homeassistant/components/mikrotik/switch.py new file mode 100644 index 000000000000..44ad66a92e3c --- /dev/null +++ b/homeassistant/components/mikrotik/switch.py @@ -0,0 +1,83 @@ +"""Support for Mikrotik routers switches.""" + +from typing import Any, Final, override + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import MikrotikConfigEntry, mikrotik_config_entry_errors +from .entity import MikrotikDeviceEntity + +PARALLEL_UPDATES = 0 + + +SENSORS: Final = ( + SwitchEntityDescription( + key="ether", + translation_key="ether", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key="wlan", + translation_key="wlan", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MikrotikConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Mikrotik switch based on a config entry.""" + + coordinator = entry.runtime_data + + switch_list = [ + MikrotikSwitchEntity(entry, coordinator, switch_desc, interface) + for switch_desc in SENSORS + for interface in coordinator.api.interfaces + if interface.get("type") == switch_desc.key + ] + + async_add_entities(switch_list) + + +class MikrotikSwitchEntity(MikrotikDeviceEntity, SwitchEntity): + """Switch device.""" + + @property + @override + def is_on(self) -> bool | None: + """Return the state of the switch.""" + return not self._interface.get("disabled") + + async def _set_state(self, action: str) -> None: + """Toggle the state of the switch.""" + with mikrotik_config_entry_errors(): + await self.hass.async_add_executor_job( + self.coordinator.api.command, + f"/interface/{action}", + {".id": self._interface[".id"]}, + ) + self._interface["disabled"] = action == "disable" + self.async_write_ha_state() + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self._set_state("enable") + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self._set_state("disable") diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py index 75a38eaaf25a..df9d060683e7 100644 --- a/tests/components/mikrotik/__init__.py +++ b/tests/components/mikrotik/__init__.py @@ -31,6 +31,7 @@ def _build_command_responses( wifiwave2_data: list[dict[str, Any]], health_data: list[dict[str, Any]], system_data: list[dict[str, Any]], + interface_data: list[dict[str, Any]], routerboard_data: list[dict[str, Any]], update_data: list[dict[str, Any]], ) -> dict[str, Any]: @@ -52,6 +53,7 @@ def _build_command_responses( mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.ARP]: ARP_DATA, mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.HEALTH]: health_data, mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.RESOURCE]: system_data, + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.INTERFACE]: interface_data, mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.UPDATE]: update_data, } @@ -116,6 +118,7 @@ async def setup_mikrotik_entry( wifiwave2_data=kwargs.get("wifiwave2_data", WIFIWAVE2_DATA), health_data=kwargs.get("health_data", HEALTH_DATA), system_data=kwargs.get("system_data", SYSTEM_DATA), + interface_data=kwargs.get("interface_data", []), routerboard_data=kwargs.get("routerboard_data", ROUTERBOARD_DATA), update_data=kwargs.get("update_data", UPDATE_DATA), ) diff --git a/tests/components/mikrotik/const.py b/tests/components/mikrotik/const.py index d8eba44abfe7..9ab3799d2dd7 100644 --- a/tests/components/mikrotik/const.py +++ b/tests/components/mikrotik/const.py @@ -189,6 +189,33 @@ SYSTEM_DATA = [ } ] +ETHER1_INTERFACE = { + ".id": "*1", + "name": "ether1", + "type": "ether", + "mac-address": "00:0C:29:00:00:01", + "running": True, + "disabled": False, +} +WLAN1_INTERFACE = { + ".id": "*2", + "name": "wlan1", + "type": "wlan", + "mac-address": "00:0C:29:00:00:02", + "running": False, + "disabled": True, +} +BRIDGE1_INTERFACE = { + ".id": "*3", + "name": "bridge1", + "type": "bridge", + "mac-address": "00:0C:29:00:00:03", + "running": True, + "disabled": False, +} + +INTERFACE_DATA = [ETHER1_INTERFACE, WLAN1_INTERFACE, BRIDGE1_INTERFACE] + ROUTERBOARD_DATA = [ { ATTR_MODEL: TEST_MODEL, diff --git a/tests/components/mikrotik/snapshots/test_switch.ambr b/tests/components/mikrotik/snapshots/test_switch.ambr new file mode 100644 index 000000000000..e4d464842c94 --- /dev/null +++ b/tests/components/mikrotik/snapshots/test_switch.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_switch_entities_created[switch.ether1_ethernet-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ether1_ethernet', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Ethernet', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ethernet', + 'platform': 'mikrotik', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ether', + 'unique_id': '00_0c_29_00_00_01_ether1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities_created[switch.ether1_ethernet-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'switch', + : 'ether1 Ethernet', + }), + 'context': , + 'entity_id': 'switch.ether1_ethernet', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities_created[switch.wlan1_wlan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.wlan1_wlan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'WLAN', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'WLAN', + 'platform': 'mikrotik', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wlan', + 'unique_id': '00_0c_29_00_00_02_wlan1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities_created[switch.wlan1_wlan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'switch', + : 'wlan1 WLAN', + }), + 'context': , + 'entity_id': 'switch.wlan1_wlan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/mikrotik/test_switch.py b/tests/components/mikrotik/test_switch.py new file mode 100644 index 000000000000..c2ccd7ccd128 --- /dev/null +++ b/tests/components/mikrotik/test_switch.py @@ -0,0 +1,83 @@ +"""Tests for the Mikrotik switch platform.""" + +from unittest.mock import MagicMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_mikrotik_entry +from .const import BRIDGE1_INTERFACE, ETHER1_INTERFACE, INTERFACE_DATA, WLAN1_INTERFACE + +from tests.common import snapshot_platform + + +async def test_switch_entities_created( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test Mikrotik switch entities are created with expected values.""" + with patch("homeassistant.components.mikrotik.PLATFORMS", [Platform.SWITCH]): + config_entry = await setup_mikrotik_entry(hass, interface_data=INTERFACE_DATA) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_switch_no_matching_interfaces(hass: HomeAssistant) -> None: + """Test no switch entities are created for unsupported interface types.""" + with patch("homeassistant.components.mikrotik.PLATFORMS", [Platform.SWITCH]): + await setup_mikrotik_entry(hass, interface_data=[BRIDGE1_INTERFACE]) + + assert hass.states.async_entity_ids(SWITCH_DOMAIN) == [] + + +async def test_switch_turn_on(hass: HomeAssistant, mock_api: MagicMock) -> None: + """Test turning on a Mikrotik switch enables the interface.""" + with patch("homeassistant.components.mikrotik.PLATFORMS", [Platform.SWITCH]): + await setup_mikrotik_entry(hass, interface_data=[dict(WLAN1_INTERFACE)]) + + entity_id = "switch.wlan1_wlan" + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_api.assert_called_with("/interface/enable", **{".id": "*2"}) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON + + +async def test_switch_turn_off(hass: HomeAssistant, mock_api: MagicMock) -> None: + """Test turning off a Mikrotik switch disables the interface.""" + with patch("homeassistant.components.mikrotik.PLATFORMS", [Platform.SWITCH]): + await setup_mikrotik_entry(hass, interface_data=[dict(ETHER1_INTERFACE)]) + + entity_id = "switch.ether1_ethernet" + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_api.assert_called_with("/interface/disable", **{".id": "*1"}) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF From c2dd4654e2fc18ba53534cdb48c0b471f2079d03 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 14:54:40 +0200 Subject: [PATCH 047/109] Adapt telegram_bot to set via_device_id in DeviceInfo (#178080) --- homeassistant/components/telegram_bot/notify.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/telegram_bot/notify.py b/homeassistant/components/telegram_bot/notify.py index e95cf2de681e..1eb9f4dc513e 100644 --- a/homeassistant/components/telegram_bot/notify.py +++ b/homeassistant/components/telegram_bot/notify.py @@ -9,6 +9,7 @@ from homeassistant.components.notify import ( ) from homeassistant.config_entries import ConfigSubentry from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TelegramBotConfigEntry @@ -51,7 +52,11 @@ class TelegramBotNotifyEntity(TelegramBotEntity, NotifyEntity): assert device_info is not None device_info["identifiers"] = {(DOMAIN, f"{self.bot_id}_{self.chat_id}")} device_info["name"] = subentry.title - device_info["via_device"] = (DOMAIN, f"{self.bot_id}") + device_info["via_device_id"] = dr.async_get_device_id_by_identifier( + config_entry.runtime_data.hass, + (DOMAIN, f"{self.bot_id}"), + config_entry_id=config_entry.entry_id, + ) @override async def async_send_message(self, message: str, title: str | None = None) -> None: From 720cceae7e576f0ff4271d30f2cc0e16d81b7922 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 3 Aug 2026 15:03:26 +0200 Subject: [PATCH 048/109] Bump aw to 0.83.4 (#178084) --- .github/aw/actions-lock.json | 14 + .github/workflows/aw.json | 3 + .github/workflows/check-requirements.lock.yml | 535 ++++++++++-------- 3 files changed, 326 insertions(+), 226 deletions(-) create mode 100644 .github/aw/actions-lock.json create mode 100644 .github/workflows/aw.json diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json new file mode 100644 index 000000000000..7dfd04e82067 --- /dev/null +++ b/.github/aw/actions-lock.json @@ -0,0 +1,14 @@ +{ + "entries": { + "github/gh-aw-actions/setup-cli@v0.83.4": { + "repo": "github/gh-aw-actions/setup-cli", + "version": "v0.83.4", + "sha": "e89c65e17eb281bbd5ff2ff9e9199a03e96654c7" + }, + "github/gh-aw-actions/setup@v0.83.4": { + "repo": "github/gh-aw-actions/setup", + "version": "v0.83.4", + "sha": "e89c65e17eb281bbd5ff2ff9e9199a03e96654c7" + } + } +} diff --git a/.github/workflows/aw.json b/.github/workflows/aw.json new file mode 100644 index 000000000000..518d5ef22718 --- /dev/null +++ b/.github/workflows/aw.json @@ -0,0 +1,3 @@ +{ + "maintenance": false +} diff --git a/.github/workflows/check-requirements.lock.yml b/.github/workflows/check-requirements.lock.yml index e42acd40e14f..5be8eeb598fb 100644 --- a/.github/workflows/check-requirements.lock.yml +++ b/.github/workflows/check-requirements.lock.yml @@ -1,20 +1,21 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"36a7fc263a2ce868d74a266f23eb7772d82fd397806464384fe087479ddd4a70","body_hash":"bba8c011f2b82bb4d9847a359f43f0e7d91245b280678c20e5112b3c9e77d5cd","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"5c2fe865bb4dc46e1450f6ee0d0541d759aea73a","version":"v0.79.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]} -# ___ _ _ -# / _ \ | | (_) -# | |_| | __ _ ___ _ __ | |_ _ ___ +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"36a7fc263a2ce868d74a266f23eb7772d82fd397806464384fe087479ddd4a70","body_hash":"bba8c011f2b82bb4d9847a359f43f0e7d91245b280678c20e5112b3c9e77d5cd","compiler_version":"v0.83.4","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.75"}} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"e89c65e17eb281bbd5ff2ff9e9199a03e96654c7","version":"v0.83.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42","digest":"sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42","digest":"sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42","digest":"sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.6","digest":"sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.6@sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748","pinned_image":"ghcr.io/github/gh-aw-node@sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748"},{"image":"ghcr.io/github/github-mcp-server:v1.7.0","digest":"sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308","pinned_image":"ghcr.io/github/github-mcp-server:v1.7.0@sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308"}]} +# This file was automatically generated by gh-aw (v0.83.4). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md +# +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ # | _ |/ _` |/ _ \ '_ \| __| |/ __| -# | | | | (_| | __/ | | | |_| | (__ +# | | | | (_| | __/ | | | |_| | (__ # \_| |_/\__, |\___|_| |_|\__|_|\___| # __/ | -# _ _ |___/ +# _ _ |___/ # | | | | / _| | # | | | | ___ _ __ _ __| |_| | _____ ____ # | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| # \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ # \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ # -# This file was automatically generated by gh-aw (v0.79.6). DO NOT EDIT. # # To update this file, edit the corresponding .md file and run: # gh aw compile @@ -31,28 +32,32 @@ # - GITHUB_TOKEN # # Custom actions used: +# - actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 +# - actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 # - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9) # - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 -# - github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6 +# - github/gh-aw-actions/setup@e89c65e17eb281bbd5ff2ff9e9199a03e96654c7 # v0.83.4 # # Container images used: -# - ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6 -# - ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4 -# - ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591 -# - ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa -# - ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c +# - ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b +# - ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607 +# - ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0 +# - ghcr.io/github/gh-aw-mcpg:v0.4.6@sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c +# - ghcr.io/github/gh-aw-node@sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748 +# - ghcr.io/github/github-mcp-server:v1.7.0@sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308 name: "Check requirements (AW)" on: workflow_run: # zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation types: - - completed + - completed workflows: - - Check requirements (deterministic) + - Check requirements (deterministic) permissions: {} @@ -75,15 +80,17 @@ jobs: contents: read env: GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }} + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} outputs: comment_id: "" comment_repo: "" - daily_effective_workflow_exceeded: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_effective_workflow_exceeded == 'true' }} - daily_effective_workflow_threshold: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_effective_workflow_threshold || '' }} - daily_effective_workflow_total_effective_tokens: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_effective_workflow_total_effective_tokens || '' }} + daily_ai_credits_exceeded: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_exceeded == 'true' }} + daily_ai_credits_threshold: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_threshold || '' }} + daily_ai_credits_total_effective_tokens: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_total_effective_tokens || '' }} engine_id: ${{ steps.generate_aw_info.outputs.engine_id }} lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} model: ${{ steps.generate_aw_info.outputs.model }} + oauth_token_check_failed: ${{ steps.check-oauth-tokens.outputs.oauth_token_check_failed == 'true' }} secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} setup-span-id: ${{ steps.setup.outputs.span-id }} @@ -92,7 +99,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6 + uses: github/gh-aw-actions/setup@e89c65e17eb281bbd5ff2ff9e9199a03e96654c7 # v0.83.4 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -102,8 +109,8 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.60" - GH_AW_INFO_AWF_VERSION: "v0.27.2" + GH_AW_INFO_VERSION: "1.0.75" + GH_AW_INFO_AWF_VERSION: "v0.27.42" GH_AW_INFO_ENGINE_ID: "copilot" - name: Generate agentic run info id: generate_aw_info @@ -111,16 +118,16 @@ jobs: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }} - GH_AW_INFO_VERSION: "1.0.60" - GH_AW_INFO_AGENT_VERSION: "1.0.60" - GH_AW_INFO_CLI_VERSION: "v0.79.6" + GH_AW_INFO_VERSION: "1.0.75" + GH_AW_INFO_AGENT_VERSION: "1.0.75" + GH_AW_INFO_CLI_VERSION: "v0.83.4" GH_AW_INFO_WORKFLOW_NAME: "Check requirements (AW)" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" GH_AW_INFO_STAGED: "false" GH_AW_INFO_ALLOWED_DOMAINS: '["python"]' GH_AW_INFO_FIREWALL_ENABLED: "true" - GH_AW_INFO_AWF_VERSION: "v0.27.2" + GH_AW_INFO_AWF_VERSION: "v0.27.42" GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_COMPILED_STRICT: "true" @@ -131,6 +138,30 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); await main(core, context); + - name: Restore daily AIC usage cache + id: restore-daily-aic-cache + if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + continue-on-error: true + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: agentic-workflow-usage-checkrequirements-${{ github.run_id }} + restore-keys: agentic-workflow-usage-checkrequirements- + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + - name: Restore daily AIC usage cache (artifact fallback) + id: restore-daily-aic-cache-fallback + if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_RESTORE_DAILY_AIC_CACHE_HIT: ${{ steps.restore-daily-aic-cache.outputs.cache-hit }} + GH_AW_RESTORE_DAILY_AIC_CACHE_MATCHED_KEY: ${{ steps.restore-daily-aic-cache.outputs.cache-matched-key }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/restore_aic_usage_cache_fallback.cjs'); + await main(); - name: Check daily workflow token guardrail id: daily-effective-workflow-guardrail if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} @@ -140,6 +171,8 @@ jobs: GH_AW_WORKFLOW_ID: "check-requirements" GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT: ${{ github.event.inputs.aw_context || '' }} + GH_AW_HAS_SLASH_COMMAND: "false" + GH_AW_HAS_LABEL_COMMAND: "false" GH_AW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }} with: @@ -154,6 +187,13 @@ jobs: run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh" COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default env: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + - name: Check for OAuth tokens + id: check-oauth-tokens + run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh" + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - name: Checkout .github and .agents folders uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -164,7 +204,6 @@ jobs: .antigravity .claude .codex - .crush .gemini .opencode .pi @@ -172,8 +211,8 @@ jobs: fetch-depth: 1 - name: Save agent config folders for base branch restoration env: - GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .crush .gemini .github .opencode .pi" - GH_AW_AGENT_FILES: ".crush.json AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" + GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .gemini .github .opencode .pi" + GH_AW_AGENT_FILES: "AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" # poutine:ignore untrusted_checkout_exec run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" - name: Check workflow lock file @@ -191,13 +230,16 @@ jobs: - name: Check compile-agentic version uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_COMPILED_VERSION: "v0.79.6" + GH_AW_COMPILED_VERSION: "v0.83.4" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs'); await main(); + - name: Log runtime features + if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }} + run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh" - name: Create prompt with built-in context env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt @@ -214,20 +256,20 @@ jobs: run: | bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" { - cat << 'GH_AW_PROMPT_781cf5b2f30d6d93_EOF' + cat << 'GH_AW_PROMPT_b9403b48a8e785a5_EOF' - GH_AW_PROMPT_781cf5b2f30d6d93_EOF + GH_AW_PROMPT_b9403b48a8e785a5_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_781cf5b2f30d6d93_EOF' + cat << 'GH_AW_PROMPT_b9403b48a8e785a5_EOF' Tools: add_comment, missing_tool, missing_data, noop - GH_AW_PROMPT_781cf5b2f30d6d93_EOF + GH_AW_PROMPT_b9403b48a8e785a5_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" - cat << 'GH_AW_PROMPT_781cf5b2f30d6d93_EOF' + cat << 'GH_AW_PROMPT_b9403b48a8e785a5_EOF' The following GitHub context information is available for this workflow: {{#if github.actor}} @@ -255,13 +297,13 @@ jobs: - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ {{/if}} - - GH_AW_PROMPT_781cf5b2f30d6d93_EOF + + GH_AW_PROMPT_b9403b48a8e785a5_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_781cf5b2f30d6d93_EOF' + cat << 'GH_AW_PROMPT_b9403b48a8e785a5_EOF' {{#runtime-import .github/workflows/check-requirements.md}} - GH_AW_PROMPT_781cf5b2f30d6d93_EOF + GH_AW_PROMPT_b9403b48a8e785a5_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -286,15 +328,15 @@ jobs: GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - GH_AW_MCP_CLI_SERVERS_LIST: '- `safeoutputs` — run `safeoutputs --help` to see available tools' + GH_AW_MCP_CLI_SERVERS_LIST: "- `github` — run `github --help` to see available tools\n- `safeoutputs` — run `safeoutputs --help` to see available tools" GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }} with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io, getOctokit); - + const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs'); - + // Call the substitution function return await substitutePlaceholders({ file: process.env.GH_AW_PROMPT, @@ -329,7 +371,6 @@ jobs: include-hidden-files: true path: | /tmp/gh-aw/aw_info.json - /tmp/gh-aw/model_multipliers.json /tmp/gh-aw/models.json /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/aw-prompts/prompt-template.txt @@ -345,7 +386,7 @@ jobs: needs: - activation - prepare - if: (needs.prepare.outputs.skip != 'true') && (needs.activation.outputs.daily_effective_workflow_exceeded != 'true') + if: (needs.prepare.outputs.skip != 'true') && (needs.activation.outputs.daily_ai_credits_exceeded != 'true') runs-on: ubuntu-latest permissions: actions: read @@ -360,6 +401,7 @@ jobs: GH_AW_ASSETS_BRANCH: "" GH_AW_ASSETS_MAX_SIZE_KB: 0 GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} GH_AW_WORKFLOW_ID_SANITIZED: checkrequirements outputs: agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }} @@ -369,7 +411,9 @@ jobs: checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }} has_patch: ${{ steps.collect_output.outputs.has_patch }} + http_400_response_error: ${{ steps.detect-agent-errors.outputs.http_400_response_error || 'false' }} inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }} + invocation_cap_exceeded: ${{ steps.detect-agent-errors.outputs.invocation_cap_exceeded || 'false' }} mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }} model: ${{ needs.activation.outputs.model }} model_not_supported_error: ${{ steps.detect-agent-errors.outputs.model_not_supported_error || 'false' }} @@ -382,7 +426,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6 + uses: github/gh-aw-actions/setup@e89c65e17eb281bbd5ff2ff9e9199a03e96654c7 # v0.83.4 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -391,8 +435,8 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.60" - GH_AW_INFO_AWF_VERSION: "v0.27.2" + GH_AW_INFO_VERSION: "1.0.75" + GH_AW_INFO_AWF_VERSION: "v0.27.42" GH_AW_INFO_ENGINE_ID: "copilot" - name: Set runtime paths id: set-runtime-paths @@ -412,6 +456,11 @@ jobs: run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh" env: GH_TOKEN: ${{ github.token }} + - name: Download activation artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: activation + path: /tmp/gh-aw - if: github.event.workflow_run.conclusion == 'success' name: Download deterministic-results artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -423,17 +472,10 @@ jobs: - name: Configure Git credentials env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_TOKEN: ${{ github.token }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - git config --global am.keepcr true - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh" - name: Checkout PR branch id: checkout-pr if: | @@ -449,11 +491,22 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install GitHub Copilot CLI - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.60 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.75 env: GH_HOST: github.com - name: Install AWF binary - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.2 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.42 --rootless + - name: Determine automatic lockdown mode for GitHub MCP Server + id: determine-automatic-lockdown + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9) + env: + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + GH_AW_GITHUB_MIN_INTEGRITY: 'unapproved' + with: + script: | + const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs'); + await determineAutomaticLockdown(github, context, core); - name: Parse integrity filter lists id: parse-guard-vars env: @@ -461,16 +514,11 @@ jobs: GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_USERS || '' }} GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} run: bash "${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh" - - name: Download activation artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: activation - path: /tmp/gh-aw - name: Restore agent config folders from base branch if: steps.checkout-pr.outcome == 'success' env: - GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .crush .gemini .github .opencode .pi" - GH_AW_AGENT_FILES: ".crush.json AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" + GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .gemini .github .opencode .pi" + GH_AW_AGENT_FILES: "AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_base_github_folders.sh" - name: Restore inline sub-agents from activation artifact env: @@ -482,15 +530,15 @@ jobs: GH_AW_SKILL_DIR: ".github/skills" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh" - name: Download container images - run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4 ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591 ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607 ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0 ghcr.io/github/gh-aw-mcpg:v0.4.6@sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c ghcr.io/github/gh-aw-node@sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748 ghcr.io/github/github-mcp-server:v1.7.0@sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308 - name: Generate Safe Outputs Config run: | mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_823c5547a5e52957_EOF' + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_8ec65654fdb08fbe_EOF' {"add_comment":{"max":1,"target":"${{ needs.prepare.outputs.pr_number }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} - GH_AW_SAFE_OUTPUTS_CONFIG_823c5547a5e52957_EOF + GH_AW_SAFE_OUTPUTS_CONFIG_8ec65654fdb08fbe_EOF - name: Generate Safe Outputs Tools env: GH_AW_TOOLS_META_JSON: | @@ -606,60 +654,22 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs'); await main(); - - name: Generate Safe Outputs MCP Server Config - id: safe-outputs-config - run: | - # Generate a secure random API key (360 bits of entropy, 40+ chars) - # Mask immediately to prevent timing vulnerabilities - API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - echo "::add-mask::${API_KEY}" - - PORT=3001 - - # Set outputs for next steps - { - echo "safe_outputs_api_key=${API_KEY}" - echo "safe_outputs_port=${PORT}" - } >> "$GITHUB_OUTPUT" - - echo "Safe Outputs MCP server will run on port ${PORT}" - - - name: Start Safe Outputs MCP HTTP Server - id: safe-outputs-start - env: - DEBUG: '*' - GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} - GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} - GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} - GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json - GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/config.json - GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs - run: | - # Environment variables are set above to prevent template injection - export DEBUG - export GH_AW_SAFE_OUTPUTS - export GH_AW_SAFE_OUTPUTS_PORT - export GH_AW_SAFE_OUTPUTS_API_KEY - export GH_AW_SAFE_OUTPUTS_TOOLS_PATH - export GH_AW_SAFE_OUTPUTS_CONFIG_PATH - export GH_AW_MCP_LOG_DIR - - bash "${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh" - - name: Start MCP Gateway id: start-mcp-gateway env: + GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST: ${{ vars.GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST || 'true' }} GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} - GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} - GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_CONFIG_PATH }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_TOOLS_PATH }} GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -eo pipefail mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config" - + # Export gateway environment variables for MCP config and gateway script export MCP_GATEWAY_PORT="8080" - export MCP_GATEWAY_DOMAIN="host.docker.internal" + export MCP_GATEWAY_DOMAIN="awmg-mcpg" export MCP_GATEWAY_HOST_DOMAIN="localhost" MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') echo "::add-mask::${MCP_GATEWAY_API_KEY}" @@ -668,29 +678,25 @@ jobs: mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" export DEBUG="*" - + export GH_AW_ENGINE="copilot" MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') - case "${DOCKER_HOST:-}" in - unix://* ) DOCKER_SOCK_PATH="${DOCKER_HOST#unix://}" ;; - /* ) DOCKER_SOCK_PATH="$DOCKER_HOST" ;; - * ) DOCKER_SOCK_PATH=/var/run/docker.sock ;; - esac - DOCKER_SOCK_GID=$(stat -c '%g' "$DOCKER_SOCK_PATH" 2>/dev/null || echo '0') - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host --add-host host.docker.internal:127.0.0.1 --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.3.25' - - mkdir -p /home/runner/.copilot + source "${RUNNER_TEMP}/gh-aw/actions/resolve_docker_socket_gid.sh" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.6' + + mkdir -p "$HOME/.copilot" GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) - cat << GH_AW_MCP_CONFIG_f09adf73c5e58a42_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_f55de5a807caee8b_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { "github": { "type": "stdio", - "container": "ghcr.io/github/github-mcp-server:v1.1.2", + "container": "ghcr.io/github/github-mcp-server:v1.7.0", "env": { - "GITHUB_HOST": "\${GITHUB_SERVER_URL}", - "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", + "GITHUB_FEATURES": "fields_param", + "GITHUB_HOST": "${GITHUB_SERVER_URL}", + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_MCP_SERVER_TOKEN}", "GITHUB_READ_ONLY": "1", "GITHUB_TOOLSETS": "repos,pull_requests" }, @@ -705,16 +711,35 @@ jobs: } }, "safeoutputs": { - "type": "http", - "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", - "headers": { - "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}" + "type": "stdio", + "container": "ghcr.io/github/gh-aw-node", + "mounts": ["\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw", "${RUNNER_TEMP}/gh-aw/safeoutputs:${RUNNER_TEMP}/gh-aw/safeoutputs:rw", "/tmp/gh-aw:/tmp/gh-aw:rw"], + "args": ["-w", "\${GITHUB_WORKSPACE}"], + "entrypoint": "sh", + "entrypointArgs": ["-c", "sh ${RUNNER_TEMP}/gh-aw/safeoutputs/start_safe_outputs_mcp.sh"], + "env": { + "DEBUG": "*", + "DEFAULT_BRANCH": "\${DEFAULT_BRANCH}", + "GH_AW_ASSETS_ALLOWED_EXTS": "\${GH_AW_ASSETS_ALLOWED_EXTS}", + "GH_AW_ASSETS_BRANCH": "\${GH_AW_ASSETS_BRANCH}", + "GH_AW_ASSETS_MAX_SIZE_KB": "\${GH_AW_ASSETS_MAX_SIZE_KB}", + "GH_AW_MCP_LOG_DIR": "\${GH_AW_MCP_LOG_DIR}", + "GH_AW_SAFE_OUTPUTS": "\${GH_AW_SAFE_OUTPUTS}", + "GH_AW_SAFE_OUTPUTS_CONFIG_PATH": "\${GH_AW_SAFE_OUTPUTS_CONFIG_PATH}", + "GH_AW_SAFE_OUTPUTS_TOOLS_PATH": "\${GH_AW_SAFE_OUTPUTS_TOOLS_PATH}", + "GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST": "\${GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}", + "GITHUB_SHA": "\${GITHUB_SHA}", + "GITHUB_TOKEN": "\${GITHUB_TOKEN}", + "GITHUB_WORKSPACE": "\${GITHUB_WORKSPACE}", + "RUNNER_TEMP": "\${RUNNER_TEMP}" }, "guard-policies": { "write-sink": { "accept": [ "*" - ] + ], + "sink-visibility": ${{ toJSON(steps.determine-automatic-lockdown.outputs.visibility) }} } } } @@ -723,10 +748,11 @@ jobs: "port": $MCP_GATEWAY_PORT, "domain": "${MCP_GATEWAY_DOMAIN}", "apiKey": "${MCP_GATEWAY_API_KEY}", - "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}", + "startupTimeout": 120 } } - GH_AW_MCP_CONFIG_f09adf73c5e58a42_EOF + GH_AW_MCP_CONFIG_f55de5a807caee8b_EOF - name: Mount MCP servers as CLIs id: mount-mcp-clis continue-on-error: true @@ -755,48 +781,51 @@ jobs: run: | set -o pipefail printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt - trap 'rm -f /home/runner/.copilot/settings.json' EXIT - mkdir -p /home/runner/.copilot - printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > /home/runner/.copilot/settings.json + trap 'gh_aw_exit_code=$?; mkdir -p /tmp/gh-aw >/dev/null 2>&1 || true; printf "%s" "$gh_aw_exit_code" > /tmp/gh-aw/agent_execution_exit_code.txt || true; rm -f "$HOME/.copilot/settings.json"' EXIT + mkdir -p "$HOME/.copilot" + printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" + export XDG_CONFIG_HOME="$HOME" + export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json" touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK" (umask 177 && touch /tmp/gh-aw/agent-stdio.log) - GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}" - printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.2/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.pythonhosted.org\",\"anaconda.org\",\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"binstar.org\",\"bootstrap.pypa.io\",\"conda.anaconda.org\",\"conda.binstar.org\",\"files.pythonhosted.org\",\"github.com\",\"host.docker.internal\",\"pip.pypa.io\",\"pypi.org\",\"pypi.python.org\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"repo.anaconda.com\",\"repo.continuum.io\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"vision\":[\"copilot/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.2,squid=sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591,agent=sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6,api-proxy=sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4,cli-proxy=sha256:02f3ec08f32dc26c5427920c6a2e2f3036238fce44802f2f11ef49ed8621b5d0\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" - GH_AW_MODEL_MULTIPLIERS_PATH="/tmp/gh-aw/model_multipliers.json" node "${RUNNER_TEMP}/gh-aw/actions/merge_awf_model_multipliers.cjs" + GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-1000}" + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.42/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.pythonhosted.org\",\"anaconda.org\",\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"binstar.org\",\"bootstrap.pypa.io\",\"conda.anaconda.org\",\"conda.binstar.org\",\"files.pythonhosted.org\",\"github.com\",\"host.docker.internal\",\"pip.pypa.io\",\"pypi.org\",\"pypi.python.org\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"repo.anaconda.com\",\"repo.continuum.io\",\"telemetry.enterprise.githubcopilot.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"fable\",\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.42,squid=sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0,agent=sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b,agent-act=sha256:a14ad974484aa518aab83d40f3f141175dfd171d3745e01c092375b970f73a20,api-proxy=sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607,cli-proxy=sha256:da006bf96d2d246dd269d57b233c1798d2ad63d6cd64ca02f7bf71045028781f\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" - GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="" + GH_AW_DOCKER_HOST="" if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then - GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="--docker-host-path-prefix /tmp/gh-aw" + GH_AW_DOCKER_HOST="${DOCKER_HOST}" + fi + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_CHROOT_BINARIES_SOURCE_PATH="${RUNNER_TEMP}/gh-aw" GH_AW_CHROOT_IDENTITY_HOME="${RUNNER_TEMP}/gh-aw/home" node "${RUNNER_TEMP}/gh-aw/actions/patch_awf_chroot_config.cjs" fi GH_AW_TOOL_CACHE_MOUNT="" - GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}" + GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}" if [ -d "$GH_AW_TOOL_CACHE" ]; then if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro" fi - elif [ -d "/home/runner/work/_tool" ]; then - GH_AW_TOOL_CACHE_MOUNT="/home/runner/work/_tool:/home/runner/work/_tool:ro" fi - # shellcheck disable=SC1003 - sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ - -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"; export PATH="$(find "$GH_AW_TOOL_CACHE" /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + # shellcheck disable=SC1003,SC2016,SC2086 + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }} + GH_AW_LLM_PROVIDER: github + GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }} GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} - GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json GH_AW_PHASE: agent GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_TIMEOUT_MINUTES: 20 - GH_AW_VERSION: v0.79.6 + GH_AW_VERSION: v0.83.4 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows @@ -811,7 +840,7 @@ jobs: GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com GIT_COMMITTER_NAME: github-actions[bot] RUNNER_TEMP: ${{ runner.temp }} - XDG_CONFIG_HOME: /home/runner + TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} - name: Detect agent errors if: always() id: detect-agent-errors @@ -819,17 +848,10 @@ jobs: run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs" - name: Configure Git credentials env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_TOKEN: ${{ github.token }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - git config --global am.keepcr true - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh" - name: Copy Copilot session state files to logs if: always() continue-on-error: true @@ -888,6 +910,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -909,16 +932,7 @@ jobs: continue-on-error: true env: AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs - run: | - # Fix permissions on firewall logs/audit dirs so they can be uploaded as artifacts - # AWF runs with sudo, creating files owned by root - sudo chmod -R a+rX /tmp/gh-aw/sandbox/firewall 2>/dev/null || true - # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) - if command -v awf &> /dev/null; then - awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" - else - echo 'AWF binary not installed, skipping firewall log summary' - fi + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_firewall_logs.sh" --rootless - name: Parse token usage for step summary if: always() continue-on-error: true @@ -997,17 +1011,19 @@ jobs: - safe_outputs if: > always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' || - needs.activation.outputs.stale_lock_file_failed == 'true' || needs.activation.outputs.daily_effective_workflow_exceeded == 'true') + needs.activation.outputs.oauth_token_check_failed == 'true' || needs.activation.outputs.stale_lock_file_failed == 'true' || + needs.activation.outputs.secret_verification_result == 'failed' || needs.activation.outputs.daily_ai_credits_exceeded == 'true') runs-on: ubuntu-slim permissions: contents: read - discussions: write issues: write pull-requests: write concurrency: group: "gh-aw-conclusion-check-requirements" cancel-in-progress: false queue: max + env: + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} outputs: incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }} noop_message: ${{ steps.noop.outputs.noop_message }} @@ -1016,7 +1032,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6 + uses: github/gh-aw-actions/setup@e89c65e17eb281bbd5ff2ff9e9199a03e96654c7 # v0.83.4 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1025,8 +1041,8 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.60" - GH_AW_INFO_AWF_VERSION: "v0.27.2" + GH_AW_INFO_VERSION: "1.0.75" + GH_AW_INFO_AWF_VERSION: "v0.27.42" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact id: download-agent-output @@ -1042,26 +1058,40 @@ jobs: mkdir -p /tmp/gh-aw/ find "/tmp/gh-aw/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Download safe outputs items manifest + id: download-safe-outputs-manifest + if: always() + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: safe-outputs-items + path: /tmp/gh-aw/ - name: Collect usage artifact files if: always() continue-on-error: true run: | mkdir -p /tmp/gh-aw/usage/agent /tmp/gh-aw/usage/detection echo "Usage artifact source file status:" - for file in /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl; do + for file in /tmp/gh-aw/aw_info.json /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/agent_usage.json /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl; do [ -f "$file" ] && echo "FOUND: $file" || echo "MISSING: $file" done + [ -f /tmp/gh-aw/aw_info.json ] && cp /tmp/gh-aw/aw_info.json /tmp/gh-aw/usage/aw_info.json || true [ -f /tmp/gh-aw/aw-info.jsonl ] && cp /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/usage/aw-info.jsonl || true + [ -f /tmp/gh-aw/agent_usage.json ] && cp /tmp/gh-aw/agent_usage.json /tmp/gh-aw/usage/agent_usage.json || true [ -f /tmp/gh-aw/agent_usage.jsonl ] && cp /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/usage/agent_usage.jsonl || true [ -f /tmp/gh-aw/detection_usage.jsonl ] && cp /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/usage/detection_usage.jsonl || true - [ -f /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true - [ -f /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true - [ -f /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true - [ -f /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true - [ -f /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true - [ -f /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true + [ -f /tmp/gh-aw/evals/evals.jsonl ] && cp /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/usage/evals.jsonl || true + [ -f /tmp/gh-aw/github_rate_limits.jsonl ] && cp /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/usage/github_rate_limits.jsonl || true + [ -s /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true + [ -s /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true + [ -s /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true + [ -s /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true + [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true + [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true [ -f /tmp/gh-aw/usage/agent/token_usage.jsonl ] || : > /tmp/gh-aw/usage/agent/token_usage.jsonl [ -f /tmp/gh-aw/usage/detection/token_usage.jsonl ] || : > /tmp/gh-aw/usage/detection/token_usage.jsonl + mkdir -p /tmp/gh-aw/usage/activity + node "${RUNNER_TEMP}/gh-aw/actions/generate_usage_activity_summary.cjs" find /tmp/gh-aw/usage -type f -print | sort - name: Upload usage artifact if: always() @@ -1070,12 +1100,56 @@ jobs: with: name: usage path: | + /tmp/gh-aw/usage/aw_info.json /tmp/gh-aw/usage/aw-info.jsonl + /tmp/gh-aw/usage/agent_usage.json /tmp/gh-aw/usage/agent_usage.jsonl /tmp/gh-aw/usage/detection_usage.jsonl + /tmp/gh-aw/usage/evals.jsonl + /tmp/gh-aw/usage/github_rate_limits.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl + /tmp/gh-aw/usage/activity/summary.json if-no-files-found: ignore + - name: Restore daily AIC usage cache + id: restore-daily-aic-cache-conclusion + if: always() + continue-on-error: true + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: agentic-workflow-usage-checkrequirements-${{ github.run_id }} + restore-keys: agentic-workflow-usage-checkrequirements- + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + - name: Write daily AIC usage cache entry + id: write-daily-aic-cache + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ github.token }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context); + const { main } = require('${{ runner.temp }}/gh-aw/actions/write_daily_aic_usage_cache.cjs'); + await main(); + - name: Save daily AIC usage cache + id: save-daily-aic-cache + if: always() + continue-on-error: true + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: agentic-workflow-usage-checkrequirements-${{ github.run_id }} + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + - name: Upload daily AIC usage cache artifact + id: upload-daily-aic-cache + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: aic-usage-cache + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + if-no-files-found: ignore + retention-days: 7 - name: Process no-op messages id: noop uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -1159,6 +1233,7 @@ jobs: GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168" GH_AW_ENGINE_ID: "copilot" GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} + GH_AW_ENGINE_SECRET_FAILURE_MESSAGE: "**Alternative**: If your organization has a Copilot subscription, you can avoid the need for a personal access token by adding a top-level `permissions` block to your workflow file. This enables Copilot inference through the org using the built-in GitHub Actions token.\n\n```yaml\npermissions:\n copilot-requests: write\n```\n\nSee: https://github.github.com/gh-aw/reference/engines/#github-copilot-default" GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }} GH_AW_AI_CREDITS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.ai_credits_rate_limit_error || 'false' }} @@ -1170,12 +1245,14 @@ jobs: GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }} GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }} GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }} + GH_AW_HTTP_400_RESPONSE_ERROR: ${{ needs.agent.outputs.http_400_response_error }} GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com" GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} + GH_AW_OAUTH_TOKEN_CHECK_FAILED: ${{ needs.activation.outputs.oauth_token_check_failed }} GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }} - GH_AW_DAILY_EFFECTIVE_WORKFLOW_EXCEEDED: ${{ needs.activation.outputs.daily_effective_workflow_exceeded }} - GH_AW_DAILY_EFFECTIVE_WORKFLOW_TOTAL_EFFECTIVE_TOKENS: ${{ needs.activation.outputs.daily_effective_workflow_total_effective_tokens }} - GH_AW_DAILY_EFFECTIVE_WORKFLOW_THRESHOLD: ${{ needs.activation.outputs.daily_effective_workflow_threshold }} + GH_AW_DAILY_AI_CREDITS_EXCEEDED: ${{ needs.activation.outputs.daily_ai_credits_exceeded }} + GH_AW_DAILY_AI_CREDITS_TOTAL_EFFECTIVE_TOKENS: ${{ needs.activation.outputs.daily_ai_credits_total_effective_tokens }} + GH_AW_DAILY_AI_CREDITS_THRESHOLD: ${{ needs.activation.outputs.daily_ai_credits_threshold }} GH_AW_GROUP_REPORTS: "false" GH_AW_FAILURE_REPORT_AS_ISSUE: "true" GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true" @@ -1193,11 +1270,12 @@ jobs: needs: - activation - agent - if: > - always() && needs.agent.result != 'skipped' && (needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true') + if: always() && needs.agent.result != 'skipped' runs-on: ubuntu-latest permissions: contents: read + env: + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} outputs: aic: ${{ steps.parse_detection_token_usage.outputs.aic }} detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} @@ -1206,7 +1284,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6 + uses: github/gh-aw-actions/setup@e89c65e17eb281bbd5ff2ff9e9199a03e96654c7 # v0.83.4 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1215,8 +1293,8 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.60" - GH_AW_INFO_AWF_VERSION: "v0.27.2" + GH_AW_INFO_VERSION: "1.0.75" + GH_AW_INFO_AWF_VERSION: "v0.27.42" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact id: download-agent-output @@ -1243,7 +1321,7 @@ jobs: rm -rf /tmp/gh-aw/sandbox/firewall/logs rm -rf /tmp/gh-aw/sandbox/firewall/audit - name: Download container images - run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4 ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591 + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607 ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0 - name: Check if detection needed id: detection_guard if: always() @@ -1262,7 +1340,7 @@ jobs: if: always() && steps.detection_guard.outputs.run_detection == 'true' run: | rm -f "${RUNNER_TEMP}/gh-aw/mcp-config/mcp-servers.json" - rm -f /home/runner/.copilot/mcp-config.json + rm -f "$HOME/.copilot/mcp-config.json" rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" - name: Prepare threat detection files if: always() && steps.detection_guard.outputs.run_detection == 'true' @@ -1306,11 +1384,11 @@ jobs: node-version: '24' package-manager-cache: false - name: Install GitHub Copilot CLI - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.60 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.75 env: GH_HOST: github.com - name: Install AWF binary - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.2 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.42 - name: Execute GitHub Copilot CLI if: always() && steps.detection_guard.outputs.run_detection == 'true' continue-on-error: true @@ -1320,46 +1398,51 @@ jobs: run: | set -o pipefail printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt - trap 'rm -f /home/runner/.copilot/settings.json' EXIT - mkdir -p /home/runner/.copilot - printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > /home/runner/.copilot/settings.json + trap 'gh_aw_exit_code=$?; mkdir -p /tmp/gh-aw >/dev/null 2>&1 || true; printf "%s" "$gh_aw_exit_code" > /tmp/gh-aw/agent_execution_exit_code.txt || true; rm -f "$HOME/.copilot/settings.json"' EXIT + mkdir -p "$HOME/.copilot" + printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" + export XDG_CONFIG_HOME="$HOME" touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK" (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log) - GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }}" - printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.2/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"github.com\",\"host.docker.internal\",\"registry.npmjs.org\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS}},\"container\":{\"imageTag\":\"0.27.2,squid=sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591,agent=sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6,api-proxy=sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4,cli-proxy=sha256:02f3ec08f32dc26c5427920c6a2e2f3036238fce44802f2f11ef49ed8621b5d0\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" - GH_AW_MODEL_MULTIPLIERS_PATH="/tmp/gh-aw/model_multipliers.json" node "${RUNNER_TEMP}/gh-aw/actions/merge_awf_model_multipliers.cjs" + GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-400}" + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.42/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"github.com\",\"host.docker.internal\",\"registry.npmjs.org\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"fable\",\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.42,squid=sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0,agent=sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b,agent-act=sha256:a14ad974484aa518aab83d40f3f141175dfd171d3745e01c092375b970f73a20,api-proxy=sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607,cli-proxy=sha256:da006bf96d2d246dd269d57b233c1798d2ad63d6cd64ca02f7bf71045028781f\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" - GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="" + GH_AW_DOCKER_HOST="" if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then - GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="--docker-host-path-prefix /tmp/gh-aw" + GH_AW_DOCKER_HOST="${DOCKER_HOST}" + fi + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + _GH_AW_CHROOT_JSON=$(jq -c --arg src "${RUNNER_TEMP}/gh-aw" --arg user "$(id -un)" --argjson uid "$(id -u)" --argjson gid "$(id -g)" --arg home "${RUNNER_TEMP}/gh-aw/home" '.chroot={"binariesSourcePath":$src,"identity":{"user":$user,"uid":$uid,"gid":$gid,"home":$home}}' "${RUNNER_TEMP}/gh-aw/awf-config.json") || { echo "chroot config patch failed" >&2; exit 1; } + printf '%s\n' "$_GH_AW_CHROOT_JSON" > "${RUNNER_TEMP}/gh-aw/awf-config.json" + printf '%s\n' "$_GH_AW_CHROOT_JSON" > "${RUNNER_TEMP}/gh-aw/awf-config.json" fi GH_AW_TOOL_CACHE_MOUNT="" - GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}" + GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}" if [ -d "$GH_AW_TOOL_CACHE" ]; then if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro" fi - elif [ -d "/home/runner/work/_tool" ]; then - GH_AW_TOOL_CACHE_MOUNT="/home/runner/work/_tool:/home/runner/work/_tool:ro" fi - # shellcheck disable=SC1003 - sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ - -- /bin/bash -c 'set +o histexpand; GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"; export PATH="$(find "$GH_AW_TOOL_CACHE" /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + # shellcheck disable=SC1003,SC2016,SC2086 + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env COPILOT_GITHUB_TOKEN --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log env: AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }} + GH_AW_LLM_PROVIDER: github + GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }} GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} GH_AW_PHASE: detection GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_TIMEOUT_MINUTES: 20 - GH_AW_VERSION: v0.79.6 + GH_AW_VERSION: v0.83.4 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows @@ -1373,7 +1456,7 @@ jobs: GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com GIT_COMMITTER_NAME: github-actions[bot] RUNNER_TEMP: ${{ runner.temp }} - XDG_CONFIG_HOME: /home/runner + TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} - name: Parse threat detection token usage for step summary id: parse_detection_token_usage if: always() @@ -1429,6 +1512,8 @@ jobs: pre_activation: runs-on: ubuntu-slim + env: + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} outputs: activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} matched_command: '' @@ -1438,15 +1523,15 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6 + uses: github/gh-aw-actions/setup@e89c65e17eb281bbd5ff2ff9e9199a03e96654c7 # v0.83.4 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} env: GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.60" - GH_AW_INFO_AWF_VERSION: "v0.27.2" + GH_AW_INFO_VERSION: "1.0.75" + GH_AW_INFO_AWF_VERSION: "v0.27.42" GH_AW_INFO_ENGINE_ID: "copilot" - name: Check team membership for workflow id: check_membership @@ -1468,7 +1553,6 @@ jobs: permissions: actions: read contents: read - outputs: pr_number: ${{ steps.prepare.outputs.pr_number }} skip: ${{ steps.prepare.outputs.skip }} @@ -1476,8 +1560,7 @@ jobs: - name: Configure GH_HOST for enterprise compatibility id: ghes-host-config shell: bash - # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input. - run: | + run: | # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input. # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. GH_HOST="${GITHUB_SERVER_URL#https://}" @@ -1507,7 +1590,6 @@ jobs: runs-on: ubuntu-slim permissions: contents: read - discussions: write issues: write pull-requests: write timeout-minutes: 45 @@ -1521,7 +1603,8 @@ jobs: GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }} GH_AW_ENGINE_ID: "copilot" GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} - GH_AW_ENGINE_VERSION: "1.0.60" + GH_AW_ENGINE_VERSION: "1.0.75" + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} GH_AW_THREAT_DETECTION_AIC: ${{ needs.detection.outputs.aic }} GH_AW_WORKFLOW_ID: "check-requirements" GH_AW_WORKFLOW_NAME: "Check requirements (AW)" @@ -1538,7 +1621,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6 + uses: github/gh-aw-actions/setup@e89c65e17eb281bbd5ff2ff9e9199a03e96654c7 # v0.83.4 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1547,8 +1630,8 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.60" - GH_AW_INFO_AWF_VERSION: "v0.27.2" + GH_AW_INFO_VERSION: "1.0.75" + GH_AW_INFO_AWF_VERSION: "v0.27.42" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact id: download-agent-output @@ -1567,8 +1650,7 @@ jobs: - name: Configure GH_HOST for enterprise compatibility id: ghes-host-config shell: bash - # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input. - run: | + run: | # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input. # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. GH_HOST="${GITHUB_SERVER_URL#https://}" @@ -1589,7 +1671,7 @@ jobs: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs'); + const { main } = require('${{ runner.temp }}/gh-aw/actions/process_safe_outputs.cjs'); await main(); - name: Upload Safe Outputs Items if: always() @@ -1599,5 +1681,6 @@ jobs: path: | /tmp/gh-aw/safe-output-items.jsonl /tmp/gh-aw/temporary-id-map.json + /tmp/gh-aw/process-safe-outputs.stdout.log + /tmp/gh-aw/process-safe-outputs.stderr.log if-no-files-found: ignore - From 15fa25428af1196acd57fc26c23d03c594530c5c Mon Sep 17 00:00:00 2001 From: Ritchie Date: Mon, 3 Aug 2026 21:26:57 +0800 Subject: [PATCH 049/109] Bump tuya-device-sharing-sdk to 0.2.14 (#177901) --- homeassistant/components/tuya/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index d61a2628f646..c735db8ced57 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -45,6 +45,6 @@ "loggers": ["tuya_sharing"], "requirements": [ "tuya-device-handlers==0.0.26", - "tuya-device-sharing-sdk==0.2.10" + "tuya-device-sharing-sdk==0.2.14" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 9f2fbdb261ba..46567175b205 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3241,7 +3241,7 @@ ttn_client==1.3.0 tuya-device-handlers==0.0.26 # homeassistant.components.tuya -tuya-device-sharing-sdk==0.2.10 +tuya-device-sharing-sdk==0.2.14 # homeassistant.components.twentemilieu twentemilieu==3.0.0 From f43bafa43c37f114d9b152975b78d3db1be02832 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 15:42:45 +0200 Subject: [PATCH 050/109] Remove via_device from switchbee (#177864) --- homeassistant/components/switchbee/entity.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/switchbee/entity.py b/homeassistant/components/switchbee/entity.py index 49e1b663a269..ba4c105a409c 100644 --- a/homeassistant/components/switchbee/entity.py +++ b/homeassistant/components/switchbee/entity.py @@ -61,10 +61,6 @@ class SwitchBeeDeviceEntity[_DeviceTypeT: SwitchBeeBaseDevice]( manufacturer=SWITCHBEE_BRAND, model=coordinator.api.module_display(device.unit_id), suggested_area=device.zone, - via_device=( - DOMAIN, - f"{coordinator.api.name} ({coordinator.api.unique_id})", - ), ) @property From 6e21ceae0b211671d9e72347b196d8339ed9a660 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 3 Aug 2026 15:43:24 +0200 Subject: [PATCH 051/109] Fix Hue button automations not triggering after a restart (#177427) --- .../components/hue/device_trigger.py | 108 +++++++-- .../components/hue/v1/device_trigger.py | 4 +- .../components/hue/test_device_trigger_v1.py | 54 ++++- .../components/hue/test_device_trigger_v2.py | 207 +++++++++++++++++- 4 files changed, 349 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/hue/device_trigger.py b/homeassistant/components/hue/device_trigger.py index 86fef4d56bd1..8244db1b067f 100644 --- a/homeassistant/components/hue/device_trigger.py +++ b/homeassistant/components/hue/device_trigger.py @@ -1,10 +1,12 @@ """Provides device automations for Philips Hue events.""" +import logging from typing import TYPE_CHECKING, Any from homeassistant.components.device_automation import InvalidDeviceAutomationConfig +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_DEVICE_ID -from homeassistant.core import CALLBACK_TYPE +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import ConfigType @@ -26,6 +28,8 @@ if TYPE_CHECKING: from .bridge import HueConfigEntry +LOGGER = logging.getLogger(__name__) + async def async_validate_trigger_config( hass: HomeAssistant, config: ConfigType @@ -64,20 +68,27 @@ async def async_attach_trigger( if (device_entry := dev_reg.async_get(device_id)) is None: raise InvalidDeviceAutomationConfig(f"Device ID {device_id} is not valid") - entry: HueConfigEntry - for entry in hass.config_entries.async_loaded_entries(DOMAIN): - if entry.entry_id not in device_entry.config_entries: - continue - bridge = entry.runtime_data - if bridge.api_version == 1: - return await async_attach_trigger_v1( - bridge, device_entry, config, action, trigger_info - ) - return await async_attach_trigger_v2( - bridge, device_entry, config, action, trigger_info + entry: HueConfigEntry | None = next( + ( + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.entry_id in device_entry.config_entries + ), + None, + ) + if entry is None: + raise InvalidDeviceAutomationConfig( + f"Device ID {device_id} is not found on any Hue bridge" ) - raise InvalidDeviceAutomationConfig( - f"Device ID {device_id} is not found on any Hue bridge" + + if entry.state is not ConfigEntryState.LOADED: + # The bridge is still setting up when automations are attached at startup. + return _async_attach_on_entry_load( + hass, entry, device_entry, config, action, trigger_info + ) + + return await _async_attach_bridge_trigger( + entry, device_entry, config, action, trigger_info ) @@ -104,3 +115,72 @@ async def async_get_triggers( return async_get_triggers_v1(bridge, device_entry) return async_get_triggers_v2(bridge, device_entry) return [] + + +async def _async_attach_bridge_trigger( + entry: HueConfigEntry, + device_entry: dr.DeviceEntry, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach the trigger to the bridge of a loaded config entry.""" + bridge = entry.runtime_data + if bridge.api_version == 1: + return await async_attach_trigger_v1( + bridge, device_entry, config, action, trigger_info + ) + return await async_attach_trigger_v2( + bridge, device_entry, config, action, trigger_info + ) + + +@callback +def _async_attach_on_entry_load( + hass: HomeAssistant, + entry: HueConfigEntry, + device_entry: dr.DeviceEntry, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach the trigger as soon as the given config entry is loaded. + + The returned callback detaches the trigger and is safe to call while the + config entry is still loading. + """ + remove_trigger: CALLBACK_TYPE | None = None + attach_scheduled = False + + async def _async_attach() -> None: + nonlocal remove_trigger + try: + remove_trigger = await _async_attach_bridge_trigger( + entry, device_entry, config, action, trigger_info + ) + except InvalidDeviceAutomationConfig as err: + LOGGER.error( + "Got error '%s' when setting up triggers for %s", + err, + trigger_info["name"], + ) + + @callback + def _handle_entry_state_change() -> None: + nonlocal attach_scheduled + # Unsubscribing here would mutate the list this callback is iterated from, + # so the subscription is kept until the trigger is detached. + if attach_scheduled or entry.state is not ConfigEntryState.LOADED: + return + attach_scheduled = True + hass.async_create_task(_async_attach()) + + unsub_state_change = entry.async_on_state_change(_handle_entry_state_change) + + @callback + def _remove() -> None: + unsub_state_change() + if remove_trigger is not None: + remove_trigger() + + return _remove diff --git a/homeassistant/components/hue/v1/device_trigger.py b/homeassistant/components/hue/v1/device_trigger.py index c0a6bb2ba1bd..16589d25ad61 100644 --- a/homeassistant/components/hue/v1/device_trigger.py +++ b/homeassistant/components/hue/v1/device_trigger.py @@ -155,7 +155,9 @@ async def async_attach_trigger( hue_event = _get_hue_event_from_device_id(hass, device_entry.id) if hue_event is None: - raise InvalidDeviceAutomationConfig + raise InvalidDeviceAutomationConfig( + f"Device {device_entry.id} is not available on the Hue bridge" + ) trigger_key: tuple[str, str] = (config[CONF_TYPE], config[CONF_SUBTYPE]) diff --git a/tests/components/hue/test_device_trigger_v1.py b/tests/components/hue/test_device_trigger_v1.py index 393b6f0a2998..df9ee53ba607 100644 --- a/tests/components/hue/test_device_trigger_v1.py +++ b/tests/components/hue/test_device_trigger_v1.py @@ -1,7 +1,8 @@ """The tests for Philips Hue device triggers for V1 bridge.""" -from unittest.mock import Mock +from unittest.mock import Mock, patch +import pytest from pytest_unordered import unordered from homeassistant.components import automation, hue @@ -12,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import setup_platform +from .conftest import create_config_entry, setup_platform from .test_sensor_v1 import HUE_DIMMER_REMOTE_1, HUE_TAP_REMOTE_1 from tests.common import async_get_device_automations @@ -181,3 +182,52 @@ async def test_if_fires_on_state_change( await hass.async_block_till_done() assert len(mock_bridge_v1.mock_requests) == 3 assert len(service_calls) == 1 + + +async def test_error_when_remote_is_gone_after_config_entry_loads( + hass: HomeAssistant, + mock_bridge_v1: Mock, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a trigger for a remote that is no longer on the bridge is reported.""" + mock_bridge_v1.mock_sensor_responses.append(REMOTES_RESPONSE) + config_entry = create_config_entry(api_version=1) + config_entry.add_to_hass(hass) + gone_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(hue.DOMAIN, "00:00:00:00:00:00:00:00")}, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": hue.DOMAIN, + "device_id": gone_device.id, + "type": "remote_button_short_press", + "subtype": "button_4", + }, + "action": {"service": "test.automation"}, + } + ] + }, + ) + await hass.async_block_till_done() + + mock_bridge_v1.config_entry = config_entry + with ( + patch.object(hue.migration, "is_v2_bridge", return_value=False), + patch("homeassistant.components.hue.HueBridge", return_value=mock_bridge_v1), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + f"Got error 'Device {gone_device.id} is not available on the Hue bridge' " + "when setting up triggers for automation 0" in caplog.text + ) diff --git a/tests/components/hue/test_device_trigger_v2.py b/tests/components/hue/test_device_trigger_v2.py index b89aa2ed91d3..1a7685884b41 100644 --- a/tests/components/hue/test_device_trigger_v2.py +++ b/tests/components/hue/test_device_trigger_v2.py @@ -1,22 +1,56 @@ """The tests for Philips Hue device triggers for V2 bridge.""" -from unittest.mock import Mock +from typing import Any +from unittest.mock import Mock, patch from aiohue.v2.models.button import ButtonEvent +import pytest from pytest_unordered import unordered -from homeassistant.components import hue -from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components import automation, hue +from homeassistant.components.device_automation import ( + DeviceAutomationType, + InvalidDeviceAutomationConfig, +) +from homeassistant.components.hue import device_trigger from homeassistant.components.hue.v2.device import async_setup_devices from homeassistant.components.hue.v2.hue_event import async_setup_hue_events -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_PLATFORM, + CONF_TYPE, + SERVICE_TURN_OFF, + Platform, +) +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.trigger import TriggerInfo +from homeassistant.setup import async_setup_component from homeassistant.util.json import JsonArrayType -from .conftest import setup_platform +from .conftest import create_config_entry, setup_platform -from tests.common import async_capture_events, async_get_device_automations +from tests.common import ( + MockConfigEntry, + async_capture_events, + async_get_device_automations, +) + +WALL_SWITCH_DEVICE_ID = "3ff06175-29e8-44a8-8fe7-af591b0025da" +WALL_SWITCH_BUTTON_PRESS = { + "button": { + "button_report": { + "event": "initial_press", + "updated": "2021-10-01T12:00:00Z", + } + }, + "id": "c658d3d8-a013-4b81-8ac6-78b248537e70", + "metadata": {"control_id": 1}, + "type": "button", +} async def test_hue_event( @@ -149,3 +183,162 @@ async def test_get_triggers_for_removed_device( hass, DeviceAutomationType.TRIGGER, orphaned_device.id ) assert triggers == [] + + +async def test_if_fires_when_config_entry_loads_late( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + device_registry: dr.DeviceRegistry, + service_calls: list[ServiceCall], +) -> None: + """Test a trigger attached before the bridge is loaded still fires. + + Regression test for https://github.com/home-assistant/core/issues/156390 + """ + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + config_entry = create_config_entry(api_version=2) + config_entry.add_to_hass(hass) + + # the device is restored from the registry while the bridge is still setting up + wall_switch_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(hue.DOMAIN, WALL_SWITCH_DEVICE_ID)}, + ) + + assert await async_setup_component( + hass, automation.DOMAIN, _automation_config(wall_switch_device.id) + ) + await hass.async_block_till_done() + assert config_entry.state is not ConfigEntryState.LOADED + + await _load_bridge(hass, mock_bridge_v2, config_entry) + + mock_bridge_v2.api.emit_event("update", WALL_SWITCH_BUTTON_PRESS) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert len(service_calls) == 1 + assert service_calls[0].data["some"] == ButtonEvent.INITIAL_PRESS.value + + # turning the automation off detaches the trigger again + await _turn_off_automation(hass) + service_calls.clear() + mock_bridge_v2.api.emit_event("update", WALL_SWITCH_BUTTON_PRESS) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert len(service_calls) == 0 + + +async def test_trigger_removed_before_config_entry_loads( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + device_registry: dr.DeviceRegistry, + service_calls: list[ServiceCall], +) -> None: + """Test a trigger removed while the bridge is still loading never fires.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + config_entry = create_config_entry(api_version=2) + config_entry.add_to_hass(hass) + wall_switch_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(hue.DOMAIN, WALL_SWITCH_DEVICE_ID)}, + ) + + assert await async_setup_component( + hass, automation.DOMAIN, _automation_config(wall_switch_device.id) + ) + await hass.async_block_till_done() + + await _turn_off_automation(hass) + service_calls.clear() + + await _load_bridge(hass, mock_bridge_v2, config_entry) + + mock_bridge_v2.api.emit_event("update", WALL_SWITCH_BUTTON_PRESS) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert len(service_calls) == 0 + + +async def test_attach_trigger_for_device_without_hue_bridge( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test attaching a trigger for a device that is not on any Hue bridge.""" + other_entry = MockConfigEntry(domain="other") + other_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, + identifiers={("other", "not-a-hue-device")}, + ) + trigger_info: TriggerInfo = { + "domain": automation.DOMAIN, + "name": "test", + "variables": None, + "trigger_data": {"id": "", "idx": "0", "alias": None}, + } + + with pytest.raises(InvalidDeviceAutomationConfig): + await device_trigger.async_attach_trigger( + hass, + { + CONF_PLATFORM: "device", + CONF_DOMAIN: hue.DOMAIN, + CONF_DEVICE_ID: device.id, + CONF_TYPE: ButtonEvent.INITIAL_PRESS.value, + "subtype": 1, + }, + Mock(), + trigger_info, + ) + + +def _automation_config(device_id: str) -> dict[str, Any]: + """Return an automation config triggering on a button press of the device.""" + return { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": hue.DOMAIN, + "device_id": device_id, + "type": ButtonEvent.INITIAL_PRESS.value, + "subtype": 1, + }, + "action": { + "service": "test.automation", + "data_template": {"some": "{{ trigger.event.data.type }}"}, + }, + } + ] + } + + +async def _load_bridge( + hass: HomeAssistant, mock_bridge: Mock, config_entry: MockConfigEntry +) -> None: + """Set up a config entry that was already added to hass.""" + mock_bridge.config_entry = config_entry + with ( + patch.object(hue.migration, "is_v2_bridge", return_value=True), + patch("homeassistant.components.hue.HueBridge", return_value=mock_bridge), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.LOADED + await async_setup_hue_events(mock_bridge) + await hass.async_block_till_done() + + +async def _turn_off_automation(hass: HomeAssistant) -> None: + """Turn off the automation created by `_automation_config`.""" + await hass.services.async_call( + automation.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "automation.automation_0"}, + blocking=True, + ) + await hass.async_block_till_done() From baf53be7b91a330b5ce58473eb73eae38241a898 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 3 Aug 2026 15:47:26 +0200 Subject: [PATCH 052/109] Add binary_sensor platform to Mikrotik (#177873) --- homeassistant/components/mikrotik/__init__.py | 1 + .../components/mikrotik/binary_sensor.py | 56 +++++++ .../snapshots/test_binary_sensor.ambr | 154 ++++++++++++++++++ .../components/mikrotik/test_binary_sensor.py | 38 +++++ 4 files changed, 249 insertions(+) create mode 100644 homeassistant/components/mikrotik/binary_sensor.py create mode 100644 tests/components/mikrotik/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/mikrotik/test_binary_sensor.py diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index 53f56766bb3f..463dd083c435 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -17,6 +17,7 @@ from .coordinator import ( ) PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR, diff --git a/homeassistant/components/mikrotik/binary_sensor.py b/homeassistant/components/mikrotik/binary_sensor.py new file mode 100644 index 000000000000..5b594a127304 --- /dev/null +++ b/homeassistant/components/mikrotik/binary_sensor.py @@ -0,0 +1,56 @@ +"""Support for binary sensors.""" + +from typing import Final, override + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import MikrotikConfigEntry +from .entity import MikrotikDeviceEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +BINARY_SENSOR_TYPES: Final = { + BinarySensorEntityDescription( + key="running", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MikrotikConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up binary sensor entities for Mikrotik Devices.""" + + coordinator = entry.runtime_data + + binary_sensors_list = [ + MikrotikBinarySensorEntity(entry, coordinator, binary_sensor_desc, interface) + for binary_sensor_desc in BINARY_SENSOR_TYPES + for interface in coordinator.api.interfaces + if interface.get(binary_sensor_desc.key) is not None + ] + + async_add_entities(binary_sensors_list) + + +class MikrotikBinarySensorEntity(MikrotikDeviceEntity, BinarySensorEntity): + """Binary sensor entity for Mikrotik.""" + + @property + @override + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return self._interface.get(self.entity_description.key) diff --git a/tests/components/mikrotik/snapshots/test_binary_sensor.ambr b/tests/components/mikrotik/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..749526c87374 --- /dev/null +++ b/tests/components/mikrotik/snapshots/test_binary_sensor.ambr @@ -0,0 +1,154 @@ +# serializer version: 1 +# name: test_binary_sensor_entities_created[binary_sensor.bridge1_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bridge1_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Connectivity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'mikrotik', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00_0c_29_00_00_03_bridge1', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_entities_created[binary_sensor.bridge1_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'connectivity', + : 'bridge1 Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.bridge1_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_entities_created[binary_sensor.ether1_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ether1_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Connectivity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'mikrotik', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00_0c_29_00_00_01_ether1', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_entities_created[binary_sensor.ether1_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'connectivity', + : 'ether1 Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.ether1_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_entities_created[binary_sensor.wlan1_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.wlan1_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Connectivity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'mikrotik', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00_0c_29_00_00_02_wlan1', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_entities_created[binary_sensor.wlan1_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'connectivity', + : 'wlan1 Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.wlan1_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/mikrotik/test_binary_sensor.py b/tests/components/mikrotik/test_binary_sensor.py new file mode 100644 index 000000000000..319285cd089e --- /dev/null +++ b/tests/components/mikrotik/test_binary_sensor.py @@ -0,0 +1,38 @@ +"""Tests for the Mikrotik binary sensor platform.""" + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_mikrotik_entry +from .const import BRIDGE1_INTERFACE, INTERFACE_DATA + +from tests.common import snapshot_platform + + +async def test_binary_sensor_entities_created( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test Mikrotik binary sensor entities are created with expected values.""" + with patch("homeassistant.components.mikrotik.PLATFORMS", [Platform.BINARY_SENSOR]): + config_entry = await setup_mikrotik_entry(hass, interface_data=INTERFACE_DATA) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_binary_sensor_no_matching_interfaces(hass: HomeAssistant) -> None: + """Test no binary sensor entities are created for interfaces without running key.""" + interface_without_running = { + key: value for key, value in BRIDGE1_INTERFACE.items() if key != "running" + } + with patch("homeassistant.components.mikrotik.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_mikrotik_entry(hass, interface_data=[interface_without_running]) + + assert hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN) == [] From d31c2099730db6460146ba125f86541d8bdf4854 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 15:49:36 +0200 Subject: [PATCH 053/109] Adapt homee to set via_device_id in DeviceInfo (#177721) --- .../components/homee/alarm_control_panel.py | 10 ++++-- .../components/homee/binary_sensor.py | 8 +++-- homeassistant/components/homee/button.py | 10 ++++-- homeassistant/components/homee/climate.py | 13 ++++--- homeassistant/components/homee/cover.py | 13 ++++--- homeassistant/components/homee/entity.py | 27 ++++++++++++--- homeassistant/components/homee/event.py | 10 ++++-- homeassistant/components/homee/fan.py | 11 +++--- homeassistant/components/homee/helpers.py | 13 +++++-- homeassistant/components/homee/light.py | 10 ++++-- homeassistant/components/homee/lock.py | 13 ++++--- homeassistant/components/homee/number.py | 10 ++++-- homeassistant/components/homee/select.py | 10 ++++-- homeassistant/components/homee/sensor.py | 15 +++++--- homeassistant/components/homee/siren.py | 7 ++-- homeassistant/components/homee/switch.py | 34 +++++++++++++------ homeassistant/components/homee/valve.py | 10 ++++-- tests/components/homee/test_init.py | 28 +++++++++++++++ 18 files changed, 186 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/homee/alarm_control_panel.py b/homeassistant/components/homee/alarm_control_panel.py index 348d5d00c120..69d5690e7895 100644 --- a/homeassistant/components/homee/alarm_control_panel.py +++ b/homeassistant/components/homee/alarm_control_panel.py @@ -61,13 +61,16 @@ def get_supported_features( async def add_alarm_control_panel_entities( + hass: HomeAssistant, config_entry: HomeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, nodes: list[HomeeNode], ) -> None: """Add homee alarm control panel entities.""" async_add_entities( - HomeeAlarmPanel(attribute, config_entry, ALARM_DESCRIPTIONS[attribute.type]) + HomeeAlarmPanel( + hass, attribute, config_entry, ALARM_DESCRIPTIONS[attribute.type] + ) for node in nodes for attribute in node.attributes if attribute.type in ALARM_DESCRIPTIONS and attribute.editable @@ -82,7 +85,7 @@ async def async_setup_entry( """Add the homee platform for the alarm control panel component.""" await setup_homee_platform( - add_alarm_control_panel_entities, async_add_entities, config_entry + hass, add_alarm_control_panel_entities, async_add_entities, config_entry ) @@ -93,12 +96,13 @@ class HomeeAlarmPanel(HomeeEntity, AlarmControlPanelEntity): def __init__( self, + hass: HomeAssistant, attribute: HomeeAttribute, entry: HomeeConfigEntry, description: HomeeAlarmControlPanelEntityDescription, ) -> None: """Initialize a Homee alarm control panel entity.""" - super().__init__(attribute, entry) + super().__init__(hass, attribute, entry) self.entity_description = description self._attr_code_arm_required = description.code_arm_required self._attr_supported_features = get_supported_features(description.state_list) diff --git a/homeassistant/components/homee/binary_sensor.py b/homeassistant/components/homee/binary_sensor.py index 869a4ca690d8..1d0c7b85efa7 100644 --- a/homeassistant/components/homee/binary_sensor.py +++ b/homeassistant/components/homee/binary_sensor.py @@ -156,6 +156,7 @@ BINARY_SENSOR_DESCRIPTIONS: dict[AttributeType, BinarySensorEntityDescription] = async def add_binary_sensor_entities( + hass: HomeAssistant, config_entry: HomeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, nodes: list[HomeeNode], @@ -163,7 +164,7 @@ async def add_binary_sensor_entities( """Add homee binary sensor entities.""" async_add_entities( HomeeBinarySensor( - attribute, config_entry, BINARY_SENSOR_DESCRIPTIONS[attribute.type] + hass, attribute, config_entry, BINARY_SENSOR_DESCRIPTIONS[attribute.type] ) for node in nodes for attribute in node.attributes @@ -179,7 +180,7 @@ async def async_setup_entry( """Add the homee platform for the binary sensor component.""" await setup_homee_platform( - add_binary_sensor_entities, async_add_entities, config_entry + hass, add_binary_sensor_entities, async_add_entities, config_entry ) @@ -188,12 +189,13 @@ class HomeeBinarySensor(HomeeEntity, BinarySensorEntity): def __init__( self, + hass: HomeAssistant, attribute: HomeeAttribute, entry: HomeeConfigEntry, description: BinarySensorEntityDescription, ) -> None: """Initialize a Homee binary sensor entity.""" - super().__init__(attribute, entry) + super().__init__(hass, attribute, entry) self.entity_description = description self._attr_translation_key = description.key diff --git a/homeassistant/components/homee/button.py b/homeassistant/components/homee/button.py index 610d27987095..f660fe02619e 100644 --- a/homeassistant/components/homee/button.py +++ b/homeassistant/components/homee/button.py @@ -43,13 +43,14 @@ BUTTON_DESCRIPTIONS: dict[AttributeType, ButtonEntityDescription] = { async def add_button_entities( + hass: HomeAssistant, config_entry: HomeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, nodes: list[HomeeNode], ) -> None: """Add homee button entities.""" async_add_entities( - HomeeButton(attribute, config_entry, BUTTON_DESCRIPTIONS[attribute.type]) + HomeeButton(hass, attribute, config_entry, BUTTON_DESCRIPTIONS[attribute.type]) for node in nodes for attribute in node.attributes if attribute.type in BUTTON_DESCRIPTIONS and attribute.editable @@ -63,7 +64,9 @@ async def async_setup_entry( ) -> None: """Add the homee platform for the button component.""" - await setup_homee_platform(add_button_entities, async_add_entities, config_entry) + await setup_homee_platform( + hass, add_button_entities, async_add_entities, config_entry + ) class HomeeButton(HomeeEntity, ButtonEntity): @@ -71,12 +74,13 @@ class HomeeButton(HomeeEntity, ButtonEntity): def __init__( self, + hass: HomeAssistant, attribute: HomeeAttribute, entry: HomeeConfigEntry, description: ButtonEntityDescription, ) -> None: """Initialize a Homee button entity.""" - super().__init__(attribute, entry) + super().__init__(hass, attribute, entry) self.entity_description = description if attribute.instance == 0: if attribute.type == AttributeType.IMPULSE: diff --git a/homeassistant/components/homee/climate.py b/homeassistant/components/homee/climate.py index 104bd5034a1b..ed03b72ad81b 100644 --- a/homeassistant/components/homee/climate.py +++ b/homeassistant/components/homee/climate.py @@ -33,13 +33,14 @@ ROOM_THERMOSTATS = { async def add_climate_entities( + hass: HomeAssistant, config_entry: HomeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, nodes: list[HomeeNode], ) -> None: """Add homee climate entities.""" async_add_entities( - HomeeClimate(node, config_entry) + HomeeClimate(hass, node, config_entry) for node in nodes if node.profile in CLIMATE_PROFILES ) @@ -52,7 +53,9 @@ async def async_setup_entry( ) -> None: """Add the Homee platform for the climate component.""" - await setup_homee_platform(add_climate_entities, async_add_entities, config_entry) + await setup_homee_platform( + hass, add_climate_entities, async_add_entities, config_entry + ) class HomeeClimate(HomeeNodeEntity, ClimateEntity): @@ -61,9 +64,11 @@ class HomeeClimate(HomeeNodeEntity, ClimateEntity): _attr_name = None _attr_translation_key = DOMAIN - def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, node: HomeeNode, entry: HomeeConfigEntry + ) -> None: """Initialize a Homee climate entity.""" - super().__init__(node, entry) + super().__init__(hass, node, entry) ( self._attr_supported_features, diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py index b2f853dc792f..6cf2d879c2c0 100644 --- a/homeassistant/components/homee/cover.py +++ b/homeassistant/components/homee/cover.py @@ -101,6 +101,7 @@ def get_device_class(node: HomeeNode) -> CoverDeviceClass | None: async def add_cover_entities( + hass: HomeAssistant, config_entry: HomeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, nodes: list[HomeeNode], @@ -122,7 +123,7 @@ async def add_cover_entities( node.name, ) - async_add_entities(HomeeCover(cover, config_entry) for cover in entities) + async_add_entities(HomeeCover(hass, cover, config_entry) for cover in entities) async def async_setup_entry( @@ -132,7 +133,9 @@ async def async_setup_entry( ) -> None: """Add the homee platform for the cover integration.""" - await setup_homee_platform(add_cover_entities, async_add_entities, config_entry) + await setup_homee_platform( + hass, add_cover_entities, async_add_entities, config_entry + ) def is_cover_node(node: HomeeNode) -> bool: @@ -151,9 +154,11 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): _attr_name = None - def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, node: HomeeNode, entry: HomeeConfigEntry + ) -> None: """Initialize a homee cover entity.""" - super().__init__(node, entry) + super().__init__(hass, node, entry) self._open_close_attribute = get_open_close_attribute(node) self._attr_supported_features = get_cover_features( node, self._open_close_attribute diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py index 39543154ef22..c94c6289aaa5 100644 --- a/homeassistant/components/homee/entity.py +++ b/homeassistant/components/homee/entity.py @@ -7,7 +7,9 @@ from pyHomee.const import AttributeType, NodeProfile, NodeState from pyHomee.model import HomeeAttribute, HomeeNode from websockets.exceptions import ConnectionClosed +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -26,7 +28,9 @@ class HomeeEntity(Entity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, attribute: HomeeAttribute, entry: HomeeConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, attribute: HomeeAttribute, entry: HomeeConfigEntry + ) -> None: """Initialize the wrapper using a HomeeAttribute and target entity.""" self._attribute = attribute self._attr_unique_id = ( @@ -43,11 +47,18 @@ class HomeeEntity(Entity): else: self._attr_device_info = DeviceInfo( identifiers={ - (DOMAIN, f"{entry.runtime_data.settings.uid}-{attribute.node_id}") + ( + DOMAIN, + f"{entry.runtime_data.settings.uid}-{attribute.node_id}", + ) }, name=node.name, model=get_name_for_enum(NodeProfile, node.profile), - via_device=(DOMAIN, entry.runtime_data.settings.uid), + via_device_id=dr.async_get_device_id_by_identifier( + hass, + (DOMAIN, entry.runtime_data.settings.uid), + config_entry_id=entry.entry_id, + ), ) if attribute.name: self._attr_name = attribute.name @@ -106,7 +117,9 @@ class HomeeNodeEntity(Entity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, node: HomeeNode, entry: HomeeConfigEntry + ) -> None: """Initialize the wrapper using a HomeeNode and target entity.""" self._node = node self._attr_unique_id = f"{entry.unique_id}-{node.id}" @@ -123,7 +136,11 @@ class HomeeNodeEntity(Entity): name=node.name, model=get_name_for_enum(NodeProfile, node.profile), sw_version=self._get_software_version(), - via_device=(DOMAIN, entry.runtime_data.settings.uid), + via_device_id=dr.async_get_device_id_by_identifier( + hass, + (DOMAIN, entry.runtime_data.settings.uid), + config_entry_id=entry.entry_id, + ), ) self._host_connected = entry.runtime_data.connected diff --git a/homeassistant/components/homee/event.py b/homeassistant/components/homee/event.py index 2e2ae896e613..6a23c2c45995 100644 --- a/homeassistant/components/homee/event.py +++ b/homeassistant/components/homee/event.py @@ -54,13 +54,14 @@ EVENT_DESCRIPTIONS: dict[AttributeType, EventEntityDescription] = { async def add_event_entities( + hass: HomeAssistant, config_entry: HomeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, nodes: list[HomeeNode], ) -> None: """Add homee event entities.""" async_add_entities( - HomeeEvent(attribute, config_entry, EVENT_DESCRIPTIONS[attribute.type]) + HomeeEvent(hass, attribute, config_entry, EVENT_DESCRIPTIONS[attribute.type]) for node in nodes for attribute in node.attributes if attribute.type in EVENT_DESCRIPTIONS @@ -76,7 +77,9 @@ async def async_setup_entry( ) -> None: """Add event entities for homee.""" - await setup_homee_platform(add_event_entities, async_add_entities, config_entry) + await setup_homee_platform( + hass, add_event_entities, async_add_entities, config_entry + ) class HomeeEvent(HomeeEntity, EventEntity): @@ -84,12 +87,13 @@ class HomeeEvent(HomeeEntity, EventEntity): def __init__( self, + hass: HomeAssistant, attribute: HomeeAttribute, entry: HomeeConfigEntry, description: EventEntityDescription, ) -> None: """Initialize the homee event entity.""" - super().__init__(attribute, entry) + super().__init__(hass, attribute, entry) self.entity_description = description self._attr_translation_key = description.key if attribute.instance > 0: diff --git a/homeassistant/components/homee/fan.py b/homeassistant/components/homee/fan.py index 287fed257697..75594a198368 100644 --- a/homeassistant/components/homee/fan.py +++ b/homeassistant/components/homee/fan.py @@ -25,13 +25,14 @@ PARALLEL_UPDATES = 0 async def add_fan_entities( + hass: HomeAssistant, config_entry: HomeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, nodes: list[HomeeNode], ) -> None: """Add homee fan entities.""" async_add_entities( - HomeeFan(node, config_entry) + HomeeFan(hass, node, config_entry) for node in nodes if node.profile == NodeProfile.VENTILATION_CONTROL ) @@ -44,7 +45,7 @@ async def async_setup_entry( ) -> None: """Set up the Homee fan platform.""" - await setup_homee_platform(add_fan_entities, async_add_entities, config_entry) + await setup_homee_platform(hass, add_fan_entities, async_add_entities, config_entry) class HomeeFan(HomeeNodeEntity, FanEntity): @@ -56,9 +57,11 @@ class HomeeFan(HomeeNodeEntity, FanEntity): speed_range = (1, 8) _attr_speed_count = int_states_in_range(speed_range) - def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, node: HomeeNode, entry: HomeeConfigEntry + ) -> None: """Initialize a Homee fan entity.""" - super().__init__(node, entry) + super().__init__(hass, node, entry) self._speed_attribute: HomeeAttribute = cast( HomeeAttribute, node.get_attribute_by_type(AttributeType.VENTILATION_LEVEL) ) diff --git a/homeassistant/components/homee/helpers.py b/homeassistant/components/homee/helpers.py index f9f675a631d0..0c819da0ebca 100644 --- a/homeassistant/components/homee/helpers.py +++ b/homeassistant/components/homee/helpers.py @@ -7,6 +7,7 @@ from typing import Any from pyHomee.model import HomeeNode +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import HomeeConfigEntry @@ -15,8 +16,14 @@ _LOGGER = logging.getLogger(__name__) async def setup_homee_platform( + hass: HomeAssistant, add_platform_entities: Callable[ - [HomeeConfigEntry, AddConfigEntryEntitiesCallback, list[HomeeNode]], + [ + HomeAssistant, + HomeeConfigEntry, + AddConfigEntryEntitiesCallback, + list[HomeeNode], + ], Coroutine[Any, Any, None], ], async_add_entities: AddConfigEntryEntitiesCallback, @@ -24,13 +31,13 @@ async def setup_homee_platform( ) -> None: """Set up a homee platform.""" await add_platform_entities( - config_entry, async_add_entities, config_entry.runtime_data.nodes + hass, config_entry, async_add_entities, config_entry.runtime_data.nodes ) async def add_device(node: HomeeNode, add: bool) -> None: """Dynamically add entities.""" if add: - await add_platform_entities(config_entry, async_add_entities, [node]) + await add_platform_entities(hass, config_entry, async_add_entities, [node]) config_entry.async_on_unload( config_entry.runtime_data.add_nodes_listener(add_device) diff --git a/homeassistant/components/homee/light.py b/homeassistant/components/homee/light.py index 50be31bd81ee..ad60b7cfe09b 100644 --- a/homeassistant/components/homee/light.py +++ b/homeassistant/components/homee/light.py @@ -87,13 +87,14 @@ def decimal_to_rgb_list(color: float) -> list[int]: async def add_light_entities( + hass: HomeAssistant, config_entry: HomeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, nodes: list[HomeeNode], ) -> None: """Add homee light entities.""" async_add_entities( - HomeeLight(node, light, config_entry) + HomeeLight(hass, node, light, config_entry) for node in nodes for light in get_light_attribute_sets(node) if is_light_node(node) @@ -107,7 +108,9 @@ async def async_setup_entry( ) -> None: """Add the homee platform for the light entity.""" - await setup_homee_platform(add_light_entities, async_add_entities, config_entry) + await setup_homee_platform( + hass, add_light_entities, async_add_entities, config_entry + ) class HomeeLight(HomeeNodeEntity, LightEntity): @@ -115,12 +118,13 @@ class HomeeLight(HomeeNodeEntity, LightEntity): def __init__( self, + hass: HomeAssistant, node: HomeeNode, light: dict[AttributeType, HomeeAttribute], entry: HomeeConfigEntry, ) -> None: """Initialize a Homee light.""" - super().__init__(node, entry) + super().__init__(hass, node, entry) self._on_off_attr: HomeeAttribute = light[AttributeType.ON_OFF] self._dimmer_attr: HomeeAttribute | None = light.get( diff --git a/homeassistant/components/homee/lock.py b/homeassistant/components/homee/lock.py index e6a77dce9d7a..3567c41449d6 100644 --- a/homeassistant/components/homee/lock.py +++ b/homeassistant/components/homee/lock.py @@ -35,13 +35,14 @@ def _determine_lock_state_open(attribute: HomeeAttribute) -> float | None: async def add_lock_entities( + hass: HomeAssistant, config_entry: HomeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, nodes: list[HomeeNode], ) -> None: """Add homee lock entities.""" async_add_entities( - HomeeLock(attribute, config_entry) + HomeeLock(hass, attribute, config_entry) for node in nodes for attribute in node.attributes if (attribute.type == AttributeType.LOCK_STATE and attribute.editable) @@ -55,7 +56,9 @@ async def async_setup_entry( ) -> None: """Add the homee platform for the lock component.""" - await setup_homee_platform(add_lock_entities, async_add_entities, config_entry) + await setup_homee_platform( + hass, add_lock_entities, async_add_entities, config_entry + ) class HomeeLock(HomeeEntity, LockEntity): @@ -63,9 +66,11 @@ class HomeeLock(HomeeEntity, LockEntity): _attr_name = None - def __init__(self, attribute: HomeeAttribute, entry: HomeeConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, attribute: HomeeAttribute, entry: HomeeConfigEntry + ) -> None: """Initialize the homee lock.""" - super().__init__(attribute, entry) + super().__init__(hass, attribute, entry) self._lock_state_open = _determine_lock_state_open(attribute) if self._lock_state_open is not None: self._attr_supported_features = LockEntityFeature.OPEN diff --git a/homeassistant/components/homee/number.py b/homeassistant/components/homee/number.py index 8ca501c368c8..27b77b58a613 100644 --- a/homeassistant/components/homee/number.py +++ b/homeassistant/components/homee/number.py @@ -139,13 +139,14 @@ NUMBER_DESCRIPTIONS = { async def add_number_entities( + hass: HomeAssistant, config_entry: HomeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, nodes: list[HomeeNode], ) -> None: """Add homee number entities.""" async_add_entities( - HomeeNumber(attribute, config_entry, NUMBER_DESCRIPTIONS[attribute.type]) + HomeeNumber(hass, attribute, config_entry, NUMBER_DESCRIPTIONS[attribute.type]) for node in nodes for attribute in node.attributes if attribute.type in NUMBER_DESCRIPTIONS and attribute.data != "fixed_value" @@ -159,7 +160,9 @@ async def async_setup_entry( ) -> None: """Add the homee platform for the number component.""" - await setup_homee_platform(add_number_entities, async_add_entities, config_entry) + await setup_homee_platform( + hass, add_number_entities, async_add_entities, config_entry + ) class HomeeNumber(HomeeEntity, NumberEntity): @@ -169,12 +172,13 @@ class HomeeNumber(HomeeEntity, NumberEntity): def __init__( self, + hass: HomeAssistant, attribute: HomeeAttribute, entry: HomeeConfigEntry, description: HomeeNumberEntityDescription, ) -> None: """Initialize a Homee number entity.""" - super().__init__(attribute, entry) + super().__init__(hass, attribute, entry) self.entity_description = description self._attr_translation_key = description.key self._attr_native_unit_of_measurement = ( diff --git a/homeassistant/components/homee/select.py b/homeassistant/components/homee/select.py index 5673ba26889a..915591ecd211 100644 --- a/homeassistant/components/homee/select.py +++ b/homeassistant/components/homee/select.py @@ -31,13 +31,14 @@ SELECT_DESCRIPTIONS: dict[AttributeType, SelectEntityDescription] = { async def add_select_entities( + hass: HomeAssistant, config_entry: HomeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, nodes: list[HomeeNode], ) -> None: """Add homee select entities.""" async_add_entities( - HomeeSelect(attribute, config_entry, SELECT_DESCRIPTIONS[attribute.type]) + HomeeSelect(hass, attribute, config_entry, SELECT_DESCRIPTIONS[attribute.type]) for node in nodes for attribute in node.attributes if attribute.type in SELECT_DESCRIPTIONS and attribute.editable @@ -51,7 +52,9 @@ async def async_setup_entry( ) -> None: """Add the homee platform for the select component.""" - await setup_homee_platform(add_select_entities, async_add_entities, config_entry) + await setup_homee_platform( + hass, add_select_entities, async_add_entities, config_entry + ) class HomeeSelect(HomeeEntity, SelectEntity): @@ -59,12 +62,13 @@ class HomeeSelect(HomeeEntity, SelectEntity): def __init__( self, + hass: HomeAssistant, attribute: HomeeAttribute, entry: HomeeConfigEntry, description: SelectEntityDescription, ) -> None: """Initialize a Homee select entity.""" - super().__init__(attribute, entry) + super().__init__(hass, attribute, entry) self.entity_description = description assert description.options is not None self._attr_options = description.options diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py index e345629c25d2..0285aee8b9b0 100644 --- a/homeassistant/components/homee/sensor.py +++ b/homeassistant/components/homee/sensor.py @@ -290,6 +290,7 @@ async def async_setup_entry( """Add the homee platform for the sensor components.""" async def add_sensor_entities( + hass: HomeAssistant, config_entry: HomeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, nodes: list[HomeeNode], @@ -300,14 +301,14 @@ async def async_setup_entry( for node in nodes: # Node properties that are sensors. entities.extend( - HomeeNodeSensor(node, config_entry, description) + HomeeNodeSensor(hass, node, config_entry, description) for description in NODE_SENSOR_DESCRIPTIONS ) # Node attributes that are sensors. entities.extend( HomeeSensor( - attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type] + hass, attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type] ) for attribute in node.attributes if attribute.type in SENSOR_DESCRIPTIONS and not attribute.editable @@ -316,7 +317,9 @@ async def async_setup_entry( if entities: async_add_entities(entities) - await setup_homee_platform(add_sensor_entities, async_add_entities, config_entry) + await setup_homee_platform( + hass, add_sensor_entities, async_add_entities, config_entry + ) class HomeeSensor(HomeeEntity, SensorEntity): @@ -326,12 +329,13 @@ class HomeeSensor(HomeeEntity, SensorEntity): def __init__( self, + hass: HomeAssistant, attribute: HomeeAttribute, entry: HomeeConfigEntry, description: HomeeSensorEntityDescription, ) -> None: """Initialize a homee sensor entity.""" - super().__init__(attribute, entry) + super().__init__(hass, attribute, entry) self.entity_description = description self._attr_translation_key = description.key if attribute.instance > 0: @@ -363,12 +367,13 @@ class HomeeNodeSensor(HomeeNodeEntity, SensorEntity): def __init__( self, + hass: HomeAssistant, node: HomeeNode, entry: HomeeConfigEntry, description: HomeeNodeSensorEntityDescription, ) -> None: """Initialize a homee node sensor entity.""" - super().__init__(node, entry) + super().__init__(hass, node, entry) self.entity_description = description self._node = node self._attr_unique_id = f"{self._attr_unique_id}-{description.key}" diff --git a/homeassistant/components/homee/siren.py b/homeassistant/components/homee/siren.py index d2bce747c97f..0617ec8f4c20 100644 --- a/homeassistant/components/homee/siren.py +++ b/homeassistant/components/homee/siren.py @@ -17,13 +17,14 @@ PARALLEL_UPDATES = 0 async def add_siren_entities( + hass: HomeAssistant, config_entry: HomeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, nodes: list[HomeeNode], ) -> None: """Add homee siren entities.""" async_add_entities( - HomeeSiren(attribute, config_entry) + HomeeSiren(hass, attribute, config_entry) for node in nodes for attribute in node.attributes if attribute.type == AttributeType.SIREN @@ -37,7 +38,9 @@ async def async_setup_entry( ) -> None: """Add siren entities for homee.""" - await setup_homee_platform(add_siren_entities, async_add_entities, config_entry) + await setup_homee_platform( + hass, add_siren_entities, async_add_entities, config_entry + ) class HomeeSiren(HomeeEntity, SirenEntity): diff --git a/homeassistant/components/homee/switch.py b/homeassistant/components/homee/switch.py index b4858e7d0ec3..c41bc86c837a 100644 --- a/homeassistant/components/homee/switch.py +++ b/homeassistant/components/homee/switch.py @@ -16,6 +16,7 @@ from homeassistant.components.switch import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -70,13 +71,14 @@ SWITCH_DESCRIPTIONS: dict[AttributeType, HomeeSwitchEntityDescription] = { async def add_switch_entities( + hass: HomeAssistant, config_entry: HomeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, nodes: list[HomeeNode], ) -> None: """Add homee switch entities.""" async_add_entities( - HomeeSwitch(attribute, config_entry, SWITCH_DESCRIPTIONS[attribute.type]) + HomeeSwitch(hass, attribute, config_entry, SWITCH_DESCRIPTIONS[attribute.type]) for node in nodes for attribute in node.attributes if (attribute.type in SWITCH_DESCRIPTIONS and attribute.editable) @@ -97,9 +99,11 @@ async def async_setup_entry( ) -> None: """Set up the switch platform for the Homee component.""" - await setup_homee_platform(add_switch_entities, async_add_entities, config_entry) + await setup_homee_platform( + hass, add_switch_entities, async_add_entities, config_entry + ) async_add_entities( - HomeegramSwitch(homeegram, config_entry) + HomeegramSwitch(hass, homeegram, config_entry) for homeegram in config_entry.runtime_data.homeegrams ) @@ -111,12 +115,13 @@ class HomeeSwitch(HomeeEntity, SwitchEntity): def __init__( self, + hass: HomeAssistant, attribute: HomeeAttribute, entry: HomeeConfigEntry, description: HomeeSwitchEntityDescription, ) -> None: """Initialize a Homee switch entity.""" - super().__init__(attribute, entry) + super().__init__(hass, attribute, entry) self.entity_description = description if attribute.instance == 0: if attribute.type == AttributeType.ON_OFF: @@ -156,17 +161,13 @@ class HomeegramSwitch(SwitchEntity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, homeegram: HomeeGram, entry: HomeeConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, homeegram: HomeeGram, entry: HomeeConfigEntry + ) -> None: """Initialize a homee Homeegram switch entity.""" self._homeegram = homeegram self._entry = entry self._attr_unique_id = f"{entry.unique_id}-hg-{homeegram.id}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{entry.unique_id}-homeegrams")}, - name="Homeegrams", - model="Homeegram Switches", - via_device=(DOMAIN, entry.runtime_data.settings.uid), - ) self._attr_translation_key = "homeegram" self._host_connected = entry.runtime_data.connected self._attr_name = homeegram.name @@ -175,6 +176,17 @@ class HomeegramSwitch(SwitchEntity): homeegram ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry.unique_id}-homeegrams")}, + name="Homeegrams", + model="Homeegram Switches", + via_device_id=dr.async_get_device_id_by_identifier( + hass, + (DOMAIN, entry.runtime_data.settings.uid), + config_entry_id=entry.entry_id, + ), + ) + @override async def async_added_to_hass(self) -> None: """Add the Homeegram entity to home assistant.""" diff --git a/homeassistant/components/homee/valve.py b/homeassistant/components/homee/valve.py index 7d3783ac619f..57eadff3d287 100644 --- a/homeassistant/components/homee/valve.py +++ b/homeassistant/components/homee/valve.py @@ -29,13 +29,14 @@ VALVE_DESCRIPTIONS = { async def add_valve_entities( + hass: HomeAssistant, config_entry: HomeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, nodes: list[HomeeNode], ) -> None: """Add homee valve entities.""" async_add_entities( - HomeeValve(attribute, config_entry, VALVE_DESCRIPTIONS[attribute.type]) + HomeeValve(hass, attribute, config_entry, VALVE_DESCRIPTIONS[attribute.type]) for node in nodes for attribute in node.attributes if attribute.type in VALVE_DESCRIPTIONS @@ -49,7 +50,9 @@ async def async_setup_entry( ) -> None: """Add the homee platform for the valve component.""" - await setup_homee_platform(add_valve_entities, async_add_entities, config_entry) + await setup_homee_platform( + hass, add_valve_entities, async_add_entities, config_entry + ) class HomeeValve(HomeeEntity, ValveEntity): @@ -59,12 +62,13 @@ class HomeeValve(HomeeEntity, ValveEntity): def __init__( self, + hass: HomeAssistant, attribute: HomeeAttribute, entry: HomeeConfigEntry, description: ValveEntityDescription, ) -> None: """Initialize a Homee valve entity.""" - super().__init__(attribute, entry) + super().__init__(hass, attribute, entry) self.entity_description = description self._attr_translation_key = description.key diff --git a/tests/components/homee/test_init.py b/tests/components/homee/test_init.py index b601b4de294e..b70491d3bbc1 100644 --- a/tests/components/homee/test_init.py +++ b/tests/components/homee/test_init.py @@ -99,6 +99,34 @@ async def test_general_data( assert device == snapshot +async def test_via_device_id( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test node device is linked to the hub device via via_device_id.""" + mock_homee.nodes = [ + build_mock_node("cover_with_position_slats.json"), + build_mock_node("homee.json"), + ] + mock_homee.get_node_by_id = lambda node_id: ( + mock_homee.nodes[0] if node_id == 3 else mock_homee.nodes[1] + ) + await setup_integration(hass, mock_config_entry) + + hub = device_registry.async_get_device_by_identifier( + (DOMAIN, HOMEE_ID), mock_config_entry.entry_id + ) + device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{HOMEE_ID}-3"), mock_config_entry.entry_id + ) + + assert hub is not None + assert device is not None + assert device.via_device_id == hub.id + + async def test_software_version( hass: HomeAssistant, mock_homee: MagicMock, From 36fb714766b7d4bad52b9d5425d5b9dea7d4c696 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 15:49:49 +0200 Subject: [PATCH 054/109] Fix via_device race in litejet (#177724) --- homeassistant/components/litejet/__init__.py | 11 +++++++- homeassistant/components/litejet/light.py | 16 ++++++++--- homeassistant/components/litejet/switch.py | 11 +++++--- tests/components/litejet/test_init.py | 28 ++++++++++++++++++++ 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/litejet/__init__.py b/homeassistant/components/litejet/__init__.py index 0c30aa4b73e2..f5461be88a23 100644 --- a/homeassistant/components/litejet/__init__.py +++ b/homeassistant/components/litejet/__init__.py @@ -8,8 +8,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr -from .const import PLATFORMS +from .const import DOMAIN, PLATFORMS type LiteJetConfigEntry = ConfigEntry[pylitejet.LiteJet] @@ -42,6 +43,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiteJetConfigEntry) -> b entry.runtime_data = system + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, f"{entry.entry_id}_mcp")}, + name="LiteJet", + manufacturer="Centralite", + model=system.model_name, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/litejet/light.py b/homeassistant/components/litejet/light.py index 0340bd64ccfa..09817d5feed1 100644 --- a/homeassistant/components/litejet/light.py +++ b/homeassistant/components/litejet/light.py @@ -13,6 +13,7 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -34,7 +35,7 @@ async def async_setup_entry( entities = [] for index in system.loads(): name = await system.get_load_name(index) - entities.append(LiteJetLight(config_entry, system, index, name)) + entities.append(LiteJetLight(hass, config_entry, system, index, name)) async_add_entities(entities, True) @@ -50,7 +51,12 @@ class LiteJetLight(LightEntity): _attr_name = None def __init__( - self, config_entry: LiteJetConfigEntry, system: LiteJet, index: int, name: str + self, + hass: HomeAssistant, + config_entry: LiteJetConfigEntry, + system: LiteJet, + index: int, + name: str, ) -> None: """Initialize a LiteJet light.""" self._config_entry = config_entry @@ -63,7 +69,11 @@ class LiteJetLight(LightEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{config_entry.entry_id}_light_{index}")}, name=name, - via_device=(DOMAIN, f"{config_entry.entry_id}_mcp"), + via_device_id=dr.async_get_device_id_by_identifier( + hass, + (DOMAIN, f"{config_entry.entry_id}_mcp"), + config_entry_id=config_entry.entry_id, + ), ) @override diff --git a/homeassistant/components/litejet/switch.py b/homeassistant/components/litejet/switch.py index ee4883a9d8a8..083c94a691b3 100644 --- a/homeassistant/components/litejet/switch.py +++ b/homeassistant/components/litejet/switch.py @@ -7,6 +7,7 @@ from pylitejet import LiteJet, LiteJetError from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -28,7 +29,7 @@ async def async_setup_entry( entities = [] for i in system.button_switches(): name = await system.get_switch_name(i) - entities.append(LiteJetSwitch(config_entry.entry_id, system, i, name)) + entities.append(LiteJetSwitch(hass, config_entry.entry_id, system, i, name)) async_add_entities(entities, True) @@ -41,7 +42,9 @@ class LiteJetSwitch(SwitchEntity): _attr_entity_registry_enabled_default = False _attr_device_class = SwitchDeviceClass.SWITCH - def __init__(self, entry_id: str, system: LiteJet, i: int, name: str) -> None: + def __init__( + self, hass: HomeAssistant, entry_id: str, system: LiteJet, i: int, name: str + ) -> None: """Initialize a LiteJet switch.""" self._lj = system self._index = i @@ -55,7 +58,9 @@ class LiteJetSwitch(SwitchEntity): identifiers={(DOMAIN, f"{entry_id}_keypad_{keypad_number}")}, name=system.get_switch_keypad_name(i), manufacturer="Centralite", - via_device=(DOMAIN, f"{entry_id}_mcp"), + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, f"{entry_id}_mcp"), config_entry_id=entry_id + ), ) @override diff --git a/tests/components/litejet/test_init.py b/tests/components/litejet/test_init.py index 9232e9fc8af9..1948b968bbe8 100644 --- a/tests/components/litejet/test_init.py +++ b/tests/components/litejet/test_init.py @@ -1,7 +1,10 @@ """The tests for the litejet component.""" +import pytest + from homeassistant.components.litejet.const import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import async_init_integration @@ -13,6 +16,31 @@ async def test_setup_with_no_config(hass: HomeAssistant) -> None: assert DOMAIN not in hass.data +@pytest.mark.usefixtures("mock_litejet") +async def test_child_devices_link_to_mcp( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test that light and switch devices are linked to the MCP parent device.""" + entry = await async_init_integration(hass, use_switch=True) + + parent = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{entry.entry_id}_mcp"), entry.entry_id + ) + assert parent is not None + + light_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{entry.entry_id}_light_1"), entry.entry_id + ) + assert light_device is not None + assert light_device.via_device_id == parent.id + + switch_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{entry.entry_id}_keypad_101"), entry.entry_id + ) + assert switch_device is not None + assert switch_device.via_device_id == parent.id + + async def test_unload_entry(hass: HomeAssistant, mock_litejet) -> None: """Test being able to unload an entry.""" entry = await async_init_integration(hass, use_switch=True, use_scene=True) From 72fda4706de7842c8f6a98c06f0dfee864d4e478 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 15:50:03 +0200 Subject: [PATCH 055/109] Fix via_device race in lupusec (#177729) --- homeassistant/components/lupusec/__init__.py | 13 +++ .../components/lupusec/binary_sensor.py | 2 +- homeassistant/components/lupusec/entity.py | 11 ++- homeassistant/components/lupusec/switch.py | 2 +- tests/components/lupusec/test_init.py | 85 +++++++++++++++++++ 5 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 tests/components/lupusec/test_init.py diff --git a/homeassistant/components/lupusec/__init__.py b/homeassistant/components/lupusec/__init__.py index cd883a65a245..b3c5749732b0 100644 --- a/homeassistant/components/lupusec/__init__.py +++ b/homeassistant/components/lupusec/__init__.py @@ -9,6 +9,9 @@ from lupupy.exceptions import LupusecException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -45,6 +48,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: LupusecConfigEntry) -> b entry.runtime_data = lupusec_system + alarm = await hass.async_add_executor_job(lupusec_system.get_alarm) + + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.entry_id)}, + name=alarm.name, + manufacturer="Lupus Electronics", + model=f"Lupusec-XT{lupusec_system.model}", + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/lupusec/binary_sensor.py b/homeassistant/components/lupusec/binary_sensor.py index 07accdc195fe..494cc0d85153 100644 --- a/homeassistant/components/lupusec/binary_sensor.py +++ b/homeassistant/components/lupusec/binary_sensor.py @@ -37,7 +37,7 @@ async def async_setup_entry( devices = await hass.async_add_executor_job(partial_func) async_add_entities( - LupusecBinarySensor(device, config_entry.entry_id) for device in devices + LupusecBinarySensor(hass, device, config_entry.entry_id) for device in devices ) diff --git a/homeassistant/components/lupusec/entity.py b/homeassistant/components/lupusec/entity.py index 8cfb559b84fe..2bfb4cf1a037 100644 --- a/homeassistant/components/lupusec/entity.py +++ b/homeassistant/components/lupusec/entity.py @@ -2,6 +2,8 @@ import lupupy +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -26,17 +28,20 @@ class LupusecDevice(Entity): class LupusecBaseSensor(LupusecDevice): """Lupusec Sensor base entity.""" - def __init__(self, device: lupupy.devices.LupusecDevice, entry_id: str) -> None: + def __init__( + self, hass: HomeAssistant, device: lupupy.devices.LupusecDevice, entry_id: str + ) -> None: """Initialize the LupusecBaseSensor.""" super().__init__(device) - self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device.device_id)}, name=device.name, manufacturer="Lupus Electronics", serial_number=device.device_id, model=TYPE_TRANSLATION.get(device.type, device.type), - via_device=(DOMAIN, entry_id), + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, entry_id), config_entry_id=entry_id + ), ) def get_type_name(self) -> str: diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py index 0a36cde83168..623b47b3f2d6 100644 --- a/homeassistant/components/lupusec/switch.py +++ b/homeassistant/components/lupusec/switch.py @@ -31,7 +31,7 @@ async def async_setup_entry( devices = await hass.async_add_executor_job(partial_func) async_add_entities( - LupusecSwitch(device, config_entry.entry_id) for device in devices + LupusecSwitch(hass, device, config_entry.entry_id) for device in devices ) diff --git a/tests/components/lupusec/test_init.py b/tests/components/lupusec/test_init.py new file mode 100644 index 000000000000..ba6abf1df1e8 --- /dev/null +++ b/tests/components/lupusec/test_init.py @@ -0,0 +1,85 @@ +"""Tests for the Lupusec integration setup.""" + +from unittest.mock import MagicMock, patch + +import lupupy.constants as CONST + +from homeassistant.components.lupusec.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + +MOCK_DATA = { + CONF_HOST: "test-host.lan", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", +} + + +def _mock_lupusec_system() -> MagicMock: + """Return a mocked Lupusec system with an alarm and one binary sensor.""" + system = MagicMock() + system.model = 2 + + alarm = MagicMock() + alarm.name = "Alarm Panel" + alarm.device_id = "0" + alarm.is_standby = True + alarm.is_away = False + alarm.is_home = False + alarm.is_alarm_triggered = False + system.get_alarm.return_value = alarm + + sensor = MagicMock() + sensor.device_id = "sensor_1" + sensor.name = "Front Door" + sensor.type = "Türkontakt" + sensor.generic_type = "door" + sensor.is_on = False + + system.get_devices.side_effect = lambda generic_type: ( + [] if generic_type == CONST.TYPE_SWITCH else [sensor] + ) + return system + + +async def test_child_device_links_to_parent( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test the child device is linked to the alarm panel via via_device_id. + + Only the binary_sensor platform is forwarded, so the alarm panel (parent) + device is not created by the sibling alarm_control_panel platform. The child + can therefore only resolve its via_device_id if the parent was registered at + setup, before the platforms were forwarded. + """ + entry = MockConfigEntry(domain=DOMAIN, title=MOCK_DATA[CONF_HOST], data=MOCK_DATA) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.lupusec.lupupy.Lupusec", + return_value=_mock_lupusec_system(), + ), + patch( + "homeassistant.components.lupusec.PLATFORMS", + [Platform.BINARY_SENSOR], + ), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + parent = device_registry.async_get_device_by_identifier( + (DOMAIN, entry.entry_id), entry.entry_id + ) + assert parent is not None + assert parent.name == "Alarm Panel" + assert parent.model == "Lupusec-XT2" + + child = device_registry.async_get_device_by_identifier( + (DOMAIN, "sensor_1"), entry.entry_id + ) + assert child is not None + assert child.via_device_id == parent.id From 3b61a83ed4dee077777d3e1ee7a2514925ce5135 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 15:50:20 +0200 Subject: [PATCH 056/109] Fix via_device race in ntfy (#177738) --- homeassistant/components/ntfy/__init__.py | 6 ++- homeassistant/components/ntfy/coordinator.py | 14 +++++++ homeassistant/components/ntfy/entity.py | 15 ++++---- tests/components/ntfy/test_init.py | 39 +++++++++++++++++++- 4 files changed, 64 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/ntfy/__init__.py b/homeassistant/components/ntfy/__init__.py index 07cfe84fbed8..53ad39f91d9b 100644 --- a/homeassistant/components/ntfy/__init__.py +++ b/homeassistant/components/ntfy/__init__.py @@ -14,7 +14,7 @@ from aiontfy.update import UpdateChecker from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey @@ -92,6 +92,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool entry.runtime_data = NtfyRuntimeData(coordinator, version) + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, **coordinator.device_info + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) diff --git a/homeassistant/components/ntfy/coordinator.py b/homeassistant/components/ntfy/coordinator.py index 3424d9ec938a..63b3c9f1df4a 100644 --- a/homeassistant/components/ntfy/coordinator.py +++ b/homeassistant/components/ntfy/coordinator.py @@ -15,10 +15,13 @@ from aiontfy.exceptions import ( NtfyUnauthorizedAuthenticationError, ) from aiontfy.update import LatestRelease, UpdateChecker, UpdateCheckerError +from yarl import URL from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -56,6 +59,17 @@ class BaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): self.ntfy = ntfy + @property + def device_info(self) -> DeviceInfo: + """Return device info of the ntfy account service device.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer="ntfy LLC", + model="ntfy", + configuration_url=URL(self.config_entry.data[CONF_URL]) / "app", + identifiers={(DOMAIN, self.config_entry.entry_id)}, + ) + @abstractmethod async def async_update_data(self) -> _DataT: """Fetch the latest data from the source.""" diff --git a/homeassistant/components/ntfy/entity.py b/homeassistant/components/ntfy/entity.py index 75e1ec1d943d..73cbaaabe7ba 100644 --- a/homeassistant/components/ntfy/entity.py +++ b/homeassistant/components/ntfy/entity.py @@ -4,6 +4,7 @@ from yarl import URL from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_URL +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -39,7 +40,11 @@ class NtfyBaseEntity(Entity): name=subentry.title, configuration_url=URL(config_entry.data[CONF_URL]) / self.topic, identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")}, - via_device=(DOMAIN, config_entry.entry_id), + via_device_id=dr.async_get_device_id_by_identifier( + config_entry.runtime_data.account.hass, + (DOMAIN, config_entry.entry_id), + config_entry_id=config_entry.entry_id, + ), ) self.ntfy = config_entry.runtime_data.account.ntfy self.config_entry = config_entry @@ -60,10 +65,4 @@ class NtfyCommonBaseEntity(CoordinatorEntity[BaseDataUpdateCoordinator]): super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - manufacturer="ntfy LLC", - model="ntfy", - configuration_url=URL(coordinator.config_entry.data[CONF_URL]) / "app", - identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, - ) + self._attr_device_info = coordinator.device_info diff --git a/tests/components/ntfy/test_init.py b/tests/components/ntfy/test_init.py index b5b73d1272c6..d04708a12a26 100644 --- a/tests/components/ntfy/test_init.py +++ b/tests/components/ntfy/test_init.py @@ -1,6 +1,6 @@ """Tests for the ntfy integration.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from aiontfy.exceptions import ( NtfyConnectionError, @@ -10,8 +10,11 @@ from aiontfy.exceptions import ( ) import pytest +from homeassistant.components.ntfy.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -33,6 +36,40 @@ async def test_entry_setup_unload( assert config_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.usefixtures("mock_aiontfy") +async def test_topic_device_via_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test topic device is linked to the account service device. + + The account service device is registered at setup so the topic device, + which is created by the event/notify platforms, is always linked to it + even when the sensor platform (which also creates the account device) is + not loaded. + """ + + with patch("homeassistant.components.ntfy.PLATFORMS", [Platform.EVENT]): + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + subentry_id = next(iter(config_entry.subentries)) + account_device = device_registry.async_get_device_by_identifier( + (DOMAIN, config_entry.entry_id), config_entry.entry_id + ) + topic_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{config_entry.entry_id}_{subentry_id}"), config_entry.entry_id + ) + + assert account_device is not None + assert topic_device is not None + assert topic_device.via_device_id == account_device.id + + @pytest.mark.parametrize( ("exception", "state"), [ From 8769e581d928b770e349fd0c2433afa382e221b1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 15:50:41 +0200 Subject: [PATCH 057/109] Fix via_device race in lyngdorf (#177732) --- homeassistant/components/lyngdorf/__init__.py | 14 +++++++++++++- tests/components/lyngdorf/test_init.py | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lyngdorf/__init__.py b/homeassistant/components/lyngdorf/__init__.py index 56d8b3db398e..3cb1a64188eb 100644 --- a/homeassistant/components/lyngdorf/__init__.py +++ b/homeassistant/components/lyngdorf/__init__.py @@ -7,9 +7,11 @@ from lyngdorf.device import async_create_receiver, lookup_receiver_model from homeassistant.const import CONF_HOST, CONF_MODEL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, DeviceInfo, + async_get_device_id_by_identifier, format_mac, ) @@ -69,6 +71,12 @@ async def async_setup_entry( model=lyngdorf_model.model_name, ) + # Register the main device up front so Zone B can resolve its via_device_id. + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, **device_info + ) + zone_b_device_info = DeviceInfo( identifiers={(DOMAIN, f"{config_entry.unique_id}_zone_b")}, manufacturer=lyngdorf_model.manufacturer, @@ -76,7 +84,11 @@ async def async_setup_entry( model=lyngdorf_model.model_name, translation_key="zone_b", translation_placeholders={"device_name": config_entry.title}, - via_device=(DOMAIN, config_entry.unique_id), + via_device_id=async_get_device_id_by_identifier( + hass, + (DOMAIN, config_entry.unique_id), + config_entry_id=config_entry.entry_id, + ), ) config_entry.runtime_data = LyngdorfRuntimeData( diff --git a/tests/components/lyngdorf/test_init.py b/tests/components/lyngdorf/test_init.py index c1fe914b75f2..27edd15e4f24 100644 --- a/tests/components/lyngdorf/test_init.py +++ b/tests/components/lyngdorf/test_init.py @@ -69,6 +69,23 @@ async def test_unload_entry( assert init_integration.state is ConfigEntryState.NOT_LOADED +async def test_zone_b_via_device_id( + init_integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that Zone B's via_device_id points at the main device.""" + assert init_integration.unique_id + main_device = device_registry.async_get_device_by_identifier( + (DOMAIN, init_integration.unique_id), init_integration.entry_id + ) + zone_b_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{init_integration.unique_id}_zone_b"), init_integration.entry_id + ) + assert main_device is not None + assert zone_b_device is not None + assert zone_b_device.via_device_id == main_device.id + + @pytest.mark.parametrize( ("serial", "expected_mac_connections"), [ From 35c5ade6e5b7d5fe011f4fd8fc46a589e806bb4d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 15:54:19 +0200 Subject: [PATCH 058/109] Fix via_device race in playstation_network (#177743) --- .../playstation_network/__init__.py | 18 ++++++++++- .../components/playstation_network/entity.py | 9 +++++- .../playstation_network/media_player.py | 6 +++- .../playstation_network/test_media_player.py | 30 ++++++++++++++++++- 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/playstation_network/__init__.py b/homeassistant/components/playstation_network/__init__.py index e0ac391f68b9..6b2d94920d44 100644 --- a/homeassistant/components/playstation_network/__init__.py +++ b/homeassistant/components/playstation_network/__init__.py @@ -1,9 +1,13 @@ """The PlayStation Network integration.""" +from typing import TYPE_CHECKING + from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceEntryType -from .const import CONF_NPSSO +from .const import CONF_NPSSO, DOMAIN from .coordinator import ( PlaystationNetworkConfigEntry, PlaystationNetworkFriendDataCoordinator, @@ -54,6 +58,18 @@ async def async_setup_entry( coordinator, trophy_titles, groups, friends, friends_list ) + # Register the account device up front so entities on concurrently set up + # platforms (media players, friends) can resolve it as their via_device. + if TYPE_CHECKING: + assert entry.unique_id + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.unique_id)}, + name=coordinator.data.username, + entry_type=DeviceEntryType.SERVICE, + manufacturer="Sony Interactive Entertainment", + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) diff --git a/homeassistant/components/playstation_network/entity.py b/homeassistant/components/playstation_network/entity.py index dc1f126505cb..a4a1757d07f7 100644 --- a/homeassistant/components/playstation_network/entity.py +++ b/homeassistant/components/playstation_network/entity.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING from homeassistant.config_entries import ConfigSubentry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -50,5 +51,11 @@ class PlaystationNetworkServiceEntity( ) if subentry: self._attr_device_info.update( - DeviceInfo(via_device=(DOMAIN, coordinator.config_entry.unique_id)) + DeviceInfo( + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.config_entry.unique_id), + config_entry_id=coordinator.config_entry.entry_id, + ) + ) ) diff --git a/homeassistant/components/playstation_network/media_player.py b/homeassistant/components/playstation_network/media_player.py index fa94e76c49ce..9b45ef1d0d2b 100644 --- a/homeassistant/components/playstation_network/media_player.py +++ b/homeassistant/components/playstation_network/media_player.py @@ -109,7 +109,11 @@ class PsnMediaPlayerEntity( name=PLATFORM_MAP[platform], manufacturer="Sony Interactive Entertainment", model=PLATFORM_MAP[platform], - via_device=(DOMAIN, coordinator.config_entry.unique_id), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.config_entry.unique_id), + config_entry_id=coordinator.config_entry.entry_id, + ), ) self.trophy_titles = trophy_titles diff --git a/tests/components/playstation_network/test_media_player.py b/tests/components/playstation_network/test_media_player.py index 53bf6436c735..1f429e031233 100644 --- a/tests/components/playstation_network/test_media_player.py +++ b/tests/components/playstation_network/test_media_player.py @@ -7,10 +7,13 @@ from unittest.mock import MagicMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.playstation_network.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .conftest import PSN_ID from tests.common import MockConfigEntry, snapshot_platform @@ -126,6 +129,31 @@ async def test_platform( await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) +@pytest.mark.usefixtures("mock_psnawpapi", "mock_token") +async def test_media_player_via_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the media player device is linked to the account device via_device.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + account_device = device_registry.async_get_device_by_identifier( + (DOMAIN, PSN_ID), config_entry.entry_id + ) + assert account_device is not None + + media_player_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{PSN_ID}_PS5"), config_entry.entry_id + ) + assert media_player_device is not None + assert media_player_device.via_device_id == account_device.id + + @pytest.mark.parametrize( "presence_payload", [ From 8976c7cca0927aa60dd1f712d040e96b2f020c5c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 15:56:37 +0200 Subject: [PATCH 059/109] Fix via_device race in overkiz (#177740) --- homeassistant/components/overkiz/__init__.py | 16 ++++ homeassistant/components/overkiz/entity.py | 7 +- tests/components/overkiz/helpers.py | 6 ++ tests/components/overkiz/test_coordinator.py | 79 +++++++++++++++++++- 4 files changed, 104 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index 3cb4d01ce1b3..87bd4a333caf 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -193,6 +193,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry) device_registry = dr.async_get(hass) + registered_gateway_ids = {gateway.id for gateway in setup.gateways} + for gateway in setup.gateways: device_registry.async_get_or_create( config_entry_id=entry.entry_id, @@ -208,6 +210,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry) configuration_url=client.server_config.configuration_url, ) + # Some devices reference a gateway that is not part of setup.gateways + # (e.g. a secondary box hosting a device). Register a device for each such + # gateway so the via_device link of its child devices resolves. + for gateway_id in { + device.identifier.gateway_id for device in coordinator.data.values() + } - registered_gateway_ids: + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, gateway_id)}, + manufacturer=client.server_config.manufacturer, + name=gateway_id, + configuration_url=client.server_config.configuration_url, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/overkiz/entity.py b/homeassistant/components/overkiz/entity.py index 97560335b283..61d1d84a3b4b 100644 --- a/homeassistant/components/overkiz/entity.py +++ b/homeassistant/components/overkiz/entity.py @@ -5,6 +5,7 @@ from typing import cast, override from pyoverkiz.enums import APIType, OverkizAttribute, OverkizCommandParam, OverkizState from pyoverkiz.models import Device +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -111,7 +112,11 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]): model_id=self.device.widget, hw_version=self.device.controllable_name, suggested_area=suggested_area, - via_device=(DOMAIN, self.device.identifier.gateway_id), + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, self.device.identifier.gateway_id), + config_entry_id=self.coordinator.config_entry.entry_id, + ), configuration_url=self.coordinator.client.server_config.configuration_url, ) diff --git a/tests/components/overkiz/helpers.py b/tests/components/overkiz/helpers.py index 02c9f2844bf3..87214a79dd7a 100644 --- a/tests/components/overkiz/helpers.py +++ b/tests/components/overkiz/helpers.py @@ -7,6 +7,7 @@ from freezegun.api import FrozenDateTimeFactory from pyoverkiz.enums import DataType, EventName, ExecutionState from pyoverkiz.models import ( DeviceAvailableEvent, + DeviceCreatedEvent, DeviceRemovedEvent, DeviceStateChangedEvent, DeviceUnavailableEvent, @@ -97,6 +98,11 @@ def device_removed_event(device_url: str) -> DeviceRemovedEvent: return DeviceRemovedEvent(name=EventName.DEVICE_REMOVED, device_url=device_url) +def device_created_event(device_url: str) -> DeviceCreatedEvent: + """Build a DEVICE_CREATED event for the given device.""" + return DeviceCreatedEvent(name=EventName.DEVICE_CREATED, device_url=device_url) + + def execution_state_changed_event( exec_id: str, new_state: ExecutionState, old_state: ExecutionState ) -> ExecutionStateChangedEvent: diff --git a/tests/components/overkiz/test_coordinator.py b/tests/components/overkiz/test_coordinator.py index 273a0ca5f531..7ec38ac0ecc9 100644 --- a/tests/components/overkiz/test_coordinator.py +++ b/tests/components/overkiz/test_coordinator.py @@ -1,6 +1,6 @@ """Tests for the Overkiz data update coordinator.""" -from unittest.mock import Mock +from unittest.mock import Mock, patch from aiohttp import ClientConnectorError from freezegun.api import FrozenDateTimeFactory @@ -13,13 +13,14 @@ from pyoverkiz.exceptions import ( ) import pytest -from homeassistant.components.overkiz.const import UPDATE_INTERVAL +from homeassistant.components.overkiz.const import DOMAIN, UPDATE_INTERVAL +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import FixtureDevice, MockOverkizClient, SetupOverkizIntegration -from .helpers import async_deliver_events, device_removed_event +from .helpers import async_deliver_events, device_created_event, device_removed_event from tests.common import MockConfigEntry, async_fire_time_changed @@ -29,6 +30,14 @@ TEMPERATURE_SENSOR = FixtureDevice( "sensor.maple_residence_garden_radiator_bathroom_temperature_sensor_temperature", ) +# A TaHoma v2 setup whose gateways list only holds the main box, while a +# swinging gate device is hosted by a secondary box absent from setup.gateways. +TAHOMA_V2_FIXTURE = "setup/cloud_somfy_tahoma_v2_europe.json" +MAIN_GATEWAY_ID = "1234-1234-6233" +SECONDARY_GATEWAY_ID = "1234-1234-8983" +MAIN_GATEWAY_CHILD_URL = "io://1234-1234-6233/12184029" +SECONDARY_GATEWAY_CHILD_URL = "io://1234-1234-8983/1959462" + @pytest.mark.parametrize( "exception", @@ -134,3 +143,67 @@ async def test_device_removed_keeps_device_owned_by_other_entry( device = device_registry.async_get(device_id) assert device is not None assert device.config_entry_id == other_entry.entry_id + + +async def test_child_devices_link_to_their_gateway( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MockOverkizClient, + freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, +) -> None: + """Every child device links to its gateway, even one absent from setup.gateways. + + The secondary box that hosts the swinging gate is not returned in + setup.gateways, but the via_device link of its child must still resolve. + A DEVICE_CREATED event reloads the entry, which must keep the link intact. + """ + mock_config_entry.add_to_hass(hass) + mock_client.set_setup_fixture(TAHOMA_V2_FIXTURE) + + with patch( + "homeassistant.components.overkiz.create_cloud_client", + return_value=mock_client, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + main_gateway = device_registry.async_get_device_by_identifier( + (DOMAIN, MAIN_GATEWAY_ID), mock_config_entry.entry_id + ) + secondary_gateway = device_registry.async_get_device_by_identifier( + (DOMAIN, SECONDARY_GATEWAY_ID), mock_config_entry.entry_id + ) + assert main_gateway is not None + assert secondary_gateway is not None + + main_child = device_registry.async_get_device_by_identifier( + (DOMAIN, MAIN_GATEWAY_CHILD_URL), mock_config_entry.entry_id + ) + secondary_child = device_registry.async_get_device_by_identifier( + (DOMAIN, SECONDARY_GATEWAY_CHILD_URL), mock_config_entry.entry_id + ) + assert main_child is not None + assert secondary_child is not None + assert main_child.via_device_id == main_gateway.id + assert secondary_child.via_device_id == secondary_gateway.id + + # A DEVICE_CREATED event reloads the entry; the links must survive. + await async_deliver_events( + hass, + freezer, + mock_client, + [device_created_event(SECONDARY_GATEWAY_CHILD_URL)], + ) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + secondary_gateway = device_registry.async_get_device_by_identifier( + (DOMAIN, SECONDARY_GATEWAY_ID), mock_config_entry.entry_id + ) + secondary_child = device_registry.async_get_device_by_identifier( + (DOMAIN, SECONDARY_GATEWAY_CHILD_URL), mock_config_entry.entry_id + ) + assert secondary_gateway is not None + assert secondary_child is not None + assert secondary_child.via_device_id == secondary_gateway.id From 132e0bf0118531397f619eec9abd30d0a3b767f8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 15:56:53 +0200 Subject: [PATCH 060/109] Fix via_device race in point (#177744) --- homeassistant/components/point/coordinator.py | 10 + homeassistant/components/point/entity.py | 5 +- tests/components/point/test_init.py | 182 +++++++++++++++++- 3 files changed, 195 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/point/coordinator.py b/homeassistant/components/point/coordinator.py index 4f3757a16545..86123138de15 100644 --- a/homeassistant/components/point/coordinator.py +++ b/homeassistant/components/point/coordinator.py @@ -9,6 +9,7 @@ from pypoint import PointSession from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.dt import parse_datetime @@ -50,7 +51,16 @@ class PointDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]] if new_homes := set(self.point.homes) - self._known_homes: _LOGGER.debug("Found new homes: %s", new_homes) + device_registry = dr.async_get(self.hass) for home_id in new_homes: + # Register the home before its child devices so they can resolve + # it as their via_device parent when their entities are created. + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, home_id)}, + manufacturer="Minut", + name=self.point.homes[home_id]["name"], + ) if self.new_home_callback: self.new_home_callback(home_id) self._known_homes.update(new_homes) diff --git a/homeassistant/components/point/entity.py b/homeassistant/components/point/entity.py index 9d4e5c1a6a84..cac2bc4fbea0 100644 --- a/homeassistant/components/point/entity.py +++ b/homeassistant/components/point/entity.py @@ -33,8 +33,11 @@ class MinutPointEntity(CoordinatorEntity[PointDataUpdateCoordinator]): name=device["description"], hw_version=device["hardware_version"], sw_version=device["firmware"]["installed"], - via_device=(DOMAIN, device["home"]), ) + if parent := dr.async_get(coordinator.hass).async_get_device_by_identifier( + (DOMAIN, device["home"]), coordinator.config_entry.entry_id + ): + self._attr_device_info["via_device_id"] = parent.id if self.device_class: self._attr_name = f"{self._name} {self.device_class.capitalize()}" diff --git a/tests/components/point/test_init.py b/tests/components/point/test_init.py index 0b241516b975..0f584977fca2 100644 --- a/tests/components/point/test_init.py +++ b/tests/components/point/test_init.py @@ -1,16 +1,147 @@ """Tests for the Point component.""" -from unittest.mock import patch +import time +from typing import Any +from unittest.mock import AsyncMock, patch +import pytest + +from homeassistant.components.application_credentials import ( + DOMAIN as APPLICATION_CREDENTIALS_DOMAIN, + ClientCredential, + async_import_client_credential, +) from homeassistant.components.point import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, ) +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +HOME_ID = "home_1" +DEVICE_ID = "device_1" + +DEVICE_RAW: dict[str, Any] = { + "device_id": DEVICE_ID, + "device_mac": "00:11:22:33:44:55", + "description": "Kitchen", + "hardware_version": "1", + "firmware": {"installed": "2.0.0"}, + "home": HOME_ID, + "last_heard_from_at": "2023-01-01T00:00:00+00:00", + "active": True, + "offline": False, + "battery": {"percent": 100}, + "ongoing_events": [], +} + +HOME_RAW: dict[str, Any] = { + "home_id": HOME_ID, + "name": "My Home", + "devices": [DEVICE_ID], + "alarm_status": "off", +} + + +class FakeDevice: + """Minimal fake of pypoint.Device.""" + + def __init__(self, raw: dict[str, Any]) -> None: + """Initialize.""" + self._raw = raw + + @property + def device_id(self) -> str: + """Return the device id.""" + return self._raw["device_id"] + + @property + def device(self) -> dict[str, Any]: + """Return the raw representation.""" + return self._raw + + @property + def name(self) -> str: + """Return the device name.""" + return self._raw["description"] + + @property + def last_update(self) -> str: + """Return the last update timestamp.""" + return self._raw["last_heard_from_at"] + + @property + def ongoing_events(self) -> list[str]: + """Return ongoing events.""" + return self._raw["ongoing_events"] + + @property + def webhook(self) -> str: + """Return the webhook.""" + return "webhook" + + @property + def device_status(self) -> dict[str, Any]: + """Return the device status.""" + return { + "active": self._raw["active"], + "offline": self._raw["offline"], + "last_update": self.last_update, + "battery_level": self._raw["battery"]["percent"], + } + + async def sensor(self, sensor_type: str) -> float: + """Return a sensor value.""" + return 1.0 + + +class FakePointSession: + """Minimal fake of pypoint.PointSession.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize.""" + self._devices = {DEVICE_ID: DEVICE_RAW} + self._homes = {HOME_ID: HOME_RAW} + self.webhook = "webhook" + + async def update(self) -> bool: + """Update the session.""" + return True + + @property + def homes(self) -> dict[str, dict[str, Any]]: + """Return known homes.""" + return self._homes + + @property + def device_ids(self) -> Any: + """Return known device ids.""" + return self._devices.keys() + + @property + def devices(self) -> Any: + """Return device representations.""" + return (FakeDevice(raw) for raw in self._devices.values()) + + def device(self, device_id: str) -> FakeDevice: + """Return a single device.""" + return FakeDevice(self._devices[device_id]) + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to set up application credentials.""" + assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential("1234", "5678"), + ) + async def test_oauth_implementation_not_available( hass: HomeAssistant, @@ -38,3 +169,52 @@ async def test_oauth_implementation_not_available( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("setup_credentials") +async def test_device_via_device_link( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that a Point device is linked to its home via_device.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="abcd", + data={ + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "expires_at": time.time() + 3600, + }, + }, + ) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.point.PointSession", + FakePointSession, + ), + patch( + "homeassistant.components.point.async_setup_webhook", + new=AsyncMock(), + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + home_device = device_registry.async_get_device_by_identifier( + (DOMAIN, HOME_ID), config_entry.entry_id + ) + assert home_device is not None + + point_device = device_registry.async_get_device_by_identifier( + (DOMAIN, DEVICE_ID), config_entry.entry_id + ) + assert point_device is not None + assert point_device.via_device_id == home_device.id From 62fa51cc52e48da727941866c7af06bfcbb499ba Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 15:57:08 +0200 Subject: [PATCH 061/109] Adapt bosch_alarm to set via_device_id in DeviceInfo (#177709) Co-authored-by: Markus Tuominen <3738613+Markus98@users.noreply.github.com> --- .../bosch_alarm/alarm_control_panel.py | 15 +++++- .../components/bosch_alarm/binary_sensor.py | 32 +++++++++---- .../components/bosch_alarm/entity.py | 47 ++++++++++++++++--- .../components/bosch_alarm/sensor.py | 8 +++- .../components/bosch_alarm/switch.py | 24 +++++++--- .../bosch_alarm/test_binary_sensor.py | 30 +++++++++++- 6 files changed, 130 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/bosch_alarm/alarm_control_panel.py b/homeassistant/components/bosch_alarm/alarm_control_panel.py index 83c979fe5ec2..519986e473e5 100644 --- a/homeassistant/components/bosch_alarm/alarm_control_panel.py +++ b/homeassistant/components/bosch_alarm/alarm_control_panel.py @@ -26,9 +26,11 @@ async def async_setup_entry( async_add_entities( AreaAlarmControlPanel( + hass, panel, area_id, config_entry.unique_id or config_entry.entry_id, + config_entry.entry_id, ) for area_id in panel.areas ) @@ -48,9 +50,18 @@ class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity): _attr_code_arm_required = False _attr_name = None - def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None: + def __init__( + self, + hass: HomeAssistant, + panel: Panel, + area_id: int, + unique_id: str, + config_entry_id: str, + ) -> None: """Initialise a Bosch Alarm control panel entity.""" - super().__init__(panel, area_id, unique_id, True, False, True) + super().__init__( + hass, panel, area_id, unique_id, config_entry_id, True, False, True + ) self._attr_unique_id = self._area_unique_id @property diff --git a/homeassistant/components/bosch_alarm/binary_sensor.py b/homeassistant/components/bosch_alarm/binary_sensor.py index 02992f149157..1c87511676ed 100644 --- a/homeassistant/components/bosch_alarm/binary_sensor.py +++ b/homeassistant/components/bosch_alarm/binary_sensor.py @@ -116,16 +116,17 @@ async def async_setup_entry( ) -> None: """Set up binary sensors for alarm points and the connection status.""" panel = config_entry.runtime_data + unique_id = config_entry.unique_id or config_entry.entry_id entities: list[BinarySensorEntity] = [ - PointSensor(panel, point_id, config_entry.unique_id or config_entry.entry_id) + PointSensor(hass, panel, point_id, unique_id, config_entry.entry_id) for point_id in panel.points ] entities.extend( PanelFaultsSensor( panel, - config_entry.unique_id or config_entry.entry_id, + unique_id, fault_type, ) for fault_type in FAULT_TYPES @@ -133,14 +134,14 @@ async def async_setup_entry( entities.extend( AreaReadyToArmSensor( - panel, area_id, config_entry.unique_id or config_entry.entry_id, "away" + hass, panel, area_id, unique_id, config_entry.entry_id, "away" ) for area_id in panel.areas ) entities.extend( AreaReadyToArmSensor( - panel, area_id, config_entry.unique_id or config_entry.entry_id, "home" + hass, panel, area_id, unique_id, config_entry.entry_id, "home" ) for area_id in panel.areas ) @@ -182,10 +183,18 @@ class AreaReadyToArmSensor(BoschAlarmAreaEntity, BinarySensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( - self, panel: Panel, area_id: int, unique_id: str, arm_type: str + self, + hass: HomeAssistant, + panel: Panel, + area_id: int, + unique_id: str, + config_entry_id: str, + arm_type: str, ) -> None: """Set up a binary sensor for arming status in a bosch panel.""" - super().__init__(panel, area_id, unique_id, False, False, True) + super().__init__( + hass, panel, area_id, unique_id, config_entry_id, False, False, True + ) self.panel = panel self._arm_type = arm_type self._attr_translation_key = f"area_ready_to_arm_{arm_type}" @@ -207,9 +216,16 @@ class PointSensor(BoschAlarmPointEntity, BinarySensorEntity): _attr_name = None - def __init__(self, panel: Panel, point_id: int, unique_id: str) -> None: + def __init__( + self, + hass: HomeAssistant, + panel: Panel, + point_id: int, + unique_id: str, + config_entry_id: str, + ) -> None: """Set up a binary sensor entity for a point in a bosch alarm panel.""" - super().__init__(panel, point_id, unique_id) + super().__init__(hass, panel, point_id, unique_id, config_entry_id) self._attr_unique_id = self._point_unique_id @property diff --git a/homeassistant/components/bosch_alarm/entity.py b/homeassistant/components/bosch_alarm/entity.py index 33a6516792a6..c52e1e990b9d 100644 --- a/homeassistant/components/bosch_alarm/entity.py +++ b/homeassistant/components/bosch_alarm/entity.py @@ -5,6 +5,8 @@ from typing import override from bosch_alarm_mode2 import Panel from homeassistant.components.sensor import Entity +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from .const import DOMAIN @@ -56,9 +58,11 @@ class BoschAlarmAreaEntity(BoschAlarmEntity): def __init__( self, + hass: HomeAssistant, panel: Panel, area_id: int, unique_id: str, + config_entry_id: str, observe_alarms: bool, observe_ready: bool, observe_status: bool, @@ -75,7 +79,9 @@ class BoschAlarmAreaEntity(BoschAlarmEntity): identifiers={(DOMAIN, self._area_unique_id)}, name=self._area.name, manufacturer="Bosch Security Systems", - via_device=(DOMAIN, unique_id), + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, unique_id), config_entry_id=config_entry_id + ), ) @override @@ -104,7 +110,14 @@ class BoschAlarmAreaEntity(BoschAlarmEntity): class BoschAlarmPointEntity(BoschAlarmEntity): """A base entity for point related entities within a bosch alarm panel.""" - def __init__(self, panel: Panel, point_id: int, unique_id: str) -> None: + def __init__( + self, + hass: HomeAssistant, + panel: Panel, + point_id: int, + unique_id: str, + config_entry_id: str, + ) -> None: """Set up a area related entity for a bosch alarm panel.""" super().__init__(panel, unique_id) self._point_id = point_id @@ -114,7 +127,9 @@ class BoschAlarmPointEntity(BoschAlarmEntity): identifiers={(DOMAIN, self._point_unique_id)}, name=self._point.name, manufacturer="Bosch Security Systems", - via_device=(DOMAIN, unique_id), + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, unique_id), config_entry_id=config_entry_id + ), ) @override @@ -133,7 +148,14 @@ class BoschAlarmPointEntity(BoschAlarmEntity): class BoschAlarmDoorEntity(BoschAlarmEntity): """A base entity for area related entities within a bosch alarm panel.""" - def __init__(self, panel: Panel, door_id: int, unique_id: str) -> None: + def __init__( + self, + hass: HomeAssistant, + panel: Panel, + door_id: int, + unique_id: str, + config_entry_id: str, + ) -> None: """Set up a area related entity for a bosch alarm panel.""" super().__init__(panel, unique_id) self._door_id = door_id @@ -143,7 +165,9 @@ class BoschAlarmDoorEntity(BoschAlarmEntity): identifiers={(DOMAIN, self._door_unique_id)}, name=self._door.name, manufacturer="Bosch Security Systems", - via_device=(DOMAIN, unique_id), + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, unique_id), config_entry_id=config_entry_id + ), ) @override @@ -162,7 +186,14 @@ class BoschAlarmDoorEntity(BoschAlarmEntity): class BoschAlarmOutputEntity(BoschAlarmEntity): """A base entity for area related entities within a bosch alarm panel.""" - def __init__(self, panel: Panel, output_id: int, unique_id: str) -> None: + def __init__( + self, + hass: HomeAssistant, + panel: Panel, + output_id: int, + unique_id: str, + config_entry_id: str, + ) -> None: """Set up a output related entity for a bosch alarm panel.""" super().__init__(panel, unique_id) self._output_id = output_id @@ -172,7 +203,9 @@ class BoschAlarmOutputEntity(BoschAlarmEntity): identifiers={(DOMAIN, self._output_unique_id)}, name=self._output.name, manufacturer="Bosch Security Systems", - via_device=(DOMAIN, unique_id), + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, unique_id), config_entry_id=config_entry_id + ), ) @override diff --git a/homeassistant/components/bosch_alarm/sensor.py b/homeassistant/components/bosch_alarm/sensor.py index deb53a07079e..d292f97bde95 100644 --- a/homeassistant/components/bosch_alarm/sensor.py +++ b/homeassistant/components/bosch_alarm/sensor.py @@ -82,7 +82,9 @@ async def async_setup_entry( unique_id = config_entry.unique_id or config_entry.entry_id async_add_entities( - BoschAreaSensor(panel, area_id, unique_id, template) + BoschAreaSensor( + hass, panel, area_id, unique_id, config_entry.entry_id, template + ) for area_id in panel.areas for template in SENSOR_TYPES ) @@ -98,16 +100,20 @@ class BoschAreaSensor(BoschAlarmAreaEntity, SensorEntity): def __init__( self, + hass: HomeAssistant, panel: Panel, area_id: int, unique_id: str, + config_entry_id: str, entity_description: BoschAlarmSensorEntityDescription, ) -> None: """Set up an area sensor entity for a bosch alarm panel.""" super().__init__( + hass, panel, area_id, unique_id, + config_entry_id, entity_description.observe_alarms, entity_description.observe_ready, entity_description.observe_status, diff --git a/homeassistant/components/bosch_alarm/switch.py b/homeassistant/components/bosch_alarm/switch.py index d7bce72ef0da..238e077d468f 100644 --- a/homeassistant/components/bosch_alarm/switch.py +++ b/homeassistant/components/bosch_alarm/switch.py @@ -59,18 +59,19 @@ async def async_setup_entry( """Set up switch entities for outputs.""" panel = config_entry.runtime_data + unique_id = config_entry.unique_id or config_entry.entry_id entities: list[SwitchEntity] = [ - PanelOutputEntity( - panel, output_id, config_entry.unique_id or config_entry.entry_id - ) + PanelOutputEntity(hass, panel, output_id, unique_id, config_entry.entry_id) for output_id in panel.outputs ] entities.extend( PanelDoorEntity( + hass, panel, door_id, - config_entry.unique_id or config_entry.entry_id, + unique_id, + config_entry.entry_id, entity_description, ) for door_id in panel.doors @@ -90,13 +91,15 @@ class PanelDoorEntity(BoschAlarmDoorEntity, SwitchEntity): def __init__( self, + hass: HomeAssistant, panel: Panel, door_id: int, unique_id: str, + config_entry_id: str, entity_description: BoschAlarmSwitchEntityDescription, ) -> None: """Set up a switch entity for a door on a bosch alarm panel.""" - super().__init__(panel, door_id, unique_id) + super().__init__(hass, panel, door_id, unique_id, config_entry_id) self.entity_description = entity_description self._attr_unique_id = f"{self._door_unique_id}_{entity_description.key}" @@ -134,9 +137,16 @@ class PanelOutputEntity(BoschAlarmOutputEntity, SwitchEntity): _attr_name = None - def __init__(self, panel: Panel, output_id: int, unique_id: str) -> None: + def __init__( + self, + hass: HomeAssistant, + panel: Panel, + output_id: int, + unique_id: str, + config_entry_id: str, + ) -> None: """Set up an output entity for a bosch alarm panel.""" - super().__init__(panel, output_id, unique_id) + super().__init__(hass, panel, output_id, unique_id, config_entry_id) self._attr_unique_id = self._output_unique_id @property diff --git a/tests/components/bosch_alarm/test_binary_sensor.py b/tests/components/bosch_alarm/test_binary_sensor.py index e788d7c5eda5..d0e3970d1c1f 100644 --- a/tests/components/bosch_alarm/test_binary_sensor.py +++ b/tests/components/bosch_alarm/test_binary_sensor.py @@ -7,9 +7,10 @@ from bosch_alarm_mode2.const import ALARM_PANEL_FAULTS import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bosch_alarm.const import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import call_observable, setup_integration @@ -76,3 +77,30 @@ async def test_area_ready_to_arm( await call_observable(hass, area.status_observer) assert hass.states.get(entity_id).state == STATE_OFF assert hass.states.get(entity_id_2).state == STATE_OFF + + +@pytest.mark.parametrize("model", ["b5512"]) +@pytest.mark.usefixtures("mock_panel") +async def test_point_via_device_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a point's device links to the panel device via via_device_id.""" + await setup_integration(hass, mock_config_entry) + + entity_entry = entity_registry.async_get("binary_sensor.window") + assert entity_entry + assert entity_entry.device_id + + point_device = device_registry.async_get(entity_entry.device_id) + assert point_device + + panel_device = device_registry.async_get_device_by_identifier( + (DOMAIN, mock_config_entry.unique_id), + mock_config_entry.entry_id, + ) + assert panel_device + + assert point_device.via_device_id == panel_device.id From fc91860c17ca0bd8ca1497efc763bce44d1ad80c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 15:57:22 +0200 Subject: [PATCH 062/109] Fix via_device race in powerwall (#177747) --- .../components/powerwall/__init__.py | 20 +++++++++++++++++++ homeassistant/components/powerwall/entity.py | 8 ++++++-- tests/components/powerwall/test_sensor.py | 6 ++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index 71b53573e7c3..cfd666d13ffd 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -28,6 +28,8 @@ from .const import ( AUTH_COOKIE_KEY, CONFIG_ENTRY_COOKIE, DOMAIN, + MANUFACTURER, + MODEL, POWERWALL_API_CHANGED, POWERWALL_COORDINATOR, ) @@ -238,6 +240,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerwallConfigEntry) -> await async_migrate_entity_unique_ids(hass, entry, base_info) + # Register the gateway device so battery devices can link to it via + # via_device_id, which must resolve to an already-registered device. + model = ( + f"{MODEL} ({base_info.device_type.name})" + if base_info.device_type is not None + else MODEL + ) + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, gateway_din)}, + manufacturer=MANUFACTURER, + model=model, + name=base_info.site_name, + sw_version=base_info.status.version if base_info.status else None, + configuration_url=base_info.url, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/powerwall/entity.py b/homeassistant/components/powerwall/entity.py index 71588145cf04..f1b1cf32ef18 100644 --- a/homeassistant/components/powerwall/entity.py +++ b/homeassistant/components/powerwall/entity.py @@ -2,6 +2,7 @@ from tesla_powerwall import BatteryResponse +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -65,7 +66,6 @@ class BatteryEntity(CoordinatorEntity[PowerwallUpdateCoordinator]): self.serial_number = battery.serial_number self.power_wall = powerwall_data[POWERWALL_API] self.base_unique_id = f"{base_info.gateway_din}_{battery.serial_number}" - self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.base_unique_id)}, manufacturer=MANUFACTURER, @@ -73,7 +73,11 @@ class BatteryEntity(CoordinatorEntity[PowerwallUpdateCoordinator]): name=f"{base_info.site_name} {battery.serial_number}", sw_version=base_info.status.version if base_info.status else None, configuration_url=base_info.url, - via_device=(DOMAIN, base_info.gateway_din), + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, base_info.gateway_din), + config_entry_id=self.coordinator.config_entry.entry_id, + ), ) @property diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index 65f04a975b41..8d0ea1bc41f2 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -59,6 +59,12 @@ async def test_sensors(hass: HomeAssistant, device_registry: dr.DeviceRegistry) assert reg_device.manufacturer == "Tesla" assert reg_device.name == "MySite" + battery_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{MOCK_GATEWAY_DIN}_TG0123456789AB"), config_entry.entry_id + ) + assert battery_device is not None + assert battery_device.via_device_id == reg_device.id + state = hass.states.get("sensor.mysite_load_power") assert state.state == "1.971" attributes = state.attributes From 5f1550f561e5f82f9e56b3174f3d1c1af2da7eae Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 15:58:58 +0200 Subject: [PATCH 063/109] Fix via_device race in portainer (#177745) Co-authored-by: Erwin Douna --- .../components/portainer/__init__.py | 14 +++ .../components/portainer/coordinator.py | 87 +++++++++++++++--- homeassistant/components/portainer/entity.py | 29 +++--- tests/components/portainer/test_init.py | 90 +++++++++++++++++++ 4 files changed, 198 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py index 6465934e3ba8..e5dc60f45295 100644 --- a/homeassistant/components/portainer/__init__.py +++ b/homeassistant/components/portainer/__init__.py @@ -90,6 +90,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> entry.async_on_unload(async_at_started(hass, _defer_docker_disk_space_refresh)) entry.runtime_data = coordinator + + # Register the endpoint and stack devices up front so that container, stack and + # volume entities can deterministically resolve them as their via device when + # their platforms are set up below, regardless of platform/entity add order. + coordinator.async_register_endpoint_and_stack_devices( + coordinator.data, + set(coordinator.data), + { + (endpoint_id, stack_name, stack_data.stack.id) + for endpoint_id, endpoint_data in coordinator.data.items() + for stack_name, stack_data in endpoint_data.stacks.items() + }, + ) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) @callback diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index a199fd079c68..66e03cbc4679 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -30,14 +30,17 @@ from pyportainer.models.docker_inspect import DockerInfo, DockerInspect, DockerV from pyportainer.models.portainer import Endpoint from pyportainer.models.stacks import Stack from pyportainer.watcher import PortainerImageWatcher +from yarl import URL from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN +from .const import DEFAULT_NAME, DOMAIN from .util import sanitize_container_name type PortainerConfigEntry = ConfigEntry[PortainerCoordinator] @@ -114,7 +117,7 @@ class PortainerBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): self.known_endpoints: set[int] = set() self.known_containers: set[tuple[int, str]] = set() - self.known_stacks: set[tuple[int, str]] = set() + self.known_stacks: set[tuple[int, str, int]] = set() self.known_volumes: set[tuple[int, str]] = set() self.new_endpoints_callbacks: list[ @@ -411,6 +414,57 @@ class PortainerCoordinator( return mapped_endpoints + def async_register_endpoint_and_stack_devices( + self, + mapped_endpoints: dict[int, PortainerCoordinatorData], + endpoint_ids: set[int], + stack_keys: set[tuple[int, str, int]], + ) -> None: + """Register endpoint and stack devices. + + Must run for an endpoint/stack before any entity that resolves it + as its via device is constructed, both during initial setup and + when a later refresh discovers new endpoints or stacks. + """ + device_registry = dr.async_get(self.hass) + + for endpoint_id in endpoint_ids: + endpoint = mapped_endpoints[endpoint_id].endpoint + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, f"{self.config_entry.entry_id}_{endpoint_id}")}, + configuration_url=URL( + f"{self.config_entry.data[CONF_URL]}#!/{endpoint_id}/docker/dashboard" + ), + manufacturer=DEFAULT_NAME, + model="Endpoint", + name=endpoint.name, + entry_type=DeviceEntryType.SERVICE, + ) + + for endpoint_id, stack_name, _stack_id in stack_keys: + stack = mapped_endpoints[endpoint_id].stacks[stack_name].stack + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={ + ( + DOMAIN, + f"{self.config_entry.entry_id}_{endpoint_id}_stack_{stack.id}", + ) + }, + configuration_url=URL( + f"{self.config_entry.data[CONF_URL]}#!/{endpoint_id}/docker/stacks/{stack.name}" + ), + manufacturer=DEFAULT_NAME, + model="Stack", + name=stack.name, + via_device_id=dr.async_get_device_id_by_identifier( + self.hass, + (DOMAIN, f"{self.config_entry.entry_id}_{endpoint_id}"), + config_entry_id=self.config_entry.entry_id, + ), + ) + def _async_add_remove_endpoints( self, mapped_endpoints: dict[int, PortainerCoordinatorData] ) -> None: @@ -418,6 +472,25 @@ class PortainerCoordinator( current_endpoints = {endpoint.id for endpoint in mapped_endpoints.values()} self.known_endpoints &= current_endpoints new_endpoints = current_endpoints - self.known_endpoints + + # The stack ID is part of the key because it is part of the stack device + # identifier; a stack recreated with the same name but a new ID must be + # detected as new so its device is (re)registered before entities resolve it. + current_stacks = { + (endpoint.id, stack_name, stack_data.stack.id) + for endpoint in mapped_endpoints.values() + for stack_name, stack_data in endpoint.stacks.items() + } + self.known_stacks &= current_stacks + new_stacks = current_stacks - self.known_stacks + + if new_endpoints or new_stacks: + # Register devices before any callback below constructs an + # entity that resolves one of them as its via device. + self.async_register_endpoint_and_stack_devices( + mapped_endpoints, new_endpoints, new_stacks + ) + if new_endpoints: _LOGGER.debug("New endpoints found: %s", new_endpoints) self.known_endpoints.update(new_endpoints) @@ -473,14 +546,6 @@ class PortainerCoordinator( volume_callback(new_volume_data) # Stack management - current_stacks = { - (endpoint.id, stack_name) - for endpoint in mapped_endpoints.values() - for stack_name in endpoint.stacks - } - - self.known_stacks &= current_stacks - new_stacks = current_stacks - self.known_stacks if new_stacks: _LOGGER.debug("New stacks found: %s", new_stacks) self.known_stacks.update(new_stacks) @@ -489,7 +554,7 @@ class PortainerCoordinator( mapped_endpoints[endpoint_id], mapped_endpoints[endpoint_id].stacks[name], ) - for endpoint_id, name in new_stacks + for endpoint_id, name, _stack_id in new_stacks ] for stack_callback in self.new_stacks_callbacks: stack_callback(new_stack_data) diff --git a/homeassistant/components/portainer/entity.py b/homeassistant/components/portainer/entity.py index 2c9358a3db42..6ff05f1a82f0 100644 --- a/homeassistant/components/portainer/entity.py +++ b/homeassistant/components/portainer/entity.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, override from yarl import URL from homeassistant.const import CONF_URL +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -114,11 +115,15 @@ class PortainerContainerEntity(PortainerCoordinatorEntity): name=self.device_name, # If the container belongs to a stack, nest it under the stack # else it's the endpoint - via_device=( - DOMAIN, - f"{coordinator.config_entry.entry_id}_{self.endpoint_id}_stack_{device_info.stack.id}" - if device_info.stack - else f"{coordinator.config_entry.entry_id}_{self.endpoint_id}", + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + ( + DOMAIN, + f"{coordinator.config_entry.entry_id}_{self.endpoint_id}_stack_{device_info.stack.id}" + if device_info.stack + else f"{coordinator.config_entry.entry_id}_{self.endpoint_id}", + ), + config_entry_id=coordinator.config_entry.entry_id, ), translation_key=None if self.device_name else "unknown_container", entry_type=DeviceEntryType.SERVICE, @@ -176,9 +181,10 @@ class PortainerStackEntity(PortainerCoordinatorEntity): ), model="Stack", name=self.device_name, - via_device=( - DOMAIN, - f"{coordinator.config_entry.entry_id}_{self.endpoint_id}", + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, f"{coordinator.config_entry.entry_id}_{self.endpoint_id}"), + config_entry_id=coordinator.config_entry.entry_id, ), ) self._attr_unique_id = ( @@ -280,9 +286,10 @@ class PortainerVolumeEntity(PortainerCoordinatorEntity): ), model="Volume", name=self.volume_name, - via_device=( - DOMAIN, - f"{coordinator.config_entry.entry_id}_{self.endpoint_id}", + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, f"{coordinator.config_entry.entry_id}_{self.endpoint_id}"), + config_entry_id=coordinator.config_entry.entry_id, ), ) self._attr_unique_id = ( diff --git a/tests/components/portainer/test_init.py b/tests/components/portainer/test_init.py index dab13e650cf2..cc9a0d768adc 100644 --- a/tests/components/portainer/test_init.py +++ b/tests/components/portainer/test_init.py @@ -364,6 +364,7 @@ async def test_new_endpoint_callback( mock_portainer_client: AsyncMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test new endpoint creates entities after refresh.""" mock_portainer_client.get_endpoints.return_value = [] @@ -391,6 +392,21 @@ async def test_new_endpoint_callback( ) assert len(entities) > 0 + # The endpoint and its stacks are discovered together on this refresh, so + # the stack device must resolve the freshly-created endpoint device as its + # via device instead of racing its creation. + endpoint_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{mock_config_entry.entry_id}_1"), mock_config_entry.entry_id + ) + assert endpoint_device is not None + + stack_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{mock_config_entry.entry_id}_1_stack_2"), + mock_config_entry.entry_id, + ) + assert stack_device is not None + assert stack_device.via_device_id == endpoint_device.id + async def test_new_container_callback( hass: HomeAssistant, @@ -467,3 +483,77 @@ async def test_new_stack_callback( assert len( er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) ) > len(entities) + + +async def test_stack_recreated_with_new_id( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a stack recreated with the same name but a new ID re-registers its device. + + The stack device identifier and a container's via device are keyed by the stack + ID, so a stack recreated with a new ID must be detected as new and its device + registered before a container in it resolves the stack as its via device. Tracking + only the name would skip registration and the via device lookup would then raise, + aborting the refresh. + """ + await setup_integration(hass, mock_config_entry) + + endpoint_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{mock_config_entry.entry_id}_1"), mock_config_entry.entry_id + ) + assert endpoint_device is not None + assert ( + device_registry.async_get_device_by_identifier( + (DOMAIN, f"{mock_config_entry.entry_id}_1_stack_1"), + mock_config_entry.entry_id, + ) + is not None + ) + + stacks = cast( + list[dict[str, Any]], + await async_load_json_array_fixture(hass, "stacks.json", DOMAIN), + ) + for stack in stacks: + if stack["Name"] == "webstack": + stack["Id"] = 99 + mock_portainer_client.get_stacks.return_value = [ + Stack.from_dict(stack) for stack in stacks + ] + + containers = cast( + list[dict[str, Any]], + await async_load_json_array_fixture(hass, "containers.json", DOMAIN), + ) + new_container = { + **next(c for c in containers if c["Names"] == ["/serene_banach"]), + "Names": ["/brave_newton"], + "Id": "cc97facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf", + } + mock_portainer_client.get_containers.return_value = [ + DockerContainer.from_dict(container) + for container in (*containers, new_container) + ] + + coordinator = mock_config_entry.runtime_data + await coordinator.async_refresh() + await hass.async_block_till_done() + + assert coordinator.last_update_success + + new_stack_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{mock_config_entry.entry_id}_1_stack_99"), + mock_config_entry.entry_id, + ) + assert new_stack_device is not None + assert new_stack_device.via_device_id == endpoint_device.id + + new_container_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{mock_config_entry.entry_id}_1_brave_newton"), + mock_config_entry.entry_id, + ) + assert new_container_device is not None + assert new_container_device.via_device_id == new_stack_device.id From 5f0e4a66c1ac40d06b257a1934692dd2c8622ddf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 15:59:28 +0200 Subject: [PATCH 064/109] Adapt hikvision to set via_device_id in DeviceInfo (#177719) --- homeassistant/components/hikvision/binary_sensor.py | 4 +++- homeassistant/components/hikvision/camera.py | 6 ++++-- homeassistant/components/hikvision/entity.py | 10 +++++++--- tests/components/hikvision/test_binary_sensor.py | 11 ++++++++--- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py index 2d0404f0def3..3342cc91744c 100644 --- a/homeassistant/components/hikvision/binary_sensor.py +++ b/homeassistant/components/hikvision/binary_sensor.py @@ -267,6 +267,7 @@ async def async_setup_entry( seen_channels.add(channel) entities.append( HikvisionBinarySensor( + hass=hass, entry=entry, description=BINARY_SENSOR_DESCRIPTIONS[sensor_type], sensor_type=sensor_type, @@ -284,13 +285,14 @@ class HikvisionBinarySensor(HikvisionEntity, BinarySensorEntity): def __init__( self, + hass: HomeAssistant, entry: HikvisionConfigEntry, description: BinarySensorEntityDescription, sensor_type: str, channel: int, ) -> None: """Initialize the binary sensor.""" - super().__init__(entry, channel) + super().__init__(hass, entry, channel) self.entity_description = description self._sensor_type = sensor_type diff --git a/homeassistant/components/hikvision/camera.py b/homeassistant/components/hikvision/camera.py index fc979f4f93ad..b44a128f8771 100644 --- a/homeassistant/components/hikvision/camera.py +++ b/homeassistant/components/hikvision/camera.py @@ -27,7 +27,7 @@ async def async_setup_entry( if data.channels: # NVR with video channels from get_video_channels() async_add_entities( - HikvisionCamera(entry, channel) + HikvisionCamera(hass, entry, channel) for channel in data.channels.values() if channel.enabled ) @@ -36,6 +36,7 @@ async def async_setup_entry( async_add_entities( [ HikvisionCamera( + hass, entry, VideoChannel(id=1, name=data.device_name, enabled=True), ) @@ -51,11 +52,12 @@ class HikvisionCamera(HikvisionEntity, Camera): def __init__( self, + hass: HomeAssistant, entry: HikvisionConfigEntry, channel: VideoChannel, ) -> None: """Initialize the camera.""" - super().__init__(entry, channel.id) + super().__init__(hass, entry, channel.id) self._video_channel = channel # Build unique ID (unique per platform per integration) diff --git a/homeassistant/components/hikvision/entity.py b/homeassistant/components/hikvision/entity.py index 4cd1ed6315f7..0723f685439c 100644 --- a/homeassistant/components/hikvision/entity.py +++ b/homeassistant/components/hikvision/entity.py @@ -1,5 +1,7 @@ """Base entity for Hikvision integration.""" +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -14,6 +16,7 @@ class HikvisionEntity(Entity): def __init__( self, + hass: HomeAssistant, entry: HikvisionConfigEntry, channel: int, ) -> None: @@ -23,12 +26,13 @@ class HikvisionEntity(Entity): self._camera = self._data.camera self._channel = channel - # Device info for device registry if self._data.device_type == "NVR": - # NVR channels get their own device linked to the NVR via via_device + # NVR channels get their own device linked to the NVR via via_device_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{self._data.device_id}_{channel}")}, - via_device=(DOMAIN, self._data.device_id), + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, self._data.device_id), config_entry_id=entry.entry_id + ), translation_key="nvr_channel", translation_placeholders={ "device_name": self._data.device_name, diff --git a/tests/components/hikvision/test_binary_sensor.py b/tests/components/hikvision/test_binary_sensor.py index 45eb0e76e67c..d0ba96fe0354 100644 --- a/tests/components/hikvision/test_binary_sensor.py +++ b/tests/components/hikvision/test_binary_sensor.py @@ -147,18 +147,23 @@ async def test_binary_sensor_nvr_device( await setup_integration(hass, mock_config_entry) - # Verify NVR channel devices are created with via_device linking + # Verify NVR channel devices are linked to the NVR device via via_device_id + nvr_device = device_registry.async_get_device_by_identifier( + (DOMAIN, TEST_DEVICE_ID), mock_config_entry.entry_id + ) + assert nvr_device is not None + channel_1_device = device_registry.async_get_device( identifiers={(DOMAIN, f"{TEST_DEVICE_ID}_1")} ) assert channel_1_device is not None - assert channel_1_device.via_device_id is not None + assert channel_1_device.via_device_id == nvr_device.id channel_2_device = device_registry.async_get_device( identifiers={(DOMAIN, f"{TEST_DEVICE_ID}_2")} ) assert channel_2_device is not None - assert channel_2_device.via_device_id is not None + assert channel_2_device.via_device_id == nvr_device.id # Verify sensors are created (entity IDs depend on translation loading) states = hass.states.async_entity_ids("binary_sensor") From 8f482bd7ecddf035a659b19661e550689e22e30d Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 3 Aug 2026 07:08:55 -0700 Subject: [PATCH 065/109] Bump solaredge-web to 0.3.0 (#177899) --- .../components/solaredge/coordinator.py | 119 +++++++-- .../components/solaredge/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/solaredge/conftest.py | 13 +- .../components/solaredge/test_coordinator.py | 239 +++++++++++------- 5 files changed, 257 insertions(+), 118 deletions(-) diff --git a/homeassistant/components/solaredge/coordinator.py b/homeassistant/components/solaredge/coordinator.py index 031b731d8daf..b3860c66d371 100644 --- a/homeassistant/components/solaredge/coordinator.py +++ b/homeassistant/components/solaredge/coordinator.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, override from aiosolaredge import SolarEdge -from solaredge_web import EnergyData, SolarEdgeWeb, TimeUnit +from solaredge_web import EnergyData, SolarEdgeWeb from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.models import ( @@ -16,6 +16,7 @@ from homeassistant.components.recorder.models import ( ) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, + async_list_statistic_ids, get_last_statistics, statistics_during_period, ) @@ -23,7 +24,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util, snakecase +from homeassistant.util import dt as dt_util, slugify, snakecase from homeassistant.util.unit_conversion import EnergyConverter from .const import ( @@ -455,6 +456,8 @@ class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]): ) self.site_id = config_entry.data[CONF_SITE_ID] self.title = config_entry.title + self._serial_to_legacy_id: dict[str, str] = {} + self._legacy_id_map_initialized = False @callback def _dummy_listener() -> None: @@ -467,13 +470,12 @@ class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]): @override async def _async_update_data(self) -> None: """Fetch data from API endpoint and update statistics.""" - equipment: dict[int, dict[str, Any]] = await self.api.async_get_equipment() + equipment: dict[str, dict[str, Any]] = await self.api.async_get_equipment() + await self._async_build_legacy_id_map(equipment) # We fetch last week's data from the API and refresh # every 12h so we overwrite recent statistics. This is # intended to allow adding any corrected/updated data. - energy_data_list: list[EnergyData] = await self.api.async_get_energy_data( - TimeUnit.WEEK - ) + energy_data_list: list[EnergyData] = await self.api.async_get_energy_data() if not energy_data_list: LOGGER.warning( "No data received from SolarEdge API for site: %s", self.site_id @@ -486,9 +488,7 @@ class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]): ), ) for equipment_id, equipment_data in equipment.items(): - display_name = equipment_data.get( - "displayName", f"Equipment {equipment_id}" - ) + display_name = equipment_data.get("name", f"Equipment {equipment_id}") statistic_id = self.get_statistic_id(equipment_id) statistic_metadata = StatisticMetaData( mean_type=StatisticMeanType.ARITHMETIC, @@ -501,30 +501,19 @@ class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]): ) statistic_sum = last_sums[statistic_id] statistics = [] - current_hour_sum = 0.0 - current_hour_count = 0 for energy_data in energy_data_list: start_time = energy_data.start_time.replace( tzinfo=dt_util.get_default_time_zone() ) value = energy_data.values.get(equipment_id, 0.0) - current_hour_sum += value - current_hour_count += 1 - if start_time.minute != 45: - continue - # API returns data every 15 minutes; aggregate to 1-hour statistics - # when we reach the energy_data for the last 15 minutes of the hour. - current_avg = current_hour_sum / current_hour_count - statistic_sum += current_avg + statistic_sum += value statistics.append( StatisticData( - start=start_time - timedelta(minutes=45), - state=current_avg, + start=start_time, + state=value, sum=statistic_sum, ) ) - current_hour_sum = 0.0 - current_hour_count = 0 LOGGER.debug( "Adding %s statistics for %s %s", len(statistics), @@ -533,12 +522,90 @@ class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]): ) async_add_external_statistics(self.hass, statistic_metadata, statistics) - def get_statistic_id(self, equipment_id: int) -> str: - """Return the statistic ID for this equipment_id.""" + async def _async_build_legacy_id_map( + self, equipment: dict[str, dict[str, Any]] + ) -> None: + """Build a map from serial numbers to legacy numeric statistic IDs. + + The old API returned numeric equipment IDs; the new API returns serials. + To preserve existing statistics, we map each serial's name (the last + part after splitting by space, e.g. "1.1.1") to the old statistic ID + by matching the statistic name. This runs once per coordinator lifecycle. + """ + if self._legacy_id_map_initialized: + return + + all_stats = await async_list_statistic_ids(self.hass) + + prefix = f"{DOMAIN}:{self.site_id}_" + # Map the short name (e.g. "1.1.1") to the old numeric statistic ID. + # Multiple numeric IDs may share the same name, e.g. after a module + # replacement; those matches are ambiguous and skipped. + name_to_legacy: dict[str, str] = {} + ambiguous_names: set[str] = set() + for stat in all_stats: + stat_id = stat["statistic_id"] + if not stat_id.startswith(prefix): + continue + suffix = stat_id[len(prefix) :] + if not suffix.isdigit(): + continue + name = stat.get("name", "") + # Use the last part after splitting by space (e.g. "solaredge 1.1.1" -> "1.1.1"). + short_name = name.rsplit(" ", 1)[-1] if name else "" + if not short_name: + continue + if short_name in name_to_legacy: + del name_to_legacy[short_name] + ambiguous_names.add(short_name) + elif short_name not in ambiguous_names: + name_to_legacy[short_name] = stat_id + + self._legacy_id_map_initialized = True + + if not name_to_legacy and not ambiguous_names: + return + + # Map each serial to its old statistic ID via the equipment name. + for serial, data in equipment.items(): + name = data.get("name", "") + short_name = name.rsplit(" ", 1)[-1] if name else "" + if not short_name: + continue + if short_name in ambiguous_names: + LOGGER.warning( + "Skipping legacy statistics migration for %s %s: multiple " + "numeric statistics share the name %s, so the match is " + "ambiguous", + self.site_id, + name, + short_name, + ) + continue + if short_name in name_to_legacy: + self._serial_to_legacy_id[serial] = name_to_legacy[short_name] + + if self._serial_to_legacy_id: + LOGGER.debug( + "Mapped %s legacy SolarEdge statistics to serials for site %s", + len(self._serial_to_legacy_id), + self.site_id, + ) + + def get_statistic_id(self, equipment_id: int | str) -> str: + """Return the statistic ID for this equipment_id. + + If a legacy numeric ID exists for this serial, reuse it for backward + compatibility. Otherwise, create a new serial-based ID. + """ + if isinstance(equipment_id, str) and equipment_id in self._serial_to_legacy_id: + return self._serial_to_legacy_id[equipment_id] + if isinstance(equipment_id, str): + equipment_id = slugify(equipment_id) return f"{DOMAIN}:{self.site_id}_{equipment_id}" async def _async_get_last_sums( - self, equipment_ids: Iterable[int], start_time: datetime + self, equipment_ids: Iterable[int | str], start_time: datetime ) -> dict[str, float]: """Get the last sum from the recorder before start_time for each statistic.""" start = start_time - timedelta(hours=1) diff --git a/homeassistant/components/solaredge/manifest.json b/homeassistant/components/solaredge/manifest.json index 276ddaf0c821..ab668d398096 100644 --- a/homeassistant/components/solaredge/manifest.json +++ b/homeassistant/components/solaredge/manifest.json @@ -14,5 +14,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["aiosolaredge", "solaredge_web"], - "requirements": ["aiosolaredge==1.0.2", "solaredge-web==0.0.1"] + "requirements": ["aiosolaredge==1.0.2", "solaredge-web==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 46567175b205..4acaae7e9a3a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3065,7 +3065,7 @@ soco==0.31.2 solaredge-local==0.2.3 # homeassistant.components.solaredge -solaredge-web==0.0.1 +solaredge-web==0.3.0 # homeassistant.components.solarlog solarlog_cli==0.7.1 diff --git a/tests/components/solaredge/conftest.py b/tests/components/solaredge/conftest.py index 21cd8153d51c..f9e795fa80aa 100644 --- a/tests/components/solaredge/conftest.py +++ b/tests/components/solaredge/conftest.py @@ -1,12 +1,15 @@ """Common fixtures for the SolarEdge tests.""" from collections.abc import Generator +from datetime import datetime from unittest.mock import AsyncMock, Mock, patch import pytest +from solaredge_web import EnergyData from homeassistant.components.solaredge.const import CONF_SITE_ID, DOMAIN from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, load_json_object_fixture @@ -102,7 +105,13 @@ def mock_solaredge_web_api_fixture() -> Generator[AsyncMock]: api = mock_web_api_flow.return_value mock_web_api_coord.return_value = api api.async_get_equipment.return_value = { - 1001: {"displayName": "1.1"}, - 1002: {"displayName": "1.2"}, + "7A012345-CA": {"name": "Optimizer 1.1"}, + "7A012346-CA": {"name": "Optimizer 1.2"}, } + api.async_get_energy_data.return_value = [ + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 1, 1, 10, 0)), + values={"7A012345-CA": 10.0, "7A012346-CA": 20.0}, + ), + ] yield api diff --git a/tests/components/solaredge/test_coordinator.py b/tests/components/solaredge/test_coordinator.py index 367d86de3fb5..f61501348c92 100644 --- a/tests/components/solaredge/test_coordinator.py +++ b/tests/components/solaredge/test_coordinator.py @@ -10,7 +10,15 @@ import pytest from solaredge_web import EnergyData from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.statistics import statistics_during_period +from homeassistant.components.recorder.models import ( + StatisticMeanType, + StatisticMetaData, +) +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + async_list_statistic_ids, + statistics_during_period, +) from homeassistant.components.solaredge.const import ( CONF_SITE_ID, DATA_MODULES_COORDINATOR, @@ -29,6 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util +from homeassistant.util.unit_conversion import EnergyConverter from . import setup_integration from .conftest import API_KEY, PASSWORD, SITE_ID, USERNAME @@ -169,41 +178,17 @@ def mock_solar_edge_web() -> AsyncMock: ) as mock_api: api = mock_api.return_value api.async_get_equipment.return_value = { - 1001: {"displayName": "1.1"}, - 1002: {"displayName": "1.2"}, + "7A012345-CA": {"name": "Optimizer 1.1"}, + "7A012346-CA": {"name": "Optimizer 1.2"}, } api.async_get_energy_data.return_value = [ EnergyData( start_time=dt_util.as_utc(datetime(2025, 1, 1, 10, 0)), - values={1001: 10.0, 1002: 20.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 10, 15)), - values={1001: 11.0, 1002: 21.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 10, 30)), - values={1001: 12.0, 1002: 22.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 10, 45)), - values={1001: 13.0, 1002: 23.0}, + values={"7A012345-CA": 10.0, "7A012346-CA": 20.0}, ), EnergyData( start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 0)), - values={1001: 14.0, 1002: 24.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 15)), - values={1001: 15.0, 1002: 25.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 30)), - values={1001: 16.0, 1002: 26.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 45)), - values={1001: 17.0, 1002: 27.0}, + values={"7A012345-CA": 14.0, "7A012346-CA": 24.0}, ), ] yield api @@ -229,19 +214,19 @@ async def test_modules_coordinator_first_run( hass, dt_util.as_utc(datetime(1970, 1, 1, 0, 0)), None, - {f"{DOMAIN}:{SITE_ID}_1001", f"{DOMAIN}:{SITE_ID}_1002"}, + {f"{DOMAIN}:{SITE_ID}_7a012345_ca", f"{DOMAIN}:{SITE_ID}_7a012346_ca"}, "hour", None, {"state", "sum"}, ) assert stats == { - f"{DOMAIN}:{SITE_ID}_1001": [ - {"start": 1735783200.0, "end": 1735786800.0, "state": 11.5, "sum": 11.5}, - {"start": 1735786800.0, "end": 1735790400.0, "state": 15.5, "sum": 27.0}, + f"{DOMAIN}:{SITE_ID}_7a012345_ca": [ + {"start": 1735783200.0, "end": 1735786800.0, "state": 10.0, "sum": 10.0}, + {"start": 1735786800.0, "end": 1735790400.0, "state": 14.0, "sum": 24.0}, ], - f"{DOMAIN}:{SITE_ID}_1002": [ - {"start": 1735783200.0, "end": 1735786800.0, "state": 21.5, "sum": 21.5}, - {"start": 1735786800.0, "end": 1735790400.0, "state": 25.5, "sum": 47.0}, + f"{DOMAIN}:{SITE_ID}_7a012346_ca": [ + {"start": 1735783200.0, "end": 1735786800.0, "state": 20.0, "sum": 20.0}, + {"start": 1735786800.0, "end": 1735790400.0, "state": 24.0, "sum": 44.0}, ], } @@ -254,7 +239,7 @@ async def test_modules_coordinator_subsequent_run( ) -> None: """Test the coordinator correctly updates statistics on subsequent runs.""" mock_solar_edge_web.async_get_equipment.return_value = { - 1001: {"displayName": "1.1"}, + "7A012345-CA": {"name": "Optimizer 1.1"}, } entry = MockConfigEntry( domain=DOMAIN, @@ -269,36 +254,12 @@ async def test_modules_coordinator_subsequent_run( # Updated values, different from the first run EnergyData( start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 0)), - values={1001: 24.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 15)), - values={1001: 25.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 30)), - values={1001: 26.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 45)), - values={1001: 27.0}, + values={"7A012345-CA": 24.0}, ), # New values for the next hour EnergyData( start_time=dt_util.as_utc(datetime(2025, 1, 1, 12, 0)), - values={1001: 28.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 12, 15)), - values={1001: 29.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 12, 30)), - values={1001: 30.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 12, 45)), - values={1001: 31.0}, + values={"7A012345-CA": 28.0}, ), ] @@ -313,16 +274,16 @@ async def test_modules_coordinator_subsequent_run( hass, dt_util.as_utc(datetime(1970, 1, 1, 0, 0)), None, - {f"{DOMAIN}:{SITE_ID}_1001"}, + {f"{DOMAIN}:{SITE_ID}_7a012345_ca"}, "hour", None, {"state", "sum"}, ) assert stats == { - f"{DOMAIN}:{SITE_ID}_1001": [ - {"start": 1735783200.0, "end": 1735786800.0, "state": 11.5, "sum": 11.5}, - {"start": 1735786800.0, "end": 1735790400.0, "state": 25.5, "sum": 37.0}, - {"start": 1735790400.0, "end": 1735794000.0, "state": 29.5, "sum": 66.5}, + f"{DOMAIN}:{SITE_ID}_7a012345_ca": [ + {"start": 1735783200.0, "end": 1735786800.0, "state": 10.0, "sum": 10.0}, + {"start": 1735786800.0, "end": 1735790400.0, "state": 24.0, "sum": 34.0}, + {"start": 1735790400.0, "end": 1735794000.0, "state": 28.0, "sum": 62.0}, ] } @@ -335,7 +296,7 @@ async def test_modules_coordinator_subsequent_run_with_gap( ) -> None: """Test the coordinator updates statistics with a gap in data.""" mock_solar_edge_web.async_get_equipment.return_value = { - 1001: {"displayName": "1.1"}, + "7A012345-CA": {"name": "Optimizer 1.1"}, } entry = MockConfigEntry( domain=DOMAIN, @@ -350,19 +311,7 @@ async def test_modules_coordinator_subsequent_run_with_gap( # New values a month later, simulating a gap in data EnergyData( start_time=dt_util.as_utc(datetime(2025, 2, 1, 11, 0)), - values={1001: 24.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 2, 1, 11, 15)), - values={1001: 25.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 2, 1, 11, 30)), - values={1001: 26.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 2, 1, 11, 45)), - values={1001: 27.0}, + values={"7A012345-CA": 24.0}, ), ] @@ -376,16 +325,16 @@ async def test_modules_coordinator_subsequent_run_with_gap( hass, dt_util.as_utc(datetime(1970, 1, 1, 0, 0)), None, - {f"{DOMAIN}:{SITE_ID}_1001"}, + {f"{DOMAIN}:{SITE_ID}_7a012345_ca"}, "hour", None, {"state", "sum"}, ) assert stats == { - f"{DOMAIN}:{SITE_ID}_1001": [ - {"start": 1735783200.0, "end": 1735786800.0, "state": 11.5, "sum": 11.5}, - {"start": 1735786800.0, "end": 1735790400.0, "state": 15.5, "sum": 27.0}, - {"start": 1738465200.0, "end": 1738468800.0, "state": 25.5, "sum": 52.5}, + f"{DOMAIN}:{SITE_ID}_7a012345_ca": [ + {"start": 1735783200.0, "end": 1735786800.0, "state": 10.0, "sum": 10.0}, + {"start": 1735786800.0, "end": 1735790400.0, "state": 14.0, "sum": 24.0}, + {"start": 1738465200.0, "end": 1738468800.0, "state": 24.0, "sum": 48.0}, ] } @@ -415,7 +364,7 @@ async def test_modules_coordinator_no_energy_data( hass, dt_util.as_utc(datetime(1970, 1, 1, 0, 0)), None, - {f"{DOMAIN}:{SITE_ID}_1001", f"{DOMAIN}:{SITE_ID}_1002"}, + {f"{DOMAIN}:{SITE_ID}_7a012345_ca", f"{DOMAIN}:{SITE_ID}_7a012346_ca"}, "hour", None, {"state", "sum"}, @@ -435,3 +384,117 @@ async def test_modules_coordinator_api_failure( await setup_integration(hass, mock_config_entry_web_login) assert mock_config_entry_web_login.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("recorder_mock") +async def test_legacy_statistic_id_reused( + hass: HomeAssistant, + mock_solar_edge_web: AsyncMock, +) -> None: + """Test that legacy numeric statistic IDs are reused via name matching.""" + # Add a legacy numeric statistic with name "1.1" (matching equipment name). + legacy_statistic_id = f"{DOMAIN}:{SITE_ID}_231397259" + legacy_metadata = StatisticMetaData( + mean_type=StatisticMeanType.ARITHMETIC, + has_sum=True, + name="SolarEdge 1.1", + source=DOMAIN, + statistic_id=legacy_statistic_id, + unit_class=EnergyConverter.UNIT_CLASS, + unit_of_measurement="Wh", + ) + async_add_external_statistics(hass, legacy_metadata, []) + await async_wait_recording_done(hass) + + # Equipment with matching name "1.1". + mock_solar_edge_web.async_get_equipment.return_value = { + "7A012345-CA": {"name": "Optimizer 1.1"}, + } + + entry = MockConfigEntry( + domain=DOMAIN, + title="SolarEdge", + data={CONF_SITE_ID: SITE_ID, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + # The legacy statistic ID should be reused (not a new serial-based one). + coordinator: SolarEdgeModulesCoordinator = entry.runtime_data[ + DATA_MODULES_COORDINATOR + ] + assert coordinator.get_statistic_id("7A012345-CA") == legacy_statistic_id + + # Data should be inserted under the legacy ID. + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + dt_util.as_utc(datetime(1970, 1, 1, 0, 0)), + None, + {legacy_statistic_id}, + "hour", + None, + {"state", "sum"}, + ) + assert stats == { + legacy_statistic_id: [ + {"start": 1735783200.0, "end": 1735786800.0, "state": 10.0, "sum": 10.0}, + {"start": 1735786800.0, "end": 1735790400.0, "state": 14.0, "sum": 24.0}, + ] + } + # No new serial-based ID should have been created. + all_stats = await async_list_statistic_ids(hass) + stat_ids = {s["statistic_id"] for s in all_stats} + assert f"{DOMAIN}:{SITE_ID}_7a012345_ca" not in stat_ids + + +@pytest.mark.usefixtures("recorder_mock") +async def test_legacy_statistic_id_ambiguous( + hass: HomeAssistant, + mock_solar_edge_web: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that ambiguous legacy statistic names are not reused.""" + # Two legacy numeric statistics with the same name, e.g. after a module + # replacement created a new series under a new numeric ID. + for legacy_id in ( + f"{DOMAIN}:{SITE_ID}_231397259", + f"{DOMAIN}:{SITE_ID}_231397260", + ): + legacy_metadata = StatisticMetaData( + mean_type=StatisticMeanType.ARITHMETIC, + has_sum=True, + name="SolarEdge 1.1", + source=DOMAIN, + statistic_id=legacy_id, + unit_class=EnergyConverter.UNIT_CLASS, + unit_of_measurement="Wh", + ) + async_add_external_statistics(hass, legacy_metadata, []) + await async_wait_recording_done(hass) + + # Equipment with matching name "1.1". + mock_solar_edge_web.async_get_equipment.return_value = { + "7A012345-CA": {"name": "Optimizer 1.1"}, + } + + entry = MockConfigEntry( + domain=DOMAIN, + title="SolarEdge", + data={CONF_SITE_ID: SITE_ID, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + # The match is ambiguous, so a new serial-based ID is used. + coordinator: SolarEdgeModulesCoordinator = entry.runtime_data[ + DATA_MODULES_COORDINATOR + ] + assert ( + coordinator.get_statistic_id("7A012345-CA") == f"{DOMAIN}:{SITE_ID}_7a012345_ca" + ) + assert "ambiguous" in caplog.text From 0a263fd35d585b5ca74b1eb06a06aed1920bbd28 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 16:24:04 +0200 Subject: [PATCH 066/109] Migrate keenetic_ndms2 to call async_remove_device (#178054) --- .../components/keenetic_ndms2/__init__.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py index 4f709ab98fec..154820234b49 100644 --- a/homeassistant/components/keenetic_ndms2/__init__.py +++ b/homeassistant/components/keenetic_ndms2/__init__.py @@ -70,11 +70,15 @@ async def async_unload_entry( _LOGGER.debug("Removing entity %s", entity_entry.entity_id) ent_reg.async_remove(entity_entry.entity_id) - if entity_entry.device_id: - dev_reg.async_update_device( - entity_entry.device_id, - remove_config_entry_id=config_entry.entry_id, - ) + # The tracker attaches to a device found by MAC, which may be + # owned by another integration; only remove it if we own it. + if ( + entity_entry.device_id + and (device := dev_reg.async_get(entity_entry.device_id)) + is not None + and device.config_entry_id == config_entry.entry_id + ): + dev_reg.async_remove_device(device.id) _LOGGER.debug("Finished cleaning device_tracker entities") From c72db7bd7dc9b2d07c7c109666593bfde6b30dbc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 16:26:53 +0200 Subject: [PATCH 067/109] Migrate teltonika dhcp flow to call async_get_devices (#177977) --- .../components/teltonika/config_flow.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/teltonika/config_flow.py b/homeassistant/components/teltonika/config_flow.py index 6b57e578bb85..5ea13a6c6b5e 100644 --- a/homeassistant/components/teltonika/config_flow.py +++ b/homeassistant/components/teltonika/config_flow.py @@ -235,18 +235,17 @@ class TeltonikaConfigFlow(ConfigFlow, domain=DOMAIN): # unauthorized endpoint. Match existing entries by MAC so it # aborts without asking for credentials again. device_reg = dr.async_get(self.hass) - if existing := device_reg.async_get_device( + for device in device_reg.async_get_devices( connections={(dr.CONNECTION_NETWORK_MAC, formatted_mac)} ): - for entry_id in existing.config_entries: - entry = self.hass.config_entries.async_get_entry(entry_id) - if ( - entry is not None - and entry.domain == DOMAIN - and entry.unique_id is not None - ): - device_id = entry.unique_id - break + entry = self.hass.config_entries.async_get_entry(device.config_entry_id) + if ( + entry is not None + and entry.domain == DOMAIN + and entry.unique_id is not None + ): + device_id = entry.unique_id + break # Use the MAC as a placeholder unique_id when nothing matched, so # parallel DHCP advertisements don't both reach dhcp_confirm. From dfb46d7dcb4c2d828cee1d51977e8ec55ae4a232 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 16:52:21 +0200 Subject: [PATCH 068/109] Adapt qbus to set via_device_id in DeviceInfo (#177750) --- homeassistant/components/qbus/entity.py | 43 +++++++++++++++++-------- tests/components/qbus/test_light.py | 29 ++++++++++++++++- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/qbus/entity.py b/homeassistant/components/qbus/entity.py index ef73a8d70fac..3a7ae8f12a43 100644 --- a/homeassistant/components/qbus/entity.py +++ b/homeassistant/components/qbus/entity.py @@ -3,13 +3,14 @@ from abc import ABC, abstractmethod from collections.abc import Callable import re -from typing import cast, override +from typing import TYPE_CHECKING, cast, override from qbusmqttapi.discovery import QbusMqttDevice, QbusMqttOutput from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory from qbusmqttapi.state import QbusMqttState from homeassistant.components.mqtt import ReceiveMessage, client as mqtt +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.entity import Entity @@ -92,6 +93,7 @@ class QbusEntity[StateT: QbusMqttState](Entity, ABC): """Initialize the Qbus entity.""" self._mqtt_output = mqtt_output + self._link_to_main_device = link_to_main_device self._topic_factory = QbusMqttTopicFactory() self._message_factory = QbusMqttMessageFactory() @@ -99,8 +101,8 @@ class QbusEntity[StateT: QbusMqttState](Entity, ABC): mqtt_output.device.id, mqtt_output.id ) - ref_id = format_ref_id(mqtt_output.ref_id) - suffix = ref_id or "" + self._ref_id = format_ref_id(mqtt_output.ref_id) + suffix = self._ref_id or "" if id_suffix: suffix += f"_{id_suffix}" @@ -109,19 +111,32 @@ class QbusEntity[StateT: QbusMqttState](Entity, ABC): mqtt_output.device.serial_number, suffix ) - if link_to_main_device: - self._attr_device_info = DeviceInfo( - identifiers={create_device_identifier(mqtt_output.device)} - ) - else: - self._attr_device_info = DeviceInfo( - name=mqtt_output.name.title(), - manufacturer=MANUFACTURER, - identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")}, - suggested_area=mqtt_output.location.title(), - via_device=create_device_identifier(mqtt_output.device), + @property + @override + def device_info(self) -> DeviceInfo: + """Return the device info.""" + if self._link_to_main_device: + return DeviceInfo( + identifiers={create_device_identifier(self._mqtt_output.device)} ) + config_entry = self.platform.config_entry + if TYPE_CHECKING: + assert config_entry is not None + return DeviceInfo( + name=self._mqtt_output.name.title(), + manufacturer=MANUFACTURER, + identifiers={ + (DOMAIN, f"{self._mqtt_output.device.serial_number}_{self._ref_id}") + }, + suggested_area=self._mqtt_output.location.title(), + via_device_id=dr.async_get_device_id_by_identifier( + self.hass, + create_device_identifier(self._mqtt_output.device), + config_entry_id=config_entry.entry_id, + ), + ) + @override async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" diff --git a/tests/components/qbus/test_light.py b/tests/components/qbus/test_light.py index 1a44949ca7bf..fca3fe28435a 100644 --- a/tests/components/qbus/test_light.py +++ b/tests/components/qbus/test_light.py @@ -3,6 +3,7 @@ from collections.abc import Awaitable, Callable from unittest.mock import patch +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import ( @@ -14,9 +15,10 @@ from homeassistant.components.light import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) +from homeassistant.components.qbus.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry, async_fire_mqtt_message, snapshot_platform from tests.typing import MqttMockHAClient @@ -95,6 +97,8 @@ _TOPIC_COLOR_SET_STATE = "cloudapp/QBUSMQTTGW/UL1/UL100/setState" _DIMMER_ENTITY_ID = "light.media_room_media_room" _COLOR_ENTITY_ID = "light.media_room_tv" +_CONTROLLER_MAC = "001122334455" + async def test_light( hass: HomeAssistant, @@ -111,6 +115,29 @@ async def test_light( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@pytest.mark.usefixtures("setup_integration") +async def test_light_via_device_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the light's device links to the controller device via via_device_id.""" + entity_entry = entity_registry.async_get(_DIMMER_ENTITY_ID) + assert entity_entry + assert entity_entry.device_id + + device = device_registry.async_get(entity_entry.device_id) + assert device + + controller_device = device_registry.async_get_device_by_identifier( + (DOMAIN, dr.format_mac(_CONTROLLER_MAC)), mock_config_entry.entry_id + ) + assert controller_device + + assert device.via_device_id == controller_device.id + + async def test_dimmer( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, From 1e9d3223d193caec65bf10d965ca3ae21325b240 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 16:52:36 +0200 Subject: [PATCH 069/109] Fix via_device race in rainbird (#177751) --- homeassistant/components/rainbird/__init__.py | 9 ++++++++ homeassistant/components/rainbird/switch.py | 7 +++++- tests/components/rainbird/test_init.py | 4 +++- tests/components/rainbird/test_switch.py | 22 ++++++++++++++++++- 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 57f4f5f7e012..3d0a8b2b2f03 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -130,6 +130,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> await data.coordinator.async_config_entry_first_refresh() entry.runtime_data = data + + # Register the controller device up front so that child zone/switch devices + # can resolve their via_device_id link during platform setup, regardless of + # which platform loads first. + if device_info := data.coordinator.device_info: + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, **device_info + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_update_listener)) diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index e8bef2de36ad..efffb7a12eec 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -8,6 +8,7 @@ from pyrainbird.exceptions import RainbirdApiException, RainbirdDeviceBusyExcept from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -66,7 +67,11 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity) name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer=MANUFACTURER, - via_device=(DOMAIN, coordinator.unique_id), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.unique_id), + config_entry_id=coordinator.config_entry.entry_id, + ), ) @property diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 258ecd49fcab..c656a0a8c30f 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -549,9 +549,11 @@ async def test_reload_migration_with_leading_zero_mac( assert reloaded_entity_entry is not None assert reloaded_entity_entry.unique_id == f"{mac_address_unique_id}-1-zone1" + # Two devices: the controller (registered at setup) and the migrated zone + # device. Neither was duplicated by the reload. assert ( len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) - == 1 + == 2 ) assert ( len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 71f6c988dc8a..c07fc22239f9 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import ( ACK_ECHO, @@ -17,6 +17,7 @@ from .conftest import ( EMPTY_STATIONS_RESPONSE, HOST, MAC_ADDRESS, + MAC_ADDRESS_UNIQUE_ID, PASSWORD, RAIN_DELAY_OFF, RAIN_SENSOR_OFF, @@ -113,6 +114,25 @@ async def test_zones( assert entity_entry.unique_id == "4c:a1:61:00:11:22-3" +@pytest.mark.usefixtures("hass") +async def test_zone_device_linked_to_controller( + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test the zone switch device is linked to the controller device.""" + + controller_device = device_registry.async_get_device_by_identifier( + (DOMAIN, MAC_ADDRESS_UNIQUE_ID), config_entry.entry_id + ) + assert controller_device is not None + + zone_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{MAC_ADDRESS_UNIQUE_ID}-1"), config_entry.entry_id + ) + assert zone_device is not None + assert zone_device.via_device_id == controller_device.id + + async def test_switch_on( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, From ebe25237a10df29d6fec77b3e619fd36b92d047b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 16:52:56 +0200 Subject: [PATCH 070/109] Adapt russound_rio to set via_device_id in DeviceInfo (#177756) --- .../components/russound_rio/__init__.py | 11 +++---- .../components/russound_rio/entity.py | 15 ++++++++-- .../components/russound_rio/media_player.py | 8 +++-- .../components/russound_rio/number.py | 6 ++-- .../components/russound_rio/select.py | 6 ++-- .../components/russound_rio/switch.py | 6 ++-- tests/components/russound_rio/test_init.py | 29 ++++++++++++++++++- 7 files changed, 65 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py index 41381ea24836..559e8553ac75 100644 --- a/homeassistant/components/russound_rio/__init__.py +++ b/homeassistant/components/russound_rio/__init__.py @@ -73,13 +73,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> or f"{client.controllers[1].mac_address}-{controller_id}" ) connections = None - via_device = None + via_device_id: str | None = None configuration_url = None if controller_id != 1: assert client.controllers[1].mac_address - via_device = ( - DOMAIN, - client.controllers[1].mac_address, + via_device_id = dr.async_get_device_id_by_identifier( + hass, + (DOMAIN, client.controllers[1].mac_address), + config_entry_id=entry.entry_id, ) else: assert controller.mac_address @@ -94,7 +95,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> model=controller.controller_type, sw_version=controller.firmware_version, connections=connections, - via_device=via_device, + via_device_id=via_device_id, configuration_url=configuration_url, ) diff --git a/homeassistant/components/russound_rio/entity.py b/homeassistant/components/russound_rio/entity.py index 51c6482ab4d0..54129f5ecbcc 100644 --- a/homeassistant/components/russound_rio/entity.py +++ b/homeassistant/components/russound_rio/entity.py @@ -2,18 +2,23 @@ from collections.abc import Awaitable, Callable, Coroutine from functools import wraps -from typing import Any, Concatenate, override +from typing import TYPE_CHECKING, Any, Concatenate, override from aiorussound.rio import RussoundRIOClient from aiorussound.rio.client import Controller, ZoneControlSurface from aiorussound.rio.models import CallbackType +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS +if TYPE_CHECKING: + from . import RussoundConfigEntry + def command[_EntityT: RussoundBaseEntity, **_P]( func: Callable[Concatenate[_EntityT, _P], Awaitable[None]], @@ -46,7 +51,9 @@ class RussoundBaseEntity(Entity): def __init__( self, + hass: HomeAssistant, controller: Controller, + entry: RussoundConfigEntry, zone_id: int | None = None, ) -> None: """Initialize the entity.""" @@ -73,7 +80,11 @@ class RussoundBaseEntity(Entity): model=controller.controller_type, sw_version=controller.firmware_version, suggested_area=zone.name, - via_device=(DOMAIN, self._device_identifier), + via_device_id=dr.async_get_device_id_by_identifier( + hass, + (DOMAIN, self._device_identifier), + config_entry_id=entry.entry_id, + ), ) @property diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index 79e76d15e15b..6c28bcdba967 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -51,7 +51,9 @@ async def async_setup_entry( ) async_add_entities( - RussoundZoneDevice(controller, zone_id, sources, zone_source_exclusion) + RussoundZoneDevice( + hass, controller, entry, zone_id, sources, zone_source_exclusion + ) for controller in client.controllers.values() for zone_id in controller.zones ) @@ -88,13 +90,15 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): def __init__( self, + hass: HomeAssistant, controller: Controller, + entry: RussoundConfigEntry, zone_id: int, sources: dict[int, Source], zone_source_exclusion: bool, ) -> None: """Initialize the zone device.""" - super().__init__(controller, zone_id) + super().__init__(hass, controller, entry, zone_id) _zone = self._zone self._sources = sources self._attr_unique_id = f"{self._primary_mac_address}-{_zone.device_str}" diff --git a/homeassistant/components/russound_rio/number.py b/homeassistant/components/russound_rio/number.py index 48930ec6f64e..eb7be8dbd3a8 100644 --- a/homeassistant/components/russound_rio/number.py +++ b/homeassistant/components/russound_rio/number.py @@ -77,7 +77,7 @@ async def async_setup_entry( """Set up Russound number entities based on a config entry.""" client = entry.runtime_data async_add_entities( - RussoundNumberEntity(controller, zone_id, description) + RussoundNumberEntity(hass, controller, entry, zone_id, description) for controller in client.controllers.values() for zone_id in controller.zones for description in CONTROL_ENTITIES @@ -91,12 +91,14 @@ class RussoundNumberEntity(RussoundBaseEntity, NumberEntity): def __init__( self, + hass: HomeAssistant, controller: Controller, + entry: RussoundConfigEntry, zone_id: int, description: RussoundZoneNumberEntityDescription, ) -> None: """Initialize a Russound number entity.""" - super().__init__(controller, zone_id) + super().__init__(hass, controller, entry, zone_id) self.entity_description = description self._attr_unique_id = ( f"{self._primary_mac_address}-{self._zone.device_str}-{description.key}" diff --git a/homeassistant/components/russound_rio/select.py b/homeassistant/components/russound_rio/select.py index 9c6b3eb7a289..a91a97598760 100644 --- a/homeassistant/components/russound_rio/select.py +++ b/homeassistant/components/russound_rio/select.py @@ -50,7 +50,7 @@ async def async_setup_entry( """Set up Russound RIO select entities based on a config entry.""" client = entry.runtime_data async_add_entities( - RussoundSelectEntity(controller, zone_id, description) + RussoundSelectEntity(hass, controller, entry, zone_id, description) for controller in client.controllers.values() for zone_id in controller.zones for description in CONTROL_ENTITIES @@ -64,12 +64,14 @@ class RussoundSelectEntity(RussoundBaseEntity, SelectEntity): def __init__( self, + hass: HomeAssistant, controller: Controller, + entry: RussoundConfigEntry, zone_id: int, description: RussoundZoneSelectEntityDescription, ) -> None: """Initialize Russound RIO select.""" - super().__init__(controller, zone_id) + super().__init__(hass, controller, entry, zone_id) self.entity_description = description self._attr_unique_id = ( f"{self._primary_mac_address}-{self._zone.device_str}-{description.key}" diff --git a/homeassistant/components/russound_rio/switch.py b/homeassistant/components/russound_rio/switch.py index eeb8f095fa57..7254cc85233a 100644 --- a/homeassistant/components/russound_rio/switch.py +++ b/homeassistant/components/russound_rio/switch.py @@ -44,7 +44,7 @@ async def async_setup_entry( """Set up Russound RIO switch entities based on a config entry.""" client = entry.runtime_data async_add_entities( - RussoundSwitchEntity(controller, zone_id, description) + RussoundSwitchEntity(hass, controller, entry, zone_id, description) for controller in client.controllers.values() for zone_id in controller.zones for description in CONTROL_ENTITIES @@ -58,12 +58,14 @@ class RussoundSwitchEntity(RussoundBaseEntity, SwitchEntity): def __init__( self, + hass: HomeAssistant, controller: Controller, + entry: RussoundConfigEntry, zone_id: int, description: RussoundZoneSwitchEntityDescription, ) -> None: """Initialize Russound RIO switch.""" - super().__init__(controller, zone_id) + super().__init__(hass, controller, entry, zone_id) self.entity_description = description self._attr_unique_id = ( f"{self._primary_mac_address}-{self._zone.device_str}-{description.key}" diff --git a/tests/components/russound_rio/test_init.py b/tests/components/russound_rio/test_init.py index 01b4bf2bd300..de6340d2a2d7 100644 --- a/tests/components/russound_rio/test_init.py +++ b/tests/components/russound_rio/test_init.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from . import mock_state_update, setup_integration -from .const import MOCK_SERIAL_CONFIG, MOCK_TCP_CONFIG, MODEL +from .const import HARDWARE_MAC, MOCK_SERIAL_CONFIG, MOCK_TCP_CONFIG, MODEL from tests.common import MockConfigEntry @@ -50,6 +50,33 @@ async def test_device_info( assert device_entry == snapshot +@pytest.mark.usefixtures("mock_russound_client") +async def test_via_device_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test secondary controllers and zones link to their parent via via_device_id.""" + await setup_integration(hass, mock_config_entry) + + primary_device = device_registry.async_get_device_by_identifier( + (DOMAIN, HARDWARE_MAC), mock_config_entry.entry_id + ) + assert primary_device is not None + + secondary_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{HARDWARE_MAC}-2"), mock_config_entry.entry_id + ) + assert secondary_device is not None + assert secondary_device.via_device_id == primary_device.id + + zone_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{HARDWARE_MAC}-1"), mock_config_entry.entry_id + ) + assert zone_device is not None + assert zone_device.via_device_id == primary_device.id + + async def test_disconnect_reconnect_log( hass: HomeAssistant, snapshot: SnapshotAssertion, From 96f3fe312238082ef46e8cda38d959261f07957f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 16:53:19 +0200 Subject: [PATCH 071/109] Fix via_device race in sense (#177757) --- homeassistant/components/sense/__init__.py | 15 ++++++++++++ homeassistant/components/sense/entity.py | 7 +++++- tests/components/sense/test_init.py | 28 ++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py index fbd49cecb399..95f9165f8cfd 100644 --- a/homeassistant/components/sense/__init__.py +++ b/homeassistant/components/sense/__init__.py @@ -15,10 +15,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TIMEOUT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( ACTIVE_UPDATE_RATE, + DOMAIN, SENSE_CONNECT_EXCEPTIONS, SENSE_TIMEOUT_EXCEPTIONS, SENSE_WEBSOCKET_EXCEPTIONS, @@ -117,6 +119,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: SenseConfigEntry) -> boo rt=realtime_coordinator, ) + # Register the monitor device up front so child devices can reference it via + # via_device_id; child entities are added by concurrently-loaded platforms + # before any of them registers the monitor device. + sense_monitor_id = gateway.sense_monitor_id + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + name=f"Sense {sense_monitor_id}", + identifiers={(DOMAIN, sense_monitor_id)}, + model="Sense", + manufacturer="Sense Labs, Inc.", + configuration_url="https://home.sense.com", + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/sense/entity.py b/homeassistant/components/sense/entity.py index 35c556a51f2c..b9ae487ebb38 100644 --- a/homeassistant/components/sense/entity.py +++ b/homeassistant/components/sense/entity.py @@ -3,6 +3,7 @@ from sense_energy import ASyncSenseable from sense_energy.sense_api import SenseDevice +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -67,5 +68,9 @@ class SenseDeviceEntity(CoordinatorEntity[SenseCoordinator]): model="Sense", manufacturer="Sense Labs, Inc.", configuration_url="https://home.sense.com", - via_device=(DOMAIN, sense_monitor_id), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, sense_monitor_id), + config_entry_id=coordinator.config_entry.entry_id, + ), ) diff --git a/tests/components/sense/test_init.py b/tests/components/sense/test_init.py index 0575044cdc17..d50b23a2e363 100644 --- a/tests/components/sense/test_init.py +++ b/tests/components/sense/test_init.py @@ -12,12 +12,40 @@ from sense_energy import ( SenseWebsocketException, ) +from homeassistant.components.sense.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import DEVICE_1_ID, MONITOR_ID from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_sense") +async def test_device_via_device_link( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test child devices link to the monitor device via via_device_id.""" + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + monitor_device = device_registry.async_get_device_by_identifier( + (DOMAIN, MONITOR_ID), config_entry.entry_id + ) + assert monitor_device is not None + + child_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{MONITOR_ID}:{DEVICE_1_ID}"), config_entry.entry_id + ) + assert child_device is not None + assert child_device.via_device_id == monitor_device.id + + @pytest.mark.parametrize( "exception", [ From 6c27469b3a2722892abc3ad202340058bfca66c7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 16:53:42 +0200 Subject: [PATCH 072/109] Adapt sia to set via_device_id in DeviceInfo (#177760) --- .../components/sia/alarm_control_panel.py | 4 +- homeassistant/components/sia/binary_sensor.py | 18 +++-- homeassistant/components/sia/entity.py | 10 ++- tests/components/sia/test_init.py | 69 +++++++++++++++++++ 4 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 tests/components/sia/test_init.py diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py index cf44fb41b700..b130d5b0c817 100644 --- a/homeassistant/components/sia/alarm_control_panel.py +++ b/homeassistant/components/sia/alarm_control_panel.py @@ -72,7 +72,7 @@ async def async_setup_entry( """Set up SIA alarm_control_panel(s) from a config entry.""" async_add_entities( SIAAlarmControlPanel( - entry, account_data[CONF_ACCOUNT], zone, ENTITY_DESCRIPTION_ALARM + hass, entry, account_data[CONF_ACCOUNT], zone, ENTITY_DESCRIPTION_ALARM ) for account_data in entry.data[CONF_ACCOUNTS] for zone in range( @@ -89,6 +89,7 @@ class SIAAlarmControlPanel(SIABaseEntity, AlarmControlPanelEntity): def __init__( self, + hass: HomeAssistant, entry: ConfigEntry, account: str, zone: int, @@ -96,6 +97,7 @@ class SIAAlarmControlPanel(SIABaseEntity, AlarmControlPanelEntity): ) -> None: """Create SIAAlarmControlPanel object.""" super().__init__( + hass, entry, account, zone, diff --git a/homeassistant/components/sia/binary_sensor.py b/homeassistant/components/sia/binary_sensor.py index 8c15fdd1a740..95e8c38ee20a 100644 --- a/homeassistant/components/sia/binary_sensor.py +++ b/homeassistant/components/sia/binary_sensor.py @@ -82,7 +82,9 @@ ENTITY_DESCRIPTION_CONNECTIVITY = SIABinarySensorEntityDescription( ) -def generate_binary_sensors(entry: ConfigEntry) -> Iterable[SIABinarySensor]: +def generate_binary_sensors( + hass: HomeAssistant, entry: ConfigEntry +) -> Iterable[SIABinarySensor]: """Generate binary sensors. For each Account there is one power sensor with zone == 0. @@ -93,12 +95,16 @@ def generate_binary_sensors(entry: ConfigEntry) -> Iterable[SIABinarySensor]: zones = entry.options[CONF_ACCOUNTS][account][CONF_ZONES] yield SIABinarySensorConnectivity( - entry, account, SIA_HUB_ZONE, ENTITY_DESCRIPTION_CONNECTIVITY + hass, entry, account, SIA_HUB_ZONE, ENTITY_DESCRIPTION_CONNECTIVITY + ) + yield SIABinarySensor( + hass, entry, account, SIA_HUB_ZONE, ENTITY_DESCRIPTION_POWER ) - yield SIABinarySensor(entry, account, SIA_HUB_ZONE, ENTITY_DESCRIPTION_POWER) for zone in range(1, zones + 1): - yield SIABinarySensor(entry, account, zone, ENTITY_DESCRIPTION_SMOKE) - yield SIABinarySensor(entry, account, zone, ENTITY_DESCRIPTION_MOISTURE) + yield SIABinarySensor(hass, entry, account, zone, ENTITY_DESCRIPTION_SMOKE) + yield SIABinarySensor( + hass, entry, account, zone, ENTITY_DESCRIPTION_MOISTURE + ) async def async_setup_entry( @@ -107,7 +113,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SIA binary sensors from a config entry.""" - async_add_entities(generate_binary_sensors(entry)) + async_add_entities(generate_binary_sensors(hass, entry)) class SIABinarySensor(SIABaseEntity, BinarySensorEntity): diff --git a/homeassistant/components/sia/entity.py b/homeassistant/components/sia/entity.py index a8eeb446380b..3917524724cc 100644 --- a/homeassistant/components/sia/entity.py +++ b/homeassistant/components/sia/entity.py @@ -10,7 +10,8 @@ from pysiaalarm import SIAEvent from homeassistant.components.alarm_control_panel import AlarmControlPanelState from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT -from homeassistant.core import CALLBACK_TYPE, State, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityDescription @@ -55,6 +56,7 @@ class SIABaseEntity(RestoreEntity): def __init__( self, + hass: HomeAssistant, entry: ConfigEntry, account: str, zone: int, @@ -77,7 +79,11 @@ class SIABaseEntity(RestoreEntity): self._attr_device_info = DeviceInfo( name=self._attr_name, identifiers={(DOMAIN, self._attr_unique_id)}, - via_device=(DOMAIN, f"{entry.data[CONF_PORT]}_{account}"), + via_device_id=dr.async_get_device_id_by_identifier( + hass, + (DOMAIN, f"{self.port}_{self.account}"), + config_entry_id=entry.entry_id, + ), ) self._post_interval_update_cb_canceller: CALLBACK_TYPE | None = None diff --git a/tests/components/sia/test_init.py b/tests/components/sia/test_init.py new file mode 100644 index 000000000000..f9fbaa83cabf --- /dev/null +++ b/tests/components/sia/test_init.py @@ -0,0 +1,69 @@ +"""Test the sia setup process.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from homeassistant.components.sia.const import ( + CONF_ACCOUNT, + CONF_ACCOUNTS, + CONF_ENCRYPTION_KEY, + CONF_IGNORE_TIMESTAMPS, + CONF_PING_INTERVAL, + CONF_ZONES, + DOMAIN, +) +from homeassistant.const import CONF_PORT, CONF_PROTOCOL +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + +CONFIG_ENTRY_DATA = { + CONF_PORT: 7777, + CONF_PROTOCOL: "TCP", + CONF_ACCOUNTS: [ + { + CONF_ACCOUNT: "ABCDEF", + CONF_ENCRYPTION_KEY: "AAAAAAAAAAAAAAAA", + CONF_PING_INTERVAL: 10, + }, + ], +} +CONFIG_ENTRY_OPTIONS = { + CONF_ACCOUNTS: {"ABCDEF": {CONF_IGNORE_TIMESTAMPS: False, CONF_ZONES: 1}} +} + + +@pytest.fixture(autouse=True) +def mock_sia_client() -> Generator[None]: + """Mock SIAClient so no real socket is opened.""" + with patch("homeassistant.components.sia.hub.SIAClient", autospec=True): + yield + + +async def test_entity_device_linked_to_hub_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test an entity's device is linked to its hub device via via_device_id.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG_ENTRY_DATA, + options=CONFIG_ENTRY_OPTIONS, + title="SIA Alarm on port 7777", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + hub_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "7777_ABCDEF"), config_entry.entry_id + ) + assert hub_device is not None + + alarm_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{config_entry.entry_id}_ABCDEF_1"), config_entry.entry_id + ) + assert alarm_device is not None + assert alarm_device.via_device_id == hub_device.id From 9c9225e30189a3758987ddb8e15bcaa759185679 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 16:54:04 +0200 Subject: [PATCH 073/109] Adapt smartthings to set via_device_id in DeviceInfo (#177761) --- .../components/smartthings/climate.py | 20 +++++++++++-- tests/components/smartthings/test_climate.py | 28 +++++++++++++++++-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 1b384c5e0337..b54e115cb80f 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -26,6 +26,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -165,7 +166,9 @@ async def async_setup_entry( ) ) entities.extend( - SmartThingsHeatPumpZone(entry_data.client, device, component) + SmartThingsHeatPumpZone( + hass, entry_data.client, device, component, entry.entry_id + ) for device in entry_data.devices.values() for component in device.status if component in {"INDOOR", "INDOOR1", "INDOOR2"} @@ -682,7 +685,14 @@ class SmartThingsHeatPumpZone(SmartThingsEntity, ClimateEntity): _attr_name = None - def __init__(self, client: SmartThings, device: FullDevice, component: str) -> None: + def __init__( + self, + hass: HomeAssistant, + client: SmartThings, + device: FullDevice, + component: str, + entry_id: str, + ) -> None: """Init the class.""" super().__init__( client, @@ -699,7 +709,11 @@ class SmartThingsHeatPumpZone(SmartThingsEntity, ClimateEntity): self._attr_hvac_modes = self._determine_hvac_modes() self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{device.device.device_id}_{component}")}, - via_device=(DOMAIN, device.device.device_id), + via_device_id=dr.async_get_device_id_by_identifier( + hass, + (DOMAIN, device.device.device_id), + config_entry_id=entry_id, + ), name=f"{device.device.label} {component}", ) diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index bf13cdbb9936..0b1d91a80612 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -36,7 +36,7 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.components.smartthings.const import MAIN +from homeassistant.components.smartthings.const import DOMAIN, MAIN from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -47,7 +47,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import ( set_attribute_value, @@ -1159,6 +1159,30 @@ async def test_heat_pump_state_attributes_update( ) +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +@pytest.mark.usefixtures("devices") +async def test_heat_pump_zone_via_device_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test heat pump zone devices are linked to their parent device.""" + await setup_integration(hass, mock_config_entry) + + parent_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "3810e5ad-5351-d9f9-12ff-000001200000"), mock_config_entry.entry_id + ) + assert parent_device is not None + + for component in ("INDOOR1", "INDOOR2"): + zone_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"3810e5ad-5351-d9f9-12ff-000001200000_{component}"), + mock_config_entry.entry_id, + ) + assert zone_device is not None + assert zone_device.via_device_id == parent_device.id + + @pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_availability( hass: HomeAssistant, From 7eb093530b0e56e8d1e894e739901f8c62a72255 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 16:54:53 +0200 Subject: [PATCH 074/109] Fix via_device race in tractive (#177775) --- homeassistant/components/tractive/__init__.py | 17 ++++++++++++++++- .../components/tractive/binary_sensor.py | 6 +++++- .../components/tractive/device_tracker.py | 12 ++++++++++-- homeassistant/components/tractive/entity.py | 13 ++++++++++--- homeassistant/components/tractive/sensor.py | 6 +++++- homeassistant/components/tractive/switch.py | 6 +++++- tests/components/tractive/test_sensor.py | 1 + 7 files changed, 52 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 896021bc6a44..b0c65eea9185 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -120,6 +120,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: TractiveConfigEntry) -> entry.runtime_data = TractiveData(tractive, filtered_trackables) + # Register the tracker devices so entities on the pet devices can resolve + # their via_device link at construction time. + device_registry = dr.async_get(hass) + for item in filtered_trackables: + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + configuration_url="https://my.tractive.com/", + identifiers={(DOMAIN, item.tracker_details["_id"])}, + translation_key="tracker", + translation_placeholders={"id": item.tracker_details["_id"]}, + manufacturer="Tractive GmbH", + sw_version=item.tracker_details["fw_version"], + model_id=item.tracker_details["model_number"], + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Send initial health overview data to sensors after platforms are set up diff --git a/homeassistant/components/tractive/binary_sensor.py b/homeassistant/components/tractive/binary_sensor.py index 3c8ec3bf295a..8ab11a4cb27c 100644 --- a/homeassistant/components/tractive/binary_sensor.py +++ b/homeassistant/components/tractive/binary_sensor.py @@ -23,12 +23,16 @@ class TractiveBinarySensor(TractiveEntity, BinarySensorEntity): def __init__( self, + hass: HomeAssistant, + entry: TractiveConfigEntry, client: TractiveClient, item: Trackables, description: TractiveBinarySensorEntityDescription, ) -> None: """Initialize sensor entity.""" super().__init__( + hass, + entry, client, item.trackable, item.tracker_details, @@ -80,7 +84,7 @@ async def async_setup_entry( trackables = entry.runtime_data.trackables entities = [ - TractiveBinarySensor(client, item, description) + TractiveBinarySensor(hass, entry, client, item, description) for description in SENSOR_TYPES for item in trackables if description.supported(item.tracker_details) diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index 4322260f3353..0b3880152e96 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -21,7 +21,7 @@ async def async_setup_entry( client = entry.runtime_data.client trackables = entry.runtime_data.trackables - entities = [TractiveDeviceTracker(client, item) for item in trackables] + entities = [TractiveDeviceTracker(hass, entry, client, item) for item in trackables] async_add_entities(entities) @@ -32,9 +32,17 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): _attr_translation_key = "tracker" _attr_name = None - def __init__(self, client: TractiveClient, item: Trackables) -> None: + def __init__( + self, + hass: HomeAssistant, + entry: TractiveConfigEntry, + client: TractiveClient, + item: Trackables, + ) -> None: """Initialize tracker entity.""" super().__init__( + hass, + entry, client, item.trackable, item.tracker_details, diff --git a/homeassistant/components/tractive/entity.py b/homeassistant/components/tractive/entity.py index 973d7037a4ef..9186b1df56a9 100644 --- a/homeassistant/components/tractive/entity.py +++ b/homeassistant/components/tractive/entity.py @@ -2,12 +2,13 @@ from typing import Any, override -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from . import TractiveClient +from . import TractiveClient, TractiveConfigEntry from .const import DOMAIN, SERVER_UNAVAILABLE @@ -18,6 +19,8 @@ class TractiveEntity(Entity): def __init__( self, + hass: HomeAssistant, + entry: TractiveConfigEntry, client: TractiveClient, trackable: dict[str, Any], tracker_details: dict[str, Any], @@ -39,7 +42,11 @@ class TractiveEntity(Entity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, trackable["_id"])}, name=trackable["details"]["name"], - via_device=(DOMAIN, tracker_details["_id"]), + via_device_id=dr.async_get_device_id_by_identifier( + hass, + (DOMAIN, tracker_details["_id"]), + config_entry_id=entry.entry_id, + ), entry_type=DeviceEntryType.SERVICE, ) diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index dc4c6a22d0fd..ccb5a2238014 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -51,6 +51,8 @@ class TractiveSensor(TractiveEntity, SensorEntity): def __init__( self, + hass: HomeAssistant, + entry: TractiveConfigEntry, client: TractiveClient, item: Trackables, description: TractiveSensorEntityDescription, @@ -63,6 +65,8 @@ class TractiveSensor(TractiveEntity, SensorEntity): else: dispatcher_signal = f"{description.signal_prefix}-{item.trackable['_id']}" super().__init__( + hass, + entry, client, item.trackable, item.tracker_details, @@ -156,7 +160,7 @@ async def async_setup_entry( trackables = entry.runtime_data.trackables entities = [ - TractiveSensor(client, item, description) + TractiveSensor(hass, entry, client, item, description) for description in SENSOR_TYPES for item in trackables ] diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index e8884c301821..d75f29f18fd5 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -65,7 +65,7 @@ async def async_setup_entry( trackables = entry.runtime_data.trackables entities = [ - TractiveSwitch(client, item, description) + TractiveSwitch(hass, entry, client, item, description) for description in SWITCH_TYPES for item in trackables ] @@ -80,12 +80,16 @@ class TractiveSwitch(TractiveEntity, SwitchEntity): def __init__( self, + hass: HomeAssistant, + entry: TractiveConfigEntry, client: TractiveClient, item: Trackables, description: TractiveSwitchEntityDescription, ) -> None: """Initialize switch entity.""" super().__init__( + hass, + entry, client, item.trackable, item.tracker_details, diff --git a/tests/components/tractive/test_sensor.py b/tests/components/tractive/test_sensor.py index f892a2c46be3..fc3bf658cc00 100644 --- a/tests/components/tractive/test_sensor.py +++ b/tests/components/tractive/test_sensor.py @@ -49,6 +49,7 @@ async def test_sensor_device_assignment( pet_device = device_registry.async_get_device(identifiers={(DOMAIN, "pet_id_123")}) assert pet_device is not None + assert pet_device.via_device_id == tracker_device.id for entity_id in ( "sensor.tracker_device_id_123_battery", From 8d976c59f28959a288a1efb05b513b43ef87516d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 16:54:56 +0200 Subject: [PATCH 075/109] Fix via_device race in toon (#177774) --- homeassistant/components/toon/__init__.py | 43 +++++++++-- homeassistant/components/toon/entity.py | 47 ++++++------ tests/components/toon/test_init.py | 92 +++++++++++++++++++++++ 3 files changed, 153 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py index 10d1e5a0bcb6..212aa2e28bbe 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -113,20 +113,47 @@ async def async_setup_entry(hass: HomeAssistant, entry: ToonConfigEntry) -> bool entry.runtime_data = coordinator - # Register device for the Meter Adapter, since it will have no entities. + agreement = coordinator.data.agreement + agreement_id = agreement.agreement_id + + # Register the parent devices before forwarding the platforms, so that the + # child devices created by the entities can deterministically resolve their + # via_device_id. device_registry = dr.async_get(hass) - device_registry.async_get_or_create( + display_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, agreement_id)}, + manufacturer="Eneco", + model=agreement.display_hardware_version.rpartition("/")[0], + name="Toon Display", + sw_version=agreement.display_software_version.rpartition("/")[-1], + ) + # The Meter Adapter has no entities of its own. + meter_adapter_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={ - ( - DOMAIN, - coordinator.data.agreement.agreement_id, - "meter_adapter", - ) # type: ignore[arg-type] + (DOMAIN, agreement_id, "meter_adapter"), # type: ignore[arg-type] }, manufacturer="Eneco", name="Meter Adapter", - via_device=(DOMAIN, coordinator.data.agreement.agreement_id), + via_device_id=display_device.id, + ) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={ + (DOMAIN, agreement_id, "electricity"), # type: ignore[arg-type] + }, + name="Electricity Meter", + via_device_id=meter_adapter_device.id, + ) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={ + (DOMAIN, agreement_id, "boiler_module"), # type: ignore[arg-type] + }, + manufacturer="Eneco", + name="Boiler Module", + via_device_id=display_device.id, ) # Spin up the platforms diff --git a/homeassistant/components/toon/entity.py b/homeassistant/components/toon/entity.py index 9a7f90cc7733..edecd0ba18b8 100644 --- a/homeassistant/components/toon/entity.py +++ b/homeassistant/components/toon/entity.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from typing import override +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -44,10 +45,10 @@ class ToonElectricityMeterDeviceEntity(ToonEntity): identifiers={ (DOMAIN, agreement_id, "electricity"), # type: ignore[arg-type] }, - via_device=( - DOMAIN, - agreement_id, # type: ignore[typeddict-item] - "meter_adapter", + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, agreement_id, "meter_adapter"), # type: ignore[arg-type] + config_entry_id=self.coordinator.config_entry.entry_id, ), ) @@ -65,10 +66,10 @@ class ToonGasMeterDeviceEntity(ToonEntity): identifiers={ (DOMAIN, agreement_id, "gas"), # type: ignore[arg-type] }, - via_device=( - DOMAIN, - agreement_id, # type: ignore[typeddict-item] - "electricity", + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, agreement_id, "electricity"), # type: ignore[arg-type] + config_entry_id=self.coordinator.config_entry.entry_id, ), ) @@ -86,10 +87,10 @@ class ToonWaterMeterDeviceEntity(ToonEntity): identifiers={ (DOMAIN, agreement_id, "water"), # type: ignore[arg-type] }, - via_device=( - DOMAIN, - agreement_id, # type: ignore[typeddict-item] - "electricity", + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, agreement_id, "electricity"), # type: ignore[arg-type] + config_entry_id=self.coordinator.config_entry.entry_id, ), ) @@ -107,10 +108,10 @@ class ToonSolarDeviceEntity(ToonEntity): identifiers={ (DOMAIN, agreement_id, "solar"), # type: ignore[arg-type] }, - via_device=( - DOMAIN, - agreement_id, # type: ignore[typeddict-item] - "meter_adapter", + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, agreement_id, "meter_adapter"), # type: ignore[arg-type] + config_entry_id=self.coordinator.config_entry.entry_id, ), ) @@ -133,7 +134,11 @@ class ToonBoilerModuleDeviceEntity(ToonEntity): "boiler_module", ) }, - via_device=(DOMAIN, agreement_id), + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, agreement_id), + config_entry_id=self.coordinator.config_entry.entry_id, + ), ) @@ -150,10 +155,10 @@ class ToonBoilerDeviceEntity(ToonEntity): identifiers={ (DOMAIN, agreement_id, "boiler"), # type: ignore[arg-type] }, - via_device=( - DOMAIN, - agreement_id, # type: ignore[typeddict-item] - "boiler_module", + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, agreement_id, "boiler_module"), # type: ignore[arg-type] + config_entry_id=self.coordinator.config_entry.entry_id, ), ) diff --git a/tests/components/toon/test_init.py b/tests/components/toon/test_init.py index 72c284a886a2..85a875707037 100644 --- a/tests/components/toon/test_init.py +++ b/tests/components/toon/test_init.py @@ -1,10 +1,15 @@ """Tests for the Toon component.""" +import time from unittest.mock import patch +from toonapi import Agreement, Status +from toonapi.models import ThermostatInfo + from homeassistant.components.toon import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow, device_registry as dr from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, ) @@ -66,3 +71,90 @@ async def test_migrate_entry_minor_version_2_2(hass: HomeAssistant) -> None: assert entry.version == 2 assert entry.minor_version == 2 assert entry.unique_id == "123" + + +async def test_device_registry_via_devices( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that child devices are linked to their parent via via_device_id.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + version=2, + minor_version=2, + unique_id="test-agreement-id", + data={ + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "expires_at": time.time() + 3600, + }, + "agreement_id": "test-agreement-id", + }, + ) + config_entry.add_to_hass(hass) + + config_entry_oauth2_flow.async_register_implementation( + hass, + DOMAIN, + config_entry_oauth2_flow.LocalOAuth2Implementation( + hass, + DOMAIN, + "client-id", + "client-secret", + "https://api.toon.eu/authorize", + "https://api.toon.eu/token", + ), + ) + + agreement = Agreement( + agreement_id="test-agreement-id", + display_common_name="display-common-name", + display_hardware_version="qb2/ICY/v0.8", + display_software_version="qb2/v1.2", + heating_type="gas", + is_toon_solar=True, + ) + status = Status(agreement=agreement) + status.thermostat = ThermostatInfo(have_opentherm_boiler=True) + + with ( + patch("toonapi.Toon.activate_agreement"), + patch("toonapi.Toon.update", return_value=status), + patch( + "homeassistant.components.toon.coordinator." + "ToonDataUpdateCoordinator.register_webhook" + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + def get_device(*identifier: str) -> dr.DeviceEntry: + device = device_registry.async_get_device_by_identifier( + (DOMAIN, *identifier), # type: ignore[arg-type] + config_entry.entry_id, + ) + assert device is not None + return device + + display = get_device("test-agreement-id") + assert display.via_device_id is None + + meter_adapter = get_device("test-agreement-id", "meter_adapter") + assert meter_adapter.via_device_id == display.id + + electricity = get_device("test-agreement-id", "electricity") + assert electricity.via_device_id == meter_adapter.id + + boiler_module = get_device("test-agreement-id", "boiler_module") + assert boiler_module.via_device_id == display.id + + assert get_device("test-agreement-id", "gas").via_device_id == electricity.id + assert get_device("test-agreement-id", "water").via_device_id == electricity.id + assert get_device("test-agreement-id", "solar").via_device_id == meter_adapter.id + assert get_device("test-agreement-id", "boiler").via_device_id == boiler_module.id From 68b820735ccea52c8212396a59f8be269066cfa5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 16:55:48 +0200 Subject: [PATCH 076/109] Fix via_device race in tessie (#177770) --- homeassistant/components/tessie/__init__.py | 10 +++++++++ homeassistant/components/tessie/entity.py | 9 ++++++-- tests/components/tessie/test_init.py | 23 +++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index 30042482cb1b..fe90bc513e8a 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -26,6 +26,7 @@ from homeassistant.exceptions import ( ConfigEntryError, ConfigEntryNotReady, ) +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo @@ -212,6 +213,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo ) entry.runtime_data = TessieData(vehicles, energysites) + + # Register energy site devices before forwarding to platforms so wall + # connector entities can resolve their via_device parent at construction. + device_registry = dr.async_get(hass) + for energysite in energysites: + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, **energysite.device + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index 9b40d15b1812..ad10278f8da4 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -5,6 +5,7 @@ from collections.abc import Awaitable, Callable from inspect import isawaitable from typing import Any, override +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -167,14 +168,18 @@ class TessieWallConnectorEntity(TessieBaseEntity): """Initialize common aspects of a Teslemetry entity.""" self.din = din self._attr_unique_id = f"{data.id}-{din}-{key}" + assert data.live_coordinator self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, din)}, manufacturer="Tesla", name="Wall Connector", - via_device=(DOMAIN, str(data.id)), + via_device_id=dr.async_get_device_id_by_identifier( + data.live_coordinator.hass, + (DOMAIN, str(data.id)), + config_entry_id=data.live_coordinator.config_entry.entry_id, + ), serial_number=din.rsplit("-", maxsplit=1)[-1], ) - assert data.live_coordinator super().__init__(data.live_coordinator, key, data_key) @property diff --git a/tests/components/tessie/test_init.py b/tests/components/tessie/test_init.py index ed2a6a1b9498..e7e419d67fda 100644 --- a/tests/components/tessie/test_init.py +++ b/tests/components/tessie/test_init.py @@ -11,8 +11,11 @@ from tesla_fleet_api.exceptions import ( TeslaFleetError, ) +from homeassistant.components.tessie.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from .common import setup_platform @@ -151,3 +154,23 @@ async def test_aiohttp_client_error_on_live_status_retries( ): entry = await setup_platform(hass) assert entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_wall_connector_via_device_id( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test that a wall connector device links to its energy site via via_device_id.""" + + entry = await setup_platform(hass, [Platform.SENSOR]) + + energy_site_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "123456"), entry.entry_id + ) + assert energy_site_device is not None + + wall_connector_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "abd-123"), entry.entry_id + ) + assert wall_connector_device is not None + assert wall_connector_device.via_device_id == energy_site_device.id From 12bed78c1a3fe3d5a6ac10f5b2979a40098948c4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 16:56:05 +0200 Subject: [PATCH 077/109] Adapt sunricher_dali to set via_device_id in DeviceInfo (#177768) --- .../sunricher_dali/binary_sensor.py | 34 ++++++------------- .../components/sunricher_dali/button.py | 18 +++------- .../components/sunricher_dali/entity.py | 21 ++++++++++-- .../components/sunricher_dali/light.py | 18 +++------- .../components/sunricher_dali/sensor.py | 34 ++++++------------- tests/components/sunricher_dali/test_init.py | 30 ++++++++++++++++ 6 files changed, 79 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/sunricher_dali/binary_sensor.py b/homeassistant/components/sunricher_dali/binary_sensor.py index a49deaa4b8a8..6b4a142d0222 100644 --- a/homeassistant/components/sunricher_dali/binary_sensor.py +++ b/homeassistant/components/sunricher_dali/binary_sensor.py @@ -11,10 +11,8 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, MANUFACTURER from .entity import DaliDeviceEntity from .types import DaliCenterConfigEntry @@ -40,8 +38,8 @@ async def async_setup_entry( entities: list[BinarySensorEntity] = [] for device in devices: if is_motion_sensor(device.dev_type): - entities.append(SunricherDaliMotionSensor(device)) - entities.append(SunricherDaliOccupancySensor(device)) + entities.append(SunricherDaliMotionSensor(hass, device, entry)) + entities.append(SunricherDaliOccupancySensor(hass, device, entry)) if entities: async_add_entities(entities) @@ -52,18 +50,12 @@ class SunricherDaliMotionSensor(DaliDeviceEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOTION - def __init__(self, device: Device) -> None: + def __init__( + self, hass: HomeAssistant, device: Device, entry: DaliCenterConfigEntry + ) -> None: """Initialize the motion sensor.""" - super().__init__(device) - self._device = device + super().__init__(hass, device, entry) self._attr_unique_id = f"{device.dev_id}_motion" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.dev_id)}, - name=device.name, - manufacturer=MANUFACTURER, - model=device.model, - via_device=(DOMAIN, device.gw_sn), - ) @override async def async_added_to_hass(self) -> None: @@ -95,18 +87,12 @@ class SunricherDaliOccupancySensor(DaliDeviceEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.OCCUPANCY - def __init__(self, device: Device) -> None: + def __init__( + self, hass: HomeAssistant, device: Device, entry: DaliCenterConfigEntry + ) -> None: """Initialize the occupancy sensor.""" - super().__init__(device) - self._device = device + super().__init__(hass, device, entry) self._attr_unique_id = f"{device.dev_id}_occupancy" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.dev_id)}, - name=device.name, - manufacturer=MANUFACTURER, - model=device.model, - via_device=(DOMAIN, device.gw_sn), - ) @override async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/sunricher_dali/button.py b/homeassistant/components/sunricher_dali/button.py index f4c2fe35f6f4..7e2c4ca8cdad 100644 --- a/homeassistant/components/sunricher_dali/button.py +++ b/homeassistant/components/sunricher_dali/button.py @@ -9,10 +9,8 @@ from PySrDaliGateway.helper import is_light_device from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, MANUFACTURER from .entity import DaliDeviceEntity from .types import DaliCenterConfigEntry @@ -30,7 +28,7 @@ async def async_setup_entry( devices = entry.runtime_data.devices async_add_entities( - DaliCenterIdentifyButton(device) + DaliCenterIdentifyButton(hass, device, entry) for device in devices if is_light_device(device.dev_type) ) @@ -43,18 +41,12 @@ class DaliCenterIdentifyButton(DaliDeviceEntity, ButtonEntity): _attr_entity_category = EntityCategory.CONFIG _attr_name = None - def __init__(self, device: Device) -> None: + def __init__( + self, hass: HomeAssistant, device: Device, entry: DaliCenterConfigEntry + ) -> None: """Initialize the device identify button.""" - super().__init__(device) - self._device = device + super().__init__(hass, device, entry) self._attr_unique_id = f"{device.unique_id}_identify" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.dev_id)}, - name=device.name, - manufacturer=MANUFACTURER, - model=device.model, - via_device=(DOMAIN, device.gw_sn), - ) @override async def async_press(self) -> None: diff --git a/homeassistant/components/sunricher_dali/entity.py b/homeassistant/components/sunricher_dali/entity.py index c59182f124a2..f37e875a603d 100644 --- a/homeassistant/components/sunricher_dali/entity.py +++ b/homeassistant/components/sunricher_dali/entity.py @@ -5,9 +5,14 @@ from typing import override from PySrDaliGateway import CallbackEventType, DaliObjectBase, Device -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity +from .const import DOMAIN, MANUFACTURER +from .types import DaliCenterConfigEntry + _LOGGER = logging.getLogger(__name__) @@ -51,7 +56,19 @@ class DaliCenterEntity(Entity): class DaliDeviceEntity(DaliCenterEntity): """Base entity for DALI Device objects.""" - def __init__(self, device: Device) -> None: + def __init__( + self, hass: HomeAssistant, device: Device, entry: DaliCenterConfigEntry + ) -> None: """Initialize device entity.""" super().__init__(device) + self._device = device self._attr_available = device.status == "online" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.dev_id)}, + name=device.name, + manufacturer=MANUFACTURER, + model=device.model, + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, device.gw_sn), config_entry_id=entry.entry_id + ), + ) diff --git a/homeassistant/components/sunricher_dali/light.py b/homeassistant/components/sunricher_dali/light.py index 491d0bfc9312..2ace1a4b2f31 100644 --- a/homeassistant/components/sunricher_dali/light.py +++ b/homeassistant/components/sunricher_dali/light.py @@ -16,10 +16,8 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, MANUFACTURER from .entity import DaliDeviceEntity from .types import DaliCenterConfigEntry @@ -38,7 +36,7 @@ async def async_setup_entry( devices = runtime_data.devices async_add_entities( - DaliCenterLight(device) + DaliCenterLight(hass, device, entry) for device in devices if is_light_device(device.dev_type) ) @@ -52,18 +50,12 @@ class DaliCenterLight(DaliDeviceEntity, LightEntity): _attr_max_color_temp_kelvin = 8000 _white_level: int | None = None - def __init__(self, light: Device) -> None: + def __init__( + self, hass: HomeAssistant, light: Device, entry: DaliCenterConfigEntry + ) -> None: """Initialize the light entity.""" - super().__init__(light) + super().__init__(hass, light, entry) self._light = light - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, light.dev_id)}, - name=light.name, - manufacturer=MANUFACTURER, - model=light.model, - via_device=(DOMAIN, light.gw_sn), - ) - self._determine_features() def _determine_features(self) -> None: diff --git a/homeassistant/components/sunricher_dali/sensor.py b/homeassistant/components/sunricher_dali/sensor.py index 5c4258b9b584..6d8417603cdf 100644 --- a/homeassistant/components/sunricher_dali/sensor.py +++ b/homeassistant/components/sunricher_dali/sensor.py @@ -14,10 +14,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import LIGHT_LUX, EntityCategory, UnitOfEnergy from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, MANUFACTURER from .entity import DaliDeviceEntity from .types import DaliCenterConfigEntry @@ -37,9 +35,9 @@ async def async_setup_entry( entities: list[SensorEntity] = [] for device in devices: if is_illuminance_sensor(device.dev_type): - entities.append(SunricherDaliIlluminanceSensor(device)) + entities.append(SunricherDaliIlluminanceSensor(hass, device, entry)) if is_light_device(device.dev_type): - entities.append(SunricherDaliEnergySensor(device)) + entities.append(SunricherDaliEnergySensor(hass, device, entry)) if entities: async_add_entities(entities) @@ -53,19 +51,13 @@ class SunricherDaliIlluminanceSensor(DaliDeviceEntity, SensorEntity): _attr_native_unit_of_measurement = LIGHT_LUX _attr_name = None - def __init__(self, device: Device) -> None: + def __init__( + self, hass: HomeAssistant, device: Device, entry: DaliCenterConfigEntry + ) -> None: """Initialize the illuminance sensor.""" - super().__init__(device) - self._device = device + super().__init__(hass, device, entry) self._illuminance_value: float | None = None self._sensor_enabled: bool = True - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.dev_id)}, - name=device.name, - manufacturer=MANUFACTURER, - model=device.model, - via_device=(DOMAIN, device.gw_sn), - ) @property @override @@ -132,18 +124,12 @@ class SunricherDaliEnergySensor(DaliDeviceEntity, SensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_suggested_display_precision = 2 - def __init__(self, device: Device) -> None: + def __init__( + self, hass: HomeAssistant, device: Device, entry: DaliCenterConfigEntry + ) -> None: """Initialize the energy sensor.""" - super().__init__(device) - self._device = device + super().__init__(hass, device, entry) self._attr_unique_id = f"{device.unique_id}_energy" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.dev_id)}, - name=device.name, - manufacturer=MANUFACTURER, - model=device.model, - via_device=(DOMAIN, device.gw_sn), - ) @override async def async_added_to_hass(self) -> None: diff --git a/tests/components/sunricher_dali/test_init.py b/tests/components/sunricher_dali/test_init.py index 2dd7db209968..6829e687cbae 100644 --- a/tests/components/sunricher_dali/test_init.py +++ b/tests/components/sunricher_dali/test_init.py @@ -48,6 +48,36 @@ async def test_devices( assert devices == snapshot +async def test_via_device_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gateway: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test devices are linked to the gateway device via via_device_id.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + gateway_device = device_registry.async_get_device_by_identifier( + (DOMAIN, mock_gateway.gw_sn), mock_config_entry.entry_id + ) + assert gateway_device is not None + + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + child_devices = [ + device_entry + for device_entry in device_entries + if device_entry.id != gateway_device.id + ] + assert child_devices + for device_entry in child_devices: + assert device_entry.via_device_id == gateway_device.id + + async def test_setup_entry_connection_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From c2ca4249f5fb36c732bbd297d6e69238e70a347b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 16:56:30 +0200 Subject: [PATCH 078/109] Fix via_device race in solaredge (#177763) --- .../components/solaredge/__init__.py | 19 ++++++++++++++- homeassistant/components/solaredge/sensor.py | 7 +++++- tests/components/solaredge/test_sensor.py | 23 ++++++++++++++++++- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/solaredge/__init__.py b/homeassistant/components/solaredge/__init__.py index 442e30d66b28..8965564374f9 100644 --- a/homeassistant/components/solaredge/__init__.py +++ b/homeassistant/components/solaredge/__init__.py @@ -8,9 +8,16 @@ from aiosolaredge import SolarEdge from homeassistant.const import CONF_API_KEY, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_SITE_ID, DATA_API_CLIENT, DATA_MODULES_COORDINATOR, LOGGER +from .const import ( + CONF_SITE_ID, + DATA_API_CLIENT, + DATA_MODULES_COORDINATOR, + DOMAIN, + LOGGER, +) from .coordinator import SolarEdgeModulesCoordinator from .types import SolarEdgeConfigEntry, SolarEdgeData @@ -42,6 +49,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: SolarEdgeConfigEntry) -> return False entry.runtime_data[DATA_API_CLIENT] = api + + # Register the site device up front so per-battery devices can resolve it + # as their via_device when their entities are created. + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, site_id)}, + manufacturer="SolarEdge", + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Setup for username/password (modules statistics) diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index 5a9913a31c65..978785af2054 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( @@ -616,7 +617,11 @@ class SolarEdgeBatterySensor(SolarEdgeSensorEntity): manufacturer="SolarEdge", name=f"Battery {serial}", serial_number=serial, - via_device=(DOMAIN, data_service.site_id), + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, data_service.site_id), + config_entry_id=data_service.config_entry.entry_id, + ), ) @property diff --git a/tests/components/solaredge/test_sensor.py b/tests/components/solaredge/test_sensor.py index 721c40fc6d41..16e747d6714f 100644 --- a/tests/components/solaredge/test_sensor.py +++ b/tests/components/solaredge/test_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.recorder import Recorder from homeassistant.components.solaredge.const import DOMAIN, INVENTORY_UPDATE_DELAY from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration from .conftest import SITE_ID @@ -471,6 +471,27 @@ async def test_storage_data_service_multi_battery( assert float(hass.states.get(bat2_soc).state) == 80.0 +@pytest.mark.usefixtures("recorder_mock", "solaredge_api") +async def test_battery_device_via_device_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the battery device is linked to the site device via via_device_id.""" + await setup_integration(hass, mock_config_entry) + + site_device = device_registry.async_get_device_by_identifier( + (DOMAIN, SITE_ID), mock_config_entry.entry_id + ) + assert site_device is not None + + battery_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{SITE_ID}_BAT001"), mock_config_entry.entry_id + ) + assert battery_device is not None + assert battery_device.via_device_id == site_device.id + + async def test_storage_service_not_created_when_inventory_has_no_batteries( recorder_mock: Recorder, hass: HomeAssistant, From d5d3748bfaacead8f6452d55ba1fe476c0b33a57 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 16:57:07 +0200 Subject: [PATCH 079/109] Adapt lutron to set via_device_id in DeviceInfo (#177730) --- .../components/lutron/binary_sensor.py | 4 +- homeassistant/components/lutron/cover.py | 4 +- homeassistant/components/lutron/entity.py | 41 +++++++++--- homeassistant/components/lutron/event.py | 8 ++- homeassistant/components/lutron/fan.py | 2 +- homeassistant/components/lutron/light.py | 7 +- homeassistant/components/lutron/scene.py | 10 ++- homeassistant/components/lutron/select.py | 16 ++++- homeassistant/components/lutron/switch.py | 24 ++++++- tests/components/lutron/test_init.py | 66 +++++++++++++++++++ 10 files changed, 158 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/lutron/binary_sensor.py b/homeassistant/components/lutron/binary_sensor.py index 760be15abaa4..e7f8d58b9f4e 100644 --- a/homeassistant/components/lutron/binary_sensor.py +++ b/homeassistant/components/lutron/binary_sensor.py @@ -29,7 +29,9 @@ async def async_setup_entry( entry_data = config_entry.runtime_data async_add_entities( [ - LutronOccupancySensor(area_name, device, entry_data.client) + LutronOccupancySensor( + hass, area_name, device, entry_data.client, config_entry.entry_id + ) for area_name, device in entry_data.binary_sensors ], True, diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py index 8e5938fe2b57..9f80a6705a20 100644 --- a/homeassistant/components/lutron/cover.py +++ b/homeassistant/components/lutron/cover.py @@ -33,7 +33,9 @@ async def async_setup_entry( entry_data = config_entry.runtime_data async_add_entities( [ - LutronCover(area_name, device, entry_data.client) + LutronCover( + hass, area_name, device, entry_data.client, config_entry.entry_id + ) for area_name, device in entry_data.covers ], True, diff --git a/homeassistant/components/lutron/entity.py b/homeassistant/components/lutron/entity.py index b8d96849ed80..6d5d42948830 100644 --- a/homeassistant/components/lutron/entity.py +++ b/homeassistant/components/lutron/entity.py @@ -4,7 +4,8 @@ from typing import override from pylutron import Keypad, Lutron, LutronEntity, LutronEvent -from homeassistant.const import ATTR_IDENTIFIERS, ATTR_VIA_DEVICE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -60,7 +61,12 @@ class LutronDevice(LutronBaseEntity): """Representation of a Lutron device entity.""" def __init__( - self, area_name: str, lutron_device: LutronEntity, controller: Lutron + self, + hass: HomeAssistant, + area_name: str, + lutron_device: LutronEntity, + controller: Lutron, + config_entry_id: str, ) -> None: """Initialize the device.""" super().__init__(area_name, lutron_device, controller) @@ -69,7 +75,9 @@ class LutronDevice(LutronBaseEntity): manufacturer="Lutron", name=lutron_device.name, suggested_area=area_name, - via_device=(DOMAIN, controller.guid), + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, controller.guid), config_entry_id=config_entry_id + ), ) @@ -78,20 +86,33 @@ class LutronKeypad(LutronBaseEntity): def __init__( self, + hass: HomeAssistant, area_name: str, lutron_device: LutronEntity, controller: Lutron, keypad: Keypad, + config_entry_id: str, ) -> None: """Initialize the device.""" super().__init__(area_name, lutron_device, controller) + self._keypad = keypad device_uuid = keypad.uuid or keypad.legacy_uuid - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{controller.guid}_{device_uuid}")}, - manufacturer="Lutron", - name=keypad.name, - ) + identifiers = {(DOMAIN, f"{controller.guid}_{device_uuid}")} + # The main repeater keypad is the controller device itself, so it owns + # the controller identifier instead of linking to it via a via device. if keypad.type == "MAIN_REPEATER": - self._attr_device_info[ATTR_IDENTIFIERS].add((DOMAIN, controller.guid)) + identifiers.add((DOMAIN, controller.guid)) + self._attr_device_info = DeviceInfo( + identifiers=identifiers, + manufacturer="Lutron", + name=keypad.name, + ) else: - self._attr_device_info[ATTR_VIA_DEVICE] = (DOMAIN, controller.guid) + self._attr_device_info = DeviceInfo( + identifiers=identifiers, + manufacturer="Lutron", + name=keypad.name, + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, controller.guid), config_entry_id=config_entry_id + ), + ) diff --git a/homeassistant/components/lutron/event.py b/homeassistant/components/lutron/event.py index e7e86799507d..b9b086eb0bd2 100644 --- a/homeassistant/components/lutron/event.py +++ b/homeassistant/components/lutron/event.py @@ -39,7 +39,9 @@ async def async_setup_entry( entry_data = config_entry.runtime_data async_add_entities( - LutronEventEntity(area_name, keypad, button, entry_data.client) + LutronEventEntity( + hass, area_name, keypad, button, entry_data.client, config_entry.entry_id + ) for area_name, keypad, button in entry_data.buttons ) @@ -51,13 +53,15 @@ class LutronEventEntity(LutronKeypad, EventEntity): def __init__( self, + hass: HomeAssistant, area_name: str, keypad: Keypad, button: Button, controller: Lutron, + config_entry_id: str, ) -> None: """Initialize the button.""" - super().__init__(area_name, button, controller, keypad) + super().__init__(hass, area_name, button, controller, keypad, config_entry_id) if (name := button.name) == "Unknown Button": name += f" {button.number}" self._attr_name = name diff --git a/homeassistant/components/lutron/fan.py b/homeassistant/components/lutron/fan.py index 5c7c41f4b3e8..057a74e03780 100644 --- a/homeassistant/components/lutron/fan.py +++ b/homeassistant/components/lutron/fan.py @@ -25,7 +25,7 @@ async def async_setup_entry( entry_data = config_entry.runtime_data async_add_entities( [ - LutronFan(area_name, device, entry_data.client) + LutronFan(hass, area_name, device, entry_data.client, config_entry.entry_id) for area_name, device in entry_data.fans ], True, diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index 9b708815e70d..49681392331c 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -36,7 +36,7 @@ async def async_setup_entry( async_add_entities( ( - LutronLight(area_name, device, entry_data.client, config_entry) + LutronLight(hass, area_name, device, entry_data.client, config_entry) for area_name, device in entry_data.lights ), True, @@ -65,13 +65,16 @@ class LutronLight(LutronDevice, LightEntity): def __init__( self, + hass: HomeAssistant, area_name: str, lutron_device: LutronEntity, controller: Lutron, config_entry: ConfigEntry, ) -> None: """Initialize the device.""" - super().__init__(area_name, lutron_device, controller) + super().__init__( + hass, area_name, lutron_device, controller, config_entry.entry_id + ) self._config_entry = config_entry @override diff --git a/homeassistant/components/lutron/scene.py b/homeassistant/components/lutron/scene.py index 46b1b8f90495..c0797c0e1e66 100644 --- a/homeassistant/components/lutron/scene.py +++ b/homeassistant/components/lutron/scene.py @@ -25,7 +25,9 @@ async def async_setup_entry( entry_data = config_entry.runtime_data async_add_entities( - LutronScene(area_name, keypad, device, entry_data.client) + LutronScene( + hass, area_name, keypad, device, entry_data.client, config_entry.entry_id + ) for area_name, keypad, device, led in entry_data.scenes ) @@ -37,13 +39,17 @@ class LutronScene(LutronKeypad, Scene): def __init__( self, + hass: HomeAssistant, area_name: str, keypad: Keypad, lutron_device: Button, controller: Lutron, + config_entry_id: str, ) -> None: """Initialize the scene/button.""" - super().__init__(area_name, lutron_device, controller, keypad) + super().__init__( + hass, area_name, lutron_device, controller, keypad, config_entry_id + ) self._attr_name = lutron_device.name @override diff --git a/homeassistant/components/lutron/select.py b/homeassistant/components/lutron/select.py index e70de30fcbb9..be448b42a2af 100644 --- a/homeassistant/components/lutron/select.py +++ b/homeassistant/components/lutron/select.py @@ -32,7 +32,15 @@ async def async_setup_entry( # Add the indicator LEDs for scenes (keypad buttons) async_add_entities( [ - LutronLedSelect(area_name, keypad, scene, led, entry_data.client) + LutronLedSelect( + hass, + area_name, + keypad, + scene, + led, + entry_data.client, + config_entry.entry_id, + ) for area_name, keypad, scene, led in entry_data.scenes if led is not None ], @@ -49,14 +57,18 @@ class LutronLedSelect(LutronKeypad, SelectEntity): def __init__( self, + hass: HomeAssistant, area_name: str, keypad: Keypad, scene_device: Button, led_device: Led, controller: Lutron, + config_entry_id: str, ) -> None: """Initialize the select entity.""" - super().__init__(area_name, led_device, controller, keypad) + super().__init__( + hass, area_name, led_device, controller, keypad, config_entry_id + ) self._attr_name = f"{scene_device.name} LED" @property diff --git a/homeassistant/components/lutron/switch.py b/homeassistant/components/lutron/switch.py index 245270628b5a..0feaaed64cf6 100644 --- a/homeassistant/components/lutron/switch.py +++ b/homeassistant/components/lutron/switch.py @@ -28,12 +28,26 @@ async def async_setup_entry( # Add Lutron Switches for area_name, device in entry_data.switches: - entities.append(LutronSwitch(area_name, device, entry_data.client)) + entities.append( + LutronSwitch( + hass, area_name, device, entry_data.client, config_entry.entry_id + ) + ) # Add the indicator LEDs for scenes (keypad buttons) for area_name, keypad, scene, led in entry_data.scenes: if led is not None: - entities.append(LutronLed(area_name, keypad, scene, led, entry_data.client)) + entities.append( + LutronLed( + hass, + area_name, + keypad, + scene, + led, + entry_data.client, + config_entry.entry_id, + ) + ) async_add_entities(entities, True) @@ -77,14 +91,18 @@ class LutronLed(LutronKeypad, SwitchEntity): def __init__( self, + hass: HomeAssistant, area_name: str, keypad: Keypad, scene_device: Button, led_device: Led, controller: Lutron, + config_entry_id: str, ) -> None: """Initialize the switch.""" - super().__init__(area_name, led_device, controller, keypad) + super().__init__( + hass, area_name, led_device, controller, keypad, config_entry_id + ) self._keypad_name = keypad.name self._attr_name = scene_device.name diff --git a/tests/components/lutron/test_init.py b/tests/components/lutron/test_init.py index e06dcb2404b2..b483cee9acd6 100644 --- a/tests/components/lutron/test_init.py +++ b/tests/components/lutron/test_init.py @@ -239,3 +239,69 @@ async def test_keypad_integer_to_uuid_migration( device = device_registry.async_get_device(identifiers={(DOMAIN, new_unique_id)}) assert device is not None assert device.name == "Test Keypad" + + +async def test_via_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_lutron: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that devices are linked to the main repeater via via_device_id.""" + mock_config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + controller_guid = mock_lutron.guid + repeater_device = device_registry.async_get_device_by_identifier( + (DOMAIN, controller_guid), mock_config_entry.entry_id + ) + assert repeater_device is not None + assert repeater_device.via_device_id is None + + light = mock_lutron.areas[0].outputs[0] + light_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{controller_guid}_{light.uuid}"), mock_config_entry.entry_id + ) + assert light_device is not None + assert light_device.via_device_id == repeater_device.id + + # The mocked keypad is not the main repeater's own keypad, so it links to + # the repeater via via_device_id like any other device. + keypad = mock_lutron.areas[0].keypads[0] + keypad_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{controller_guid}_{keypad.uuid}"), mock_config_entry.entry_id + ) + assert keypad_device is not None + assert keypad_device.via_device_id == repeater_device.id + + +async def test_via_device_id_main_repeater_keypad( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_lutron: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the main repeater's own keypad stays the root device.""" + mock_config_entry.add_to_hass(hass) + mock_lutron.areas[0].keypads[0].type = "MAIN_REPEATER" + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + controller_guid = mock_lutron.guid + repeater_device = device_registry.async_get_device_by_identifier( + (DOMAIN, controller_guid), mock_config_entry.entry_id + ) + assert repeater_device is not None + assert repeater_device.via_device_id is None + + keypad = mock_lutron.areas[0].keypads[0] + keypad_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{controller_guid}_{keypad.uuid}"), mock_config_entry.entry_id + ) + # A MAIN_REPEATER keypad shares the controller's identifier, so it + # resolves to the same root device instead of a separate child device. + assert keypad_device is not None + assert keypad_device.id == repeater_device.id From d345d37aa2be6d3bef65042374bd9af56ae1b43a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 16:57:56 +0200 Subject: [PATCH 080/109] Fix via_device race in habitica (#177718) Co-authored-by: Markus Tuominen <3738613+Markus98@users.noreply.github.com> --- homeassistant/components/habitica/__init__.py | 38 ++++++++++++++++++- homeassistant/components/habitica/entity.py | 23 ++++++----- tests/components/habitica/test_init.py | 37 ++++++++++++++++++ 3 files changed, 88 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index 4b4db64374c5..aa5fb872c875 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -1,8 +1,10 @@ """The habitica integration.""" +from typing import TYPE_CHECKING from uuid import UUID from habiticalib import Habitica +from yarl import URL from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform @@ -16,7 +18,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey -from .const import CONF_API_USER, DOMAIN, X_CLIENT +from .const import CONF_API_USER, DOMAIN, MANUFACTURER, NAME, X_CLIENT from .coordinator import ( HabiticaConfigEntry, HabiticaDataUpdateCoordinator, @@ -71,6 +73,23 @@ async def async_setup_entry( config_entry.runtime_data = coordinator + if TYPE_CHECKING: + assert config_entry.unique_id + + # Register the user device so children (party device) can resolve it + # deterministically as their via_device parent before platforms are set up. + device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + entry_type=dr.DeviceEntryType.SERVICE, + manufacturer=MANUFACTURER, + model=NAME, + name=coordinator.data.user.profile.name, + configuration_url=( + URL(config_entry.data[CONF_URL]) / "profile" / config_entry.unique_id + ), + identifiers={(DOMAIN, config_entry.unique_id)}, + ) + party = coordinator.data.user.party.id hass.data.setdefault(HABITICA_KEY, {}) @@ -81,6 +100,23 @@ async def async_setup_entry( hass.data[HABITICA_KEY][party] = party_coordinator party_added_by_this_entry = party + if party is not None: + # Register the party device so party member devices can resolve it + # deterministically as their via_device parent. + device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + entry_type=dr.DeviceEntryType.SERVICE, + manufacturer=MANUFACTURER, + model=NAME, + name=hass.data[HABITICA_KEY][party].data.party.summary, + identifiers={(DOMAIN, f"{config_entry.unique_id}_{party!s}")}, + via_device_id=dr.async_get_device_id_by_identifier( + hass, + (DOMAIN, config_entry.unique_id), + config_entry_id=config_entry.entry_id, + ), + ) + @callback def _party_update_listener() -> None: """On party change, unload coordinator, remove device and reload.""" diff --git a/homeassistant/components/habitica/entity.py b/homeassistant/components/habitica/entity.py index 7857c7ed6081..c031f7d4de34 100644 --- a/homeassistant/components/habitica/entity.py +++ b/homeassistant/components/habitica/entity.py @@ -8,6 +8,7 @@ from yarl import URL from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_URL +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -57,14 +58,14 @@ class HabiticaBase(CoordinatorEntity[HabiticaDataUpdateCoordinator]): ) if subentry: - self._attr_device_info.update( - DeviceInfo( - via_device=( - ( - DOMAIN, - f"{coordinator.config_entry.unique_id}_{self.user.party.id}", - ) - ) + self._attr_device_info["via_device_id"] = ( + dr.async_get_device_id_by_identifier( + coordinator.hass, + ( + DOMAIN, + f"{coordinator.config_entry.unique_id}_{self.user.party.id}", + ), + config_entry_id=coordinator.config_entry.entry_id, ) ) @@ -138,6 +139,10 @@ class HabiticaPartyBase(CoordinatorEntity[HabiticaPartyCoordinator]): model=NAME, name=coordinator.data.party.summary, identifiers={(DOMAIN, unique_id)}, - via_device=(DOMAIN, config_entry.unique_id), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, config_entry.unique_id), + config_entry_id=config_entry.entry_id, + ), ) self.content = content diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index 469197b54b15..38c2840bf9ad 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -179,3 +179,40 @@ async def test_remove_party_and_reload( hass.states.get("notify.test_user_private_message_test_partymember_displayname") is None ) + + +@pytest.mark.usefixtures("habitica") +async def test_device_via_device_links( + hass: HomeAssistant, + config_entry_with_subentry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the via_device links between user, party and party member devices.""" + group_id = "1e87097c-4c03-4f8c-a475-67cc7da7f409" + member_id = "ffce870c-3ff3-4fa4-bad1-87612e52b8e7" + + config_entry_with_subentry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_with_subentry.entry_id) + + assert config_entry_with_subentry.state is ConfigEntryState.LOADED + + unique_id = config_entry_with_subentry.unique_id + entry_id = config_entry_with_subentry.entry_id + + user_device = device_registry.async_get_device_by_identifier( + (DOMAIN, unique_id), entry_id + ) + assert user_device is not None + assert user_device.via_device_id is None + + party_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{unique_id}_{group_id}"), entry_id + ) + assert party_device is not None + assert party_device.via_device_id == user_device.id + + member_device = device_registry.async_get_device_by_identifier( + (DOMAIN, member_id), entry_id + ) + assert member_device is not None + assert member_device.via_device_id == party_device.id From 015c48556ffa4c664b536309ab074fa72ce80372 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 3 Aug 2026 18:21:00 +0300 Subject: [PATCH 081/109] Bump aiowebostv to 0.9.0 (#178091) --- homeassistant/components/webostv/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 241483ad4a54..56be93a8e443 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiowebostv"], "quality_scale": "platinum", - "requirements": ["aiowebostv==0.8.0"], + "requirements": ["aiowebostv==0.9.0"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index 4acaae7e9a3a..90b70f8be763 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -471,7 +471,7 @@ aiowatttime==0.1.1 aiowebdav2==0.6.2 # homeassistant.components.webostv -aiowebostv==0.8.0 +aiowebostv==0.9.0 # homeassistant.components.withings aiowithings==3.1.6 From d3b40afcf0b945311f7e9b692ba703a71a682a58 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 17:25:23 +0200 Subject: [PATCH 082/109] Migrate ring dhcp flow to call async_get_devices (#177976) --- homeassistant/components/ring/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index b33459570a2c..396e39f9b22c 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -90,7 +90,7 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() if self.hass.config_entries.async_has_entries(DOMAIN): device_registry = dr.async_get(self.hass) - if device_registry.async_get_device( + if device_registry.async_get_devices( identifiers={(DOMAIN, discovery_info.macaddress)} ): return self.async_abort(reason="already_configured") From 6b6e1230c6a4e0aa0bd4d9749c0a4cab9d0be7b7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 17:28:44 +0200 Subject: [PATCH 083/109] Migrate onvif dhcp flow to call async_get_devices (#177975) --- homeassistant/components/onvif/config_flow.py | 12 ++-- tests/components/onvif/test_config_flow.py | 67 ++++++++++++++++++- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index 3e4b98091605..931d130cd1ea 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -186,14 +186,16 @@ class OnvifFlowHandler(ConfigFlow, domain=DOMAIN): mac = discovery_info.macaddress registry = dr.async_get(self.hass) if not ( - device := registry.async_get_device( + devices := registry.async_get_devices( connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) ): return self.async_abort(reason="no_devices_found") - for entry_id in device.config_entries: + for device in devices: if ( - not (entry := hass.config_entries.async_get_entry(entry_id)) + not ( + entry := hass.config_entries.async_get_entry(device.config_entry_id) + ) or entry.domain != DOMAIN or entry.state is ConfigEntryState.LOADED ): @@ -201,7 +203,9 @@ class OnvifFlowHandler(ConfigFlow, domain=DOMAIN): if hass.config_entries.async_update_entry( entry, data=entry.data | {CONF_HOST: discovery_info.ip} ): - hass.async_create_task(self.hass.config_entries.async_reload(entry_id)) + hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id) + ) return self.async_abort(reason="already_configured") async def async_step_device( diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index 2030cd7e3bf7..54d17eba5059 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -1,7 +1,7 @@ """Test ONVIF config flow.""" import logging -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -28,6 +28,8 @@ from . import ( setup_onvif_integration, ) +from tests.common import MockConfigEntry + DISCOVERY = [ { "EPR": URN, @@ -759,6 +761,69 @@ async def test_discovered_by_dhcp_does_not_update_if_no_matching_entry( assert result["reason"] == "no_devices_found" +async def test_discovered_by_dhcp_updates_all_matching_onvif_entries( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test dhcp updates every matching ONVIF entry and skips other domains. + + A MAC can be shared by several registry devices, one per config entry. The flow + must update the host of every ONVIF config entry owning such a device and request + a reload for it, while leaving devices owned by other domains untouched. + """ + onvif_entry_1 = MockConfigEntry( + domain=DOMAIN, + unique_id=MAC, + data={CONF_HOST: "1.2.3.4"}, + entry_id="onvif1", + ) + onvif_entry_1.add_to_hass(hass) + onvif_entry_2 = MockConfigEntry( + domain=DOMAIN, + unique_id="aa:bb:cc:dd:ee:00", + data={CONF_HOST: "2.3.4.5"}, + entry_id="onvif2", + ) + onvif_entry_2.add_to_hass(hass) + other_entry = MockConfigEntry( + domain="other_domain", + data={CONF_HOST: "9.9.9.9"}, + entry_id="other", + ) + other_entry.add_to_hass(hass) + + connections = {(dr.CONNECTION_NETWORK_MAC, MAC)} + device_registry.async_get_or_create( + config_entry_id=onvif_entry_1.entry_id, connections=connections + ) + device_registry.async_get_or_create( + config_entry_id=onvif_entry_2.entry_id, connections=connections + ) + device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, connections=connections + ) + assert len(device_registry.async_get_devices(connections=connections)) == 3 + + with patch.object( + hass.config_entries, "async_reload", new_callable=AsyncMock + ) as mock_reload: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + # Both matching ONVIF entries are updated to the discovered host and reloaded. + assert onvif_entry_1.data[CONF_HOST] == DHCP_DISCOVERY.ip + assert onvif_entry_2.data[CONF_HOST] == DHCP_DISCOVERY.ip + assert {call.args[0] for call in mock_reload.call_args_list} == { + onvif_entry_1.entry_id, + onvif_entry_2.entry_id, + } + # The device owned by another domain is left untouched. + assert other_entry.data[CONF_HOST] == "9.9.9.9" + + def _get_schema_default(schema, key_name): """Iterate schema to find a key.""" for schema_key in schema: From e5cb2a992c954c4d7c0d213db2f3b6024e8d821c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 17:28:58 +0200 Subject: [PATCH 084/109] Fix via_device race in fibaro (#177868) --- homeassistant/components/fibaro/__init__.py | 9 ++++-- tests/components/fibaro/test_init.py | 33 ++++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 0bba79975973..b57d98b2d6a9 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -186,9 +186,13 @@ class FibaroController: identifiers={(DOMAIN, main_device.fibaro_id)}, manufacturer=manufacturer, name=main_device.name, - via_device=(DOMAIN, self.hub_serial), ) + def link_main_devices_to_hub(self, via_device_id: str) -> None: + """Link all main devices to the hub via device.""" + for device_info in self._device_infos.values(): + device_info["via_device_id"] = via_device_id + def get_device_info(self, device: DeviceModel) -> DeviceInfo: """Get the device info by fibaro device id.""" if device.fibaro_id in self._device_infos: @@ -321,7 +325,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bo # register the hub device info separately as the hub has sometimes no entities fibaro_info = controller.read_fibaro_info() device_registry = dr.async_get(hass) - device_registry.async_get_or_create( + hub_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, controller.hub_serial)}, serial_number=controller.hub_serial, @@ -332,6 +336,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bo configuration_url=controller.get_frontend_url(), connections={(dr.CONNECTION_NETWORK_MAC, fibaro_info.mac_address)}, ) + controller.link_main_devices_to_hub(hub_device.id) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/tests/components/fibaro/test_init.py b/tests/components/fibaro/test_init.py index 330de74d6af4..e9adfa72c5ce 100644 --- a/tests/components/fibaro/test_init.py +++ b/tests/components/fibaro/test_init.py @@ -2,10 +2,12 @@ from unittest.mock import Mock, patch +from homeassistant.components.fibaro.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr -from .conftest import init_integration +from .conftest import TEST_SERIALNUMBER, init_integration from tests.common import MockConfigEntry @@ -29,3 +31,32 @@ async def test_unload_integration( await hass.async_block_till_done() # Assert assert mock_fibaro_client.unregister_update_handler.call_count == 1 + + +async def test_load_integration_links_device_to_hub( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_light: Mock, + mock_room: Mock, +) -> None: + """Test load integration sets via_device_id for a light device to the hub device.""" + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_light] + + # Act + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, mock_config_entry) + + # Assert + hub_device = device_registry.async_get_device_by_identifier( + (DOMAIN, TEST_SERIALNUMBER), mock_config_entry.entry_id + ) + light_device = device_registry.async_get_device_by_identifier( + (DOMAIN, mock_light.fibaro_id), mock_config_entry.entry_id + ) + assert hub_device is not None + assert light_device is not None + assert light_device.via_device_id == hub_device.id From dc9837a95fbf49c55770da3f75de1b7fd627b921 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 17:29:12 +0200 Subject: [PATCH 085/109] Fix via_device race in homematicip_cloud (#177867) --- .../components/homematicip_cloud/__init__.py | 19 +---------- .../homematicip_cloud/alarm_control_panel.py | 11 ++++-- .../components/homematicip_cloud/climate.py | 11 ++++-- .../components/homematicip_cloud/entity.py | 10 ++++-- .../components/homematicip_cloud/hap.py | 30 +++++++++++++++- .../homematicip_cloud/test_device.py | 34 +++++++++++++++++++ .../components/homematicip_cloud/test_hap.py | 13 +++++-- 7 files changed, 100 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 46934718b7ef..45656d0b8325 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -7,11 +7,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - entity_registry as er, -) +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType from .const import ( @@ -95,19 +91,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomematicIPConfigEntry) EVENT_HOMEASSISTANT_STOP, hap.shutdown ) - # Register hap as device in registry. - device_registry = dr.async_get(hass) - - home = hap.home - hapname = home.label if home.label != entry.unique_id else f"Home-{home.label}" - - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, home.id)}, - manufacturer="eQ-3", - # Add the name from config entry. - name=hapname, - ) return True diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index 5d2f168967d9..8710673bf9cd 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -1,7 +1,7 @@ """Support for HomematicIP Cloud alarm control panel.""" import logging -from typing import override +from typing import TYPE_CHECKING, override from homematicip.functionalHomes import SecurityAndAlarmHome @@ -11,6 +11,7 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -51,12 +52,18 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity): @override def device_info(self) -> DeviceInfo: """Return device specific attributes.""" + if TYPE_CHECKING: + assert self.platform.config_entry is not None return DeviceInfo( identifiers={(DOMAIN, f"ACP {self._home.id}")}, manufacturer="eQ-3", model=CONST_ALARM_CONTROL_PANEL_NAME, name=self.name, - via_device=(DOMAIN, self._home.id), + via_device_id=dr.async_get_device_id_by_identifier( + self.hass, + (DOMAIN, self._home.id), + config_entry_id=self.platform.config_entry.entry_id, + ), ) @property diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 8d3855ecd654..d84a0d833871 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -1,6 +1,6 @@ """Support for HomematicIP Cloud climate devices.""" -from typing import Any, override +from typing import TYPE_CHECKING, Any, override from homematicip.base.enums import AbsenceType from homematicip.device import ( @@ -24,6 +24,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -104,12 +105,18 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): @override def device_info(self) -> DeviceInfo: """Return device specific attributes.""" + if TYPE_CHECKING: + assert self.platform.config_entry is not None return DeviceInfo( identifiers={(DOMAIN, self._device.id)}, manufacturer="eQ-3", model=self._device.modelType, name=self._device.label, - via_device=(DOMAIN, self._device.homeId), + via_device_id=dr.async_get_device_id_by_identifier( + self.hass, + (DOMAIN, self._device.homeId), + config_entry_id=self.platform.config_entry.entry_id, + ), ) @property diff --git a/homeassistant/components/homematicip_cloud/entity.py b/homeassistant/components/homematicip_cloud/entity.py index 5e2947b902fe..f5e56c2715a6 100644 --- a/homeassistant/components/homematicip_cloud/entity.py +++ b/homeassistant/components/homematicip_cloud/entity.py @@ -2,7 +2,7 @@ import contextlib import logging -from typing import Any, override +from typing import TYPE_CHECKING, Any, override from homematicip.base.functionalChannels import FunctionalChannel from homematicip.device import Device @@ -145,6 +145,8 @@ class HomematicipGenericEntity(Entity): if device_name and home_name: device_name = f"{home_name} {device_name}" + if TYPE_CHECKING: + assert self.platform.config_entry is not None return DeviceInfo( identifiers={ # Serial numbers of Homematic IP device @@ -155,7 +157,11 @@ class HomematicipGenericEntity(Entity): name=device_name, sw_version=self._device.firmwareVersion, # Link to the homematic ip access point. - via_device=(DOMAIN, home_id), + via_device_id=dr.async_get_device_id_by_identifier( + self.hass, + (DOMAIN, home_id), + config_entry_id=self.platform.config_entry.entry_id, + ), ) return None diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index 65da63cd344f..ad0699f5d89a 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -19,9 +19,17 @@ import homeassistant from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.httpx_client import get_async_client -from .const import HMIPC_AUTHTOKEN, HMIPC_HAPID, HMIPC_NAME, HMIPC_PIN, PLATFORMS +from .const import ( + DOMAIN, + HMIPC_AUTHTOKEN, + HMIPC_HAPID, + HMIPC_NAME, + HMIPC_PIN, + PLATFORMS, +) from .errors import HmipcConnectionError _LOGGER = logging.getLogger(__name__) @@ -138,12 +146,32 @@ class HomematicipHAP: "Connected to HomematicIP with HAP %s", self.config_entry.unique_id ) + # Register the access point as a device before forwarding platforms so + # child devices can resolve it as their via_device. + self._async_register_access_point() + await self.hass.config_entries.async_forward_entry_setups( self.config_entry, PLATFORMS ) return True + @callback + def _async_register_access_point(self) -> None: + """Register the access point as a device in the device registry.""" + home = self.home + hapname = ( + home.label + if home.label != self.config_entry.unique_id + else f"Home-{home.label}" + ) + dr.async_get(self.hass).async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, home.id)}, + manufacturer="eQ-3", + name=hapname, + ) + @callback def async_update(self, *args, **kwargs) -> None: """Async update the home device. diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 49d7f800ea5c..0dc457147e3d 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -4,6 +4,7 @@ from unittest.mock import patch from homematicip.base.enums import EventType +from homeassistant.components.homematicip_cloud import DOMAIN from homeassistant.components.homematicip_cloud.hap import HomematicipHAP from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -296,3 +297,36 @@ async def test_hmip_multi_area_device( # get the hap hap_device = device_registry.async_get(device.via_device_id) assert hap_device.name == "Home" + + +async def test_hmip_child_device_links_to_access_point( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + default_mock_hap_factory: HomeFactory, +) -> None: + """Test a child device links back to the access point via via_device_id.""" + entity_id = "light.treppe_ch" + entity_name = "Treppe CH" + device_model = "HmIP-BSL" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Treppe"] + ) + + ha_state, _hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + assert ha_state + + entity = entity_registry.async_get(entity_id) + assert entity is not None + + child_device = device_registry.async_get(entity.device_id) + assert child_device is not None + + access_point_device = device_registry.async_get_device_by_identifier( + (DOMAIN, mock_hap.home.id), mock_hap.config_entry.entry_id + ) + assert access_point_device is not None + + assert child_device.via_device_id == access_point_device.id diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index 85a73db8fdeb..1fa4b859097e 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -91,9 +91,13 @@ async def test_hap_setup_works(hass: HomeAssistant) -> None: domain=DOMAIN, data={HMIPC_HAPID: "ABC123", HMIPC_AUTHTOKEN: "123", HMIPC_NAME: "hmip"}, ) - home = Mock() + entry.add_to_hass(hass) + home = Mock(id="ABC123", label="Home") hap = HomematicipHAP(hass, entry) - with patch.object(hap, "get_hap", return_value=home): + with ( + patch.object(hap, "get_hap", return_value=home), + patch.object(hass.config_entries, "async_forward_entry_setups"), + ): async with entry.setup_lock: assert await hap.async_setup() @@ -133,11 +137,13 @@ async def test_hap_reset_unloads_entry_if_setup( assert config_entries[0].state is ConfigEntryState.NOT_LOADED +@pytest.mark.usefixtures("simple_mock_home") async def test_hap_create( - hass: HomeAssistant, hmip_config_entry: MockConfigEntry, simple_mock_home + hass: HomeAssistant, hmip_config_entry: MockConfigEntry ) -> None: """Mock AsyncHome to execute get_hap.""" hass.config.components.add(DOMAIN) + hmip_config_entry.add_to_hass(hass) hap = HomematicipHAP(hass, hmip_config_entry) assert hap with ( @@ -146,6 +152,7 @@ async def test_hap_create( return_value=ConnectionContext(), ), patch.object(hap, "async_connect"), + patch.object(hass.config_entries, "async_forward_entry_setups"), ): async with hmip_config_entry.setup_lock: assert await hap.async_setup() From 27625a946b8ea5dc4982b10c4e24b4f0288e3fc3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 17:29:42 +0200 Subject: [PATCH 086/109] Adapt nobo_hub to set via_device_id in DeviceInfo (#177737) --- homeassistant/components/nobo_hub/climate.py | 24 ++++++++++------ homeassistant/components/nobo_hub/entity.py | 10 +++++-- homeassistant/components/nobo_hub/select.py | 30 +++++++++++++------- homeassistant/components/nobo_hub/sensor.py | 11 ++++--- tests/components/nobo_hub/test_init.py | 28 ++++++++++++++++++ 5 files changed, 79 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index 83c9a9966e03..40dc0ba20d71 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -29,7 +29,6 @@ from homeassistant.util import dt as dt_util from . import NoboHubConfigEntry from .const import ( - ATTR_SERIAL, ATTR_TEMP_COMFORT_C, ATTR_TEMP_ECO_C, CONF_OVERRIDE_TYPE, @@ -78,7 +77,8 @@ async def async_setup_entry( new_zones = [zone_id for zone_id in hub.zones if zone_id not in known_zones] known_zones.update(new_zones) async_add_entities( - NoboZone(zone_id, hub, override_type) for zone_id in new_zones + NoboZone(hass, zone_id, hub, override_type, config_entry.entry_id) + for zone_id in new_zones ) _add_zones(hub) @@ -106,17 +106,25 @@ class NoboZone(NoboBaseEntity, ClimateEntity): # Need to poll to get preset change when in HVACMode.AUTO _attr_should_poll = True - def __init__(self, zone_id: str, hub: nobo, override_type: str) -> None: + def __init__( + self, + hass: HomeAssistant, + zone_id: str, + hub: nobo, + override_type: str, + entry_id: str, + ) -> None: """Initialize the climate device.""" - super().__init__(hub) + super().__init__(hass, hub, entry_id) self._id = zone_id self._attr_unique_id = f"{hub.hub_serial}:{zone_id}" self._override_type = override_type + zone_name = hub.zones[zone_id][ATTR_NAME] self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{hub.hub_serial}:{zone_id}")}, - name=hub.zones[zone_id][ATTR_NAME], - via_device=(DOMAIN, hub.hub_info[ATTR_SERIAL]), - suggested_area=hub.zones[zone_id][ATTR_NAME], + identifiers={(DOMAIN, self._attr_unique_id)}, + name=zone_name, + via_device_id=self._hub_device_id, + suggested_area=zone_name, ) self._read_state() diff --git a/homeassistant/components/nobo_hub/entity.py b/homeassistant/components/nobo_hub/entity.py index 7ad26edb40bf..4620b9c0596e 100644 --- a/homeassistant/components/nobo_hub/entity.py +++ b/homeassistant/components/nobo_hub/entity.py @@ -4,9 +4,12 @@ from typing import override from pynobo import nobo -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity +from .const import ATTR_SERIAL, DOMAIN + class NoboBaseEntity(Entity): """Base class for Nobø Ecohub entities.""" @@ -14,10 +17,13 @@ class NoboBaseEntity(Entity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, hub: nobo) -> None: + def __init__(self, hass: HomeAssistant, hub: nobo, entry_id: str) -> None: """Initialize the entity.""" self._nobo = hub self._attr_available = hub.connected + self._hub_device_id = dr.async_get_device_id_by_identifier( + hass, (DOMAIN, hub.hub_info[ATTR_SERIAL]), config_entry_id=entry_id + ) @override async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index ef6f7f29f365..3473bf57b064 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -14,7 +14,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import NoboHubConfigEntry from .const import ( ATTR_HARDWARE_VERSION, - ATTR_SERIAL, ATTR_SOFTWARE_VERSION, CONF_OVERRIDE_TYPE, DOMAIN, @@ -40,7 +39,9 @@ async def async_setup_entry( else nobo.API.OVERRIDE_TYPE_CONSTANT ) - async_add_entities([NoboGlobalSelector(hub, override_type)], True) + async_add_entities( + [NoboGlobalSelector(hass, hub, override_type, config_entry.entry_id)], True + ) known_zones: set[str] = set() @@ -56,7 +57,11 @@ async def async_setup_entry( new_zones = [zone_id for zone_id in hub.zones if zone_id not in known_zones] known_zones.update(new_zones) async_add_entities( - (NoboProfileSelector(zone_id, hub) for zone_id in new_zones), True + ( + NoboProfileSelector(hass, zone_id, hub, config_entry.entry_id) + for zone_id in new_zones + ), + True, ) _add_profiles(hub) @@ -77,9 +82,11 @@ class NoboGlobalSelector(NoboBaseEntity, SelectEntity): _attr_options = list(_modes.values()) _attr_current_option: str | None = None - def __init__(self, hub: nobo, override_type: str) -> None: + def __init__( + self, hass: HomeAssistant, hub: nobo, override_type: str, entry_id: str + ) -> None: """Initialize the global override selector.""" - super().__init__(hub) + super().__init__(hass, hub, entry_id) self._attr_unique_id = hub.hub_serial self._override_type = override_type self._attr_device_info = DeviceInfo( @@ -126,18 +133,21 @@ class NoboProfileSelector(NoboBaseEntity, SelectEntity): _attr_translation_key = "week_profile" _attr_current_option: str | None = None - def __init__(self, zone_id: str, hub: nobo) -> None: + def __init__( + self, hass: HomeAssistant, zone_id: str, hub: nobo, entry_id: str + ) -> None: """Initialize the week profile selector.""" - super().__init__(hub) + super().__init__(hass, hub, entry_id) self._id = zone_id self._profiles: dict[str, str] = {} self._attr_options: list[str] = [] self._attr_unique_id = f"{hub.hub_serial}:{zone_id}:profile" + zone_name = hub.zones[zone_id][ATTR_NAME] self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{hub.hub_serial}:{zone_id}")}, - name=hub.zones[zone_id][ATTR_NAME], - via_device=(DOMAIN, hub.hub_info[ATTR_SERIAL]), - suggested_area=hub.zones[zone_id][ATTR_NAME], + name=zone_name, + via_device_id=self._hub_device_id, + suggested_area=zone_name, ) @override diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index 371fa96e6823..885c80b40f06 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -48,7 +48,8 @@ async def async_setup_entry( ] known_components.update(new_components) async_add_entities( - NoboTemperatureSensor(serial, hub) for serial in new_components + NoboTemperatureSensor(hass, serial, hub, config_entry.entry_id) + for serial in new_components ) _add_sensors(hub) @@ -64,9 +65,11 @@ class NoboTemperatureSensor(NoboBaseEntity, SensorEntity): _attr_state_class = SensorStateClass.MEASUREMENT _attr_suggested_display_precision = 1 - def __init__(self, serial: str, hub: nobo) -> None: + def __init__( + self, hass: HomeAssistant, serial: str, hub: nobo, entry_id: str + ) -> None: """Initialize the temperature sensor.""" - super().__init__(hub) + super().__init__(hass, hub, entry_id) self._temperature: StateType = None self._id = serial component = hub.components[self._id] @@ -81,7 +84,7 @@ class NoboTemperatureSensor(NoboBaseEntity, SensorEntity): name=component[ATTR_NAME], manufacturer=NOBO_MANUFACTURER, model=component[ATTR_MODEL].name, - via_device=(DOMAIN, hub.hub_info[ATTR_SERIAL]), + via_device_id=self._hub_device_id, suggested_area=suggested_area, ) self._read_state() diff --git a/tests/components/nobo_hub/test_init.py b/tests/components/nobo_hub/test_init.py index f89c50b5bffc..e36da7d6859f 100644 --- a/tests/components/nobo_hub/test_init.py +++ b/tests/components/nobo_hub/test_init.py @@ -262,6 +262,34 @@ async def test_setup_registers_hub_device_with_mac( } +@pytest.mark.parametrize( + "platforms", [[Platform.CLIMATE, Platform.SENSOR]], indirect=True +) +@pytest.mark.usefixtures("init_integration") +async def test_zone_and_component_devices_link_to_hub( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Zone and component devices reference the hub device via via_device_id.""" + entry_id = mock_config_entry.entry_id + hub_device = device_registry.async_get_device_by_identifier( + (DOMAIN, SERIAL), entry_id + ) + assert hub_device is not None + + zone_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{SERIAL}:1"), entry_id + ) + assert zone_device is not None + assert zone_device.via_device_id == hub_device.id + + component_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "200000059091"), entry_id + ) + assert component_device is not None + assert component_device.via_device_id == hub_device.id + + @pytest.mark.usefixtures("init_integration") async def test_entity_available_when_hub_connected(hass: HomeAssistant) -> None: """Entities are available when the hub reports connected.""" From 8ec800df3e4fa3119f380dd73a92e0815e517623 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 17:30:00 +0200 Subject: [PATCH 087/109] Fix via_device race in yardian (#177859) --- homeassistant/components/yardian/__init__.py | 8 +++++++ homeassistant/components/yardian/entity.py | 7 +++++- tests/components/yardian/conftest.py | 7 ++++++ tests/components/yardian/test_init.py | 23 ++++++++++++++++++++ 4 files changed, 44 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/yardian/__init__.py b/homeassistant/components/yardian/__init__.py index b6b7ffcbef0d..3eb0db27b6fb 100644 --- a/homeassistant/components/yardian/__init__.py +++ b/homeassistant/components/yardian/__init__.py @@ -4,6 +4,7 @@ from pyyardian import AsyncYardianClient from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from .coordinator import YardianConfigEntry, YardianUpdateCoordinator @@ -33,6 +34,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: YardianConfigEntry) -> b entry.runtime_data = coordinator + # Register the main device so zone child devices can resolve it as their + # via_device regardless of platform setup order. + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + **coordinator.device_info, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/yardian/entity.py b/homeassistant/components/yardian/entity.py index a590d1cea714..e905937b6c5f 100644 --- a/homeassistant/components/yardian/entity.py +++ b/homeassistant/components/yardian/entity.py @@ -1,5 +1,6 @@ """Base entities for Yardian integration.""" +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -31,5 +32,9 @@ class YardianZoneEntity(CoordinatorEntity[YardianUpdateCoordinator]): identifiers={(DOMAIN, f"{coordinator.yid}_{zone_id}")}, name=coordinator.data.zones[zone_id].name, manufacturer=MANUFACTURER, - via_device=(DOMAIN, coordinator.yid), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.yid), + config_entry_id=coordinator.config_entry.entry_id, + ), ) diff --git a/tests/components/yardian/conftest.py b/tests/components/yardian/conftest.py index 45554f70735f..5d87a6072e08 100644 --- a/tests/components/yardian/conftest.py +++ b/tests/components/yardian/conftest.py @@ -81,6 +81,13 @@ def sensor_platform_only() -> Generator[None]: yield +@pytest.fixture +def switch_platform_only() -> Generator[None]: + """Limit the integration setup to the switch (child-only) platform.""" + with patch("homeassistant.components.yardian.PLATFORMS", [Platform.SWITCH]): + yield + + @pytest.fixture(autouse=True) def mock_ui_refresh_delay() -> Generator[None]: """Patch the Switch refresh delay to 0 seconds to speed up tests.""" diff --git a/tests/components/yardian/test_init.py b/tests/components/yardian/test_init.py index 0a675ebd6fe5..a3401ac1cfb5 100644 --- a/tests/components/yardian/test_init.py +++ b/tests/components/yardian/test_init.py @@ -5,8 +5,10 @@ from unittest.mock import AsyncMock import pytest from pyyardian import NetworkException, NotAuthorizedException +from homeassistant.components.yardian.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import setup_integration @@ -35,3 +37,24 @@ async def test_setup_unauthorized( await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is entry_state + + +@pytest.mark.usefixtures("mock_yardian_client", "switch_platform_only") +async def test_zone_device_via_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that zone child devices link to the main device via via_device_id.""" + await setup_integration(hass, mock_config_entry) + + main_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "yid123"), mock_config_entry.entry_id + ) + zone_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "yid123_0"), mock_config_entry.entry_id + ) + + assert main_device is not None + assert zone_device is not None + assert zone_device.via_device_id == main_device.id From bf69a1a85fbe2e40aece7bd2ac2264c15f902573 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 17:30:33 +0200 Subject: [PATCH 088/109] Adapt unifiprotect to set via_device_id in DeviceInfo (#177779) --- .../components/unifiprotect/__init__.py | 3 ++- homeassistant/components/unifiprotect/data.py | 3 +++ .../components/unifiprotect/entity.py | 2 +- .../components/unifiprotect/siren.py | 3 +-- .../components/unifiprotect/switch.py | 3 +-- tests/components/unifiprotect/test_init.py | 22 ++++++++++++++++ tests/components/unifiprotect/test_relay.py | 25 ++++++++++++++++++- tests/components/unifiprotect/test_siren.py | 24 +++++++++++++++++- 8 files changed, 77 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 6eb877fe1f43..1c1c40c5f073 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -192,7 +192,7 @@ async def _async_setup_entry( # This ensures via_device references work for all device entities nvr = bootstrap.nvr device_registry = dr.async_get(hass) - device_registry.async_get_or_create( + nvr_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, nvr.mac)}, identifiers={(DOMAIN, nvr.mac)}, @@ -202,6 +202,7 @@ async def _async_setup_entry( sw_version=str(nvr.version), configuration_url=nvr.api.base_url, ) + data_service.nvr_device_id = nvr_device.id await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) hass.http.register_view(ThumbnailProxyView(hass)) diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 182f50c8e505..a9760ecf0f96 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -72,6 +72,9 @@ def _async_dispatch_id(entry: UFPConfigEntry, dispatch: str) -> str: class ProtectData: """Coordinate updates.""" + # Resolved once in _async_setup_entry, before any entity is created. + nvr_device_id: str + def __init__( self, hass: HomeAssistant, diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 9508de8a3b1d..c814336aa1e0 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -416,7 +416,7 @@ class ProtectDeviceEntity(BaseProtectEntity): manufacturer=DEFAULT_BRAND, model=self.device.market_name or self.device.type, model_id=self.device.type, - via_device=(DOMAIN, self.data.api.bootstrap.nvr.mac), + via_device_id=self.data.nvr_device_id, sw_version=self.device.firmware_version, connections={(dr.CONNECTION_NETWORK_MAC, self.device.mac)}, configuration_url=self.device.protect_url, diff --git a/homeassistant/components/unifiprotect/siren.py b/homeassistant/components/unifiprotect/siren.py index 1ea88028e80e..4256c022f32c 100644 --- a/homeassistant/components/unifiprotect/siren.py +++ b/homeassistant/components/unifiprotect/siren.py @@ -68,14 +68,13 @@ class ProtectSiren(SirenEntity): self.data = data self._siren_id = siren.id self._attr_unique_id = f"{siren.mac}_siren" # pylint: disable=home-assistant-entity-unique-id-redundant-platform - nvr = data.api.bootstrap.nvr self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, siren.mac)}, identifiers={(DOMAIN, siren.mac)}, manufacturer=DEFAULT_BRAND, name=siren.name, model="Siren", - via_device=(DOMAIN, nvr.mac), + via_device_id=data.nvr_device_id, ) self._siren_mac = siren.mac self._cancel_scheduled_off: CALLBACK_TYPE | None = None diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index 54812f75882e..b6cd391a63a7 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -615,14 +615,13 @@ class ProtectRelayOutputSwitch(SwitchEntity): self._attr_translation_placeholders = { "output_name": output.name or str(output.id), } - nvr = data.api.bootstrap.nvr self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, relay.mac)}, identifiers={(DOMAIN, relay.mac)}, manufacturer=DEFAULT_BRAND, name=relay.name, model="Relay", - via_device=(DOMAIN, nvr.mac), + via_device_id=data.nvr_device_id, ) self._update_from_relay(relay) diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 5b4b382aa705..2b9654969e3c 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -67,6 +67,28 @@ async def test_setup_creates_nvr_device( assert nvr_device == snapshot +async def test_device_links_to_nvr_via_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + ufp: MockUFPFixture, + light: Light, +) -> None: + """Test that a standard Protect device's via_device_id points at the NVR device.""" + await init_entry(hass, ufp, [light]) + + nvr = ufp.api.bootstrap.nvr + nvr_device = device_registry.async_get_device_by_identifier( + (DOMAIN, nvr.mac), ufp.entry.entry_id + ) + assert nvr_device is not None + + light_device = device_registry.async_get_device_by_connection( + (dr.CONNECTION_NETWORK_MAC, light.mac), ufp.entry.entry_id + ) + assert light_device is not None + assert light_device.via_device_id == nvr_device.id + + async def test_setup(hass: HomeAssistant, ufp: MockUFPFixture) -> None: """Test working setup of unifiprotect entry.""" diff --git a/tests/components/unifiprotect/test_relay.py b/tests/components/unifiprotect/test_relay.py index 329e16b33775..28608fefdac6 100644 --- a/tests/components/unifiprotect/test_relay.py +++ b/tests/components/unifiprotect/test_relay.py @@ -15,6 +15,7 @@ from uiprotect.exceptions import ClientError, NotAuthorized from uiprotect.websocket import WebsocketState from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.unifiprotect.const import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -26,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .utils import MockUFPFixture, init_entry @@ -126,6 +127,28 @@ async def test_relay_switch_created_with_state( assert state.state == STATE_ON +async def test_relay_switch_device_links_to_nvr_via_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """Relay device's via_device_id points at the NVR device.""" + ufp, _relay = ufp_with_relay + await init_entry(hass, ufp, []) + + nvr = ufp.api.bootstrap.nvr + nvr_device = device_registry.async_get_device_by_identifier( + (DOMAIN, nvr.mac), ufp.entry.entry_id + ) + assert nvr_device is not None + + relay_device = device_registry.async_get_device_by_identifier( + (DOMAIN, RELAY_MAC), ufp.entry.entry_id + ) + assert relay_device is not None + assert relay_device.via_device_id == nvr_device.id + + async def test_relay_switch_off_otp_is_off( hass: HomeAssistant, ufp_with_relay: tuple[MockUFPFixture, Mock], diff --git a/tests/components/unifiprotect/test_siren.py b/tests/components/unifiprotect/test_siren.py index 5c273d613b4c..747972d6764e 100644 --- a/tests/components/unifiprotect/test_siren.py +++ b/tests/components/unifiprotect/test_siren.py @@ -19,6 +19,7 @@ from homeassistant.components.siren import ( ATTR_VOLUME_LEVEL, DOMAIN as SIREN_DOMAIN, ) +from homeassistant.components.unifiprotect.const import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -30,7 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util from .utils import MockUFPFixture, assert_entity_counts, init_entry @@ -158,6 +159,27 @@ async def test_siren_created_on( assert state.state == STATE_ON +async def test_siren_device_links_to_nvr_via_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + ufp_with_siren: MockUFPFixture, +) -> None: + """Siren device's via_device_id points at the NVR device.""" + await init_entry(hass, ufp_with_siren, []) + + nvr = ufp_with_siren.api.bootstrap.nvr + nvr_device = device_registry.async_get_device_by_identifier( + (DOMAIN, nvr.mac), ufp_with_siren.entry.entry_id + ) + assert nvr_device is not None + + siren_device = device_registry.async_get_device_by_identifier( + (DOMAIN, SIREN_MAC), ufp_with_siren.entry.entry_id + ) + assert siren_device is not None + assert siren_device.via_device_id == nvr_device.id + + # --------------------------------------------------------------------------- # Commands # --------------------------------------------------------------------------- From 21a7268b22b0556eaf61b179f0659e5431e342db Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 17:34:46 +0200 Subject: [PATCH 089/109] Fix via_device race in bluesound (#177932) --- homeassistant/components/bluesound/button.py | 8 ++- .../components/bluesound/media_player.py | 13 ++++- tests/components/bluesound/conftest.py | 17 +++++++ tests/components/bluesound/test_init.py | 49 +++++++++++++++++++ 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluesound/button.py b/homeassistant/components/bluesound/button.py index a6f54adda848..99ddd9247342 100644 --- a/homeassistant/components/bluesound/button.py +++ b/homeassistant/components/bluesound/button.py @@ -9,6 +9,7 @@ from pyblu import Player from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, DeviceInfo, @@ -118,8 +119,13 @@ class BluesoundButton(CoordinatorEntity[BluesoundCoordinator], ButtonEntity): manufacturer=sync_status.brand, model=sync_status.model_name, model_id=sync_status.model, - via_device=(DOMAIN, format_mac(sync_status.mac)), ) + # The leader (default-port) device is a separate config entry that may + # not be loaded yet; link to it only if it already exists. + if leader_devices := dr.async_get(coordinator.hass).async_get_devices( + identifiers={(DOMAIN, format_mac(sync_status.mac))} + ): + self._attr_device_info["via_device_id"] = leader_devices[0].id @override async def async_press(self) -> None: diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index ac2dd0033171..baa446706608 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -19,7 +19,11 @@ from homeassistant.components.media_player import ( from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, DeviceInfo, @@ -131,8 +135,13 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity manufacturer=sync_status.brand, model=sync_status.model_name, model_id=sync_status.model, - via_device=(DOMAIN, format_mac(sync_status.mac)), ) + # The leader (default-port) device is a separate config entry that may + # not be loaded yet; link to it only if it already exists. + if leader_devices := dr.async_get(coordinator.hass).async_get_devices( + identifiers={(DOMAIN, format_mac(sync_status.mac))} + ): + self._attr_device_info["via_device_id"] = leader_devices[0].id @override async def async_added_to_hass(self) -> None: diff --git a/tests/components/bluesound/conftest.py b/tests/components/bluesound/conftest.py index c8ad285f02df..55724d55cd90 100644 --- a/tests/components/bluesound/conftest.py +++ b/tests/components/bluesound/conftest.py @@ -159,6 +159,23 @@ def config_entry_secondary() -> MockConfigEntry: ) +@pytest.fixture +def config_entry_non_default_port() -> MockConfigEntry: + """Return a mocked config entry for a non-default-port player. + + Shares its mac (via player_mocks) with the leader `config_entry` at the + default port. + """ + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.2", + CONF_PORT: 11001, + }, + unique_id="ff:ff:01:01:01:01-11001", + ) + + @pytest.fixture async def setup_config_entry( hass: HomeAssistant, config_entry: MockConfigEntry, player_mocks: PlayerMocks diff --git a/tests/components/bluesound/test_init.py b/tests/components/bluesound/test_init.py index b373ca743cd8..436a655308eb 100644 --- a/tests/components/bluesound/test_init.py +++ b/tests/components/bluesound/test_init.py @@ -2,8 +2,10 @@ from pyblu.errors import PlayerUnreachableError +from homeassistant.components.bluesound import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from .conftest import PlayerMocks @@ -45,3 +47,50 @@ async def test_unload_entry_while_player_is_offline( assert hass.states.get("media_player.player_name1111").state == "unavailable" assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_non_default_port_links_to_leader_via_device_id( + hass: HomeAssistant, + setup_config_entry: None, + config_entry_non_default_port: MockConfigEntry, + player_mocks: PlayerMocks, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a non-default-port player links to the already-registered leader device.""" + config_entry_non_default_port.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry_non_default_port.entry_id) + await hass.async_block_till_done() + + assert config_entry_non_default_port.state is ConfigEntryState.LOADED + + leader_devices = device_registry.async_get_devices( + identifiers={(DOMAIN, "ff:ff:01:01:01:01")} + ) + assert leader_devices + leader_device = leader_devices[0] + + child_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "ff:ff:01:01:01:01-11001"), config_entry_non_default_port.entry_id + ) + assert child_device is not None + assert child_device.via_device_id == leader_device.id + + +async def test_non_default_port_without_leader_has_no_via_device_id( + hass: HomeAssistant, + config_entry_non_default_port: MockConfigEntry, + player_mocks: PlayerMocks, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a non-default-port player sets up fine when the leader device is absent.""" + config_entry_non_default_port.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry_non_default_port.entry_id) + await hass.async_block_till_done() + + assert config_entry_non_default_port.state is ConfigEntryState.LOADED + + child_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "ff:ff:01:01:01:01-11001"), config_entry_non_default_port.entry_id + ) + assert child_device is not None + assert child_device.via_device_id is None From cddf63e4688055c8c350421c8915472ab296a9ff Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 17:35:09 +0200 Subject: [PATCH 090/109] Remove via_device from brunt (#177862) --- homeassistant/components/brunt/cover.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py index 5784b6d740de..3b4fd6c34bfb 100644 --- a/homeassistant/components/brunt/cover.py +++ b/homeassistant/components/brunt/cover.py @@ -38,7 +38,7 @@ async def async_setup_entry( coordinator = entry.runtime_data async_add_entities( - BruntDevice(coordinator, serial, thing, entry.entry_id) + BruntDevice(coordinator, serial, thing) for serial, thing in coordinator.data.items() ) @@ -64,20 +64,17 @@ class BruntDevice(CoordinatorEntity[BruntCoordinator], CoverEntity): coordinator: BruntCoordinator, serial: str | None, thing: Thing, - entry_id: str, ) -> None: """Init the Brunt device.""" super().__init__(coordinator) self._attr_unique_id = serial self._thing = thing - self._entry_id = entry_id self._remove_update_listener = None self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._attr_unique_id)}, # type: ignore[arg-type] name=self._thing.name, - via_device=(DOMAIN, self._entry_id), manufacturer="Brunt", sw_version=self._thing.fw_version, model=self._thing.model, From b2da7aaf5dccda92e3ca1edef8d5f55001e27242 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 17:35:24 +0200 Subject: [PATCH 091/109] Remove via_device from wilight (#177865) --- homeassistant/components/wilight/entity.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/wilight/entity.py b/homeassistant/components/wilight/entity.py index bb360ab2805d..562b9c47a460 100644 --- a/homeassistant/components/wilight/entity.py +++ b/homeassistant/components/wilight/entity.py @@ -35,7 +35,6 @@ class WiLightDevice(Entity): model=api_device.model, manufacturer="WiLight", sw_version=api_device.swversion, - via_device=(DOMAIN, self._device_id), ) @property From 8fd563cd5b0f9b8f40b6a6041441b5a78f487f4c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 17:35:43 +0200 Subject: [PATCH 092/109] Adapt zimi to set via_device_id in DeviceInfo (#177860) --- homeassistant/components/zimi/cover.py | 8 +++++-- homeassistant/components/zimi/entity.py | 13 +++++++++-- homeassistant/components/zimi/fan.py | 4 +++- homeassistant/components/zimi/light.py | 28 +++++++++++++++++++----- homeassistant/components/zimi/sensor.py | 6 +++-- homeassistant/components/zimi/switch.py | 4 +++- tests/components/zimi/test_switch.py | 29 +++++++++++++++++++++++-- 7 files changed, 76 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/zimi/cover.py b/homeassistant/components/zimi/cover.py index c3deb122dea3..8374f96673b6 100644 --- a/homeassistant/components/zimi/cover.py +++ b/homeassistant/components/zimi/cover.py @@ -26,9 +26,13 @@ async def async_setup_entry( api = config_entry.runtime_data - covers = [ZimiCover(device, api) for device in api.blinds] + covers = [ + ZimiCover(hass, device, api, config_entry.entry_id) for device in api.blinds + ] - covers.extend(ZimiCover(device, api) for device in api.doors) + covers.extend( + ZimiCover(hass, device, api, config_entry.entry_id) for device in api.doors + ) async_add_entities(covers) diff --git a/homeassistant/components/zimi/entity.py b/homeassistant/components/zimi/entity.py index eb631b114a32..139abc526305 100644 --- a/homeassistant/components/zimi/entity.py +++ b/homeassistant/components/zimi/entity.py @@ -6,6 +6,8 @@ from typing import override from zcc import ControlPoint from zcc.device import ControlPointDevice +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -21,7 +23,12 @@ class ZimiEntity(Entity): _attr_has_entity_name = True def __init__( - self, device: ControlPointDevice, api: ControlPoint, use_device_name=True + self, + hass: HomeAssistant, + device: ControlPointDevice, + api: ControlPoint, + config_entry_id: str, + use_device_name: bool = True, ) -> None: """Initialize an HA Entity which is a ZimiDevice.""" @@ -35,7 +42,9 @@ class ZimiEntity(Entity): hw_version=device.manufacture_info.hwVersion, sw_version=device.manufacture_info.firmwareVersion, suggested_area=device.room, - via_device=(DOMAIN, api.mac), + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, api.mac), config_entry_id=config_entry_id + ), ) if use_device_name: self._attr_name = device.name.strip() diff --git a/homeassistant/components/zimi/fan.py b/homeassistant/components/zimi/fan.py index 919cb764e8da..2d46796b5f34 100644 --- a/homeassistant/components/zimi/fan.py +++ b/homeassistant/components/zimi/fan.py @@ -28,7 +28,9 @@ async def async_setup_entry( api = config_entry.runtime_data - async_add_entities([ZimiFan(device, api) for device in api.fans]) + async_add_entities( + ZimiFan(hass, device, api, config_entry.entry_id) for device in api.fans + ) class ZimiFan(ZimiEntity, FanEntity): diff --git a/homeassistant/components/zimi/light.py b/homeassistant/components/zimi/light.py index c2abcd0daf67..b26f93258ada 100644 --- a/homeassistant/components/zimi/light.py +++ b/homeassistant/components/zimi/light.py @@ -26,11 +26,15 @@ async def async_setup_entry( api = config_entry.runtime_data lights: list[ZimiLight | ZimiDimmer] = [ - ZimiLight(device, api) for device in api.lights if device.type != "dimmer" + ZimiLight(hass, device, api, config_entry.entry_id) + for device in api.lights + if device.type != "dimmer" ] lights.extend( - ZimiDimmer(device, api) for device in api.lights if device.type == "dimmer" + ZimiDimmer(hass, device, api, config_entry.entry_id) + for device in api.lights + if device.type == "dimmer" ) async_add_entities(lights) @@ -39,10 +43,16 @@ async def async_setup_entry( class ZimiLight(ZimiEntity, LightEntity): """Representation of a Zimi Light.""" - def __init__(self, device: ControlPointDevice, api: ControlPoint) -> None: + def __init__( + self, + hass: HomeAssistant, + device: ControlPointDevice, + api: ControlPoint, + config_entry_id: str, + ) -> None: """Initialize a ZimiLight.""" - super().__init__(device, api) + super().__init__(hass, device, api, config_entry_id) self._attr_color_mode = ColorMode.ONOFF self._attr_supported_color_modes = {ColorMode.ONOFF} @@ -77,9 +87,15 @@ class ZimiLight(ZimiEntity, LightEntity): class ZimiDimmer(ZimiLight): """Zimi Light supporting dimming.""" - def __init__(self, device: ControlPointDevice, api: ControlPoint) -> None: + def __init__( + self, + hass: HomeAssistant, + device: ControlPointDevice, + api: ControlPoint, + config_entry_id: str, + ) -> None: """Initialize a ZimiDimmer.""" - super().__init__(device, api) + super().__init__(hass, device, api, config_entry_id) self._attr_color_mode = ColorMode.BRIGHTNESS self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} diff --git a/homeassistant/components/zimi/sensor.py b/homeassistant/components/zimi/sensor.py index 2ab507343a14..306043b2670e 100644 --- a/homeassistant/components/zimi/sensor.py +++ b/homeassistant/components/zimi/sensor.py @@ -71,7 +71,7 @@ async def async_setup_entry( api = config_entry.runtime_data async_add_entities( - ZimiSensor(device, description, api) + ZimiSensor(hass, device, description, api, config_entry.entry_id) for device in api.sensors for description in GARAGE_SENSOR_DESCRIPTIONS ) @@ -84,13 +84,15 @@ class ZimiSensor(ZimiEntity, SensorEntity): def __init__( self, + hass: HomeAssistant, device: ControlPointDevice, description: ZimiSensorEntityDescription, api: ControlPoint, + config_entry_id: str, ) -> None: """Initialize an ZimiSensor with specified type.""" - super().__init__(device, api, use_device_name=False) + super().__init__(hass, device, api, config_entry_id, use_device_name=False) self.entity_description = description self._attr_unique_id = device.identifier + "." + self.entity_description.key diff --git a/homeassistant/components/zimi/switch.py b/homeassistant/components/zimi/switch.py index 689e348acd8a..acb3b89049ea 100644 --- a/homeassistant/components/zimi/switch.py +++ b/homeassistant/components/zimi/switch.py @@ -22,7 +22,9 @@ async def async_setup_entry( api = config_entry.runtime_data - outlets = [ZimiSwitch(device, api) for device in api.outlets] + outlets = [ + ZimiSwitch(hass, device, api, config_entry.entry_id) for device in api.outlets + ] async_add_entities(outlets) diff --git a/tests/components/zimi/test_switch.py b/tests/components/zimi/test_switch.py index 873d3c2c68e1..61a71c31aa89 100644 --- a/tests/components/zimi/test_switch.py +++ b/tests/components/zimi/test_switch.py @@ -5,11 +5,13 @@ from unittest.mock import MagicMock from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.zimi.const import DOMAIN from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er -from .common import ENTITY_INFO, mock_api_device, setup_platform +from .common import DEVICE_INFO, ENTITY_INFO, mock_api_device, setup_platform +from .conftest import INPUT_MAC async def test_switch_entity( @@ -53,3 +55,26 @@ async def test_switch_entity( ) assert mock_api.outlets[0].turn_off.called + + +async def test_switch_via_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_api: MagicMock, +) -> None: + """Tests switch device is linked to the controller device via via_device_id.""" + + mock_api.outlets = [mock_api_device(entity_type="switch")] + + config_entry = await setup_platform(hass, Platform.SWITCH) + + controller_device = device_registry.async_get_device_by_identifier( + (DOMAIN, INPUT_MAC), config_entry.entry_id + ) + assert controller_device is not None + + switch_device = device_registry.async_get_device_by_identifier( + (DOMAIN, DEVICE_INFO["id"]), config_entry.entry_id + ) + assert switch_device is not None + assert switch_device.via_device_id == controller_device.id From 34aa09fb2f32902078cf12e96145ac0d039290b9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 17:36:08 +0200 Subject: [PATCH 093/109] Adapt xiaomi_aqara to set via_device_id in DeviceInfo (#177856) --- .../components/xiaomi_aqara/entity.py | 7 +- tests/components/xiaomi_aqara/test_init.py | 84 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 tests/components/xiaomi_aqara/test_init.py diff --git a/homeassistant/components/xiaomi_aqara/entity.py b/homeassistant/components/xiaomi_aqara/entity.py index 69e14fa6b4f5..4c9be755dd18 100644 --- a/homeassistant/components/xiaomi_aqara/entity.py +++ b/homeassistant/components/xiaomi_aqara/entity.py @@ -103,6 +103,7 @@ class XiaomiDevice(Entity): else: if TYPE_CHECKING: assert self._gateway_id is not None + assert self.platform.config_entry is not None device_info = DeviceInfo( connections={(dr.CONNECTION_ZIGBEE, self._device_id)}, identifiers={(DOMAIN, self._device_id)}, @@ -110,7 +111,11 @@ class XiaomiDevice(Entity): model=self._model, name=self._device_name, sw_version=self._protocol, - via_device=(DOMAIN, self._gateway_id), + via_device_id=dr.async_get_device_id_by_identifier( + self.hass, + (DOMAIN, self._gateway_id), + config_entry_id=self.platform.config_entry.entry_id, + ), ) return device_info diff --git a/tests/components/xiaomi_aqara/test_init.py b/tests/components/xiaomi_aqara/test_init.py new file mode 100644 index 000000000000..258f0284ce60 --- /dev/null +++ b/tests/components/xiaomi_aqara/test_init.py @@ -0,0 +1,84 @@ +"""Test the Xiaomi Aqara setup and device registry linking.""" + +from collections import defaultdict +from unittest.mock import AsyncMock, Mock, patch + +from homeassistant.components.xiaomi_aqara import const +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT, CONF_PROTOCOL +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + +TEST_HOST = "1.2.3.4" +TEST_PORT = 1234 +TEST_PROTOCOL = "1.1.1" +TEST_MAC = "ab:cd:ef:00:11:22" +TEST_MOTION_SID = "158d0001a2b3c4" + + +async def test_child_device_links_to_gateway_via_device_id( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test a Zigbee child device is linked to its gateway via via_device_id.""" + mock_gateway = Mock() + mock_gateway.sid = TEST_MAC.replace(":", "").lower() + mock_gateway.callbacks = defaultdict(list) + mock_gateway.devices = { + "binary_sensor": [ + { + "sid": TEST_MOTION_SID, + "model": "motion", + "proto": TEST_PROTOCOL, + "data": {}, + "raw_data": {"cmd": "report"}, + } + ], + "sensor": [], + } + + mock_multicast = Mock() + mock_multicast.start_listen = AsyncMock() + mock_multicast.stop_listen = Mock() + + entry = MockConfigEntry( + domain=const.DOMAIN, + unique_id=TEST_MAC, + data={ + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_MAC: TEST_MAC, + const.CONF_INTERFACE: "any", + CONF_PROTOCOL: TEST_PROTOCOL, + const.CONF_KEY: None, + const.CONF_SID: mock_gateway.sid, + }, + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.xiaomi_aqara.XiaomiGateway", + return_value=mock_gateway, + ), + patch( + "homeassistant.components.xiaomi_aqara.AsyncXiaomiGatewayMulticast", + return_value=mock_multicast, + ), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + gateway_device = device_registry.async_get_device_by_identifier( + (const.DOMAIN, TEST_MAC), entry.entry_id + ) + child_device = device_registry.async_get_device_by_identifier( + (const.DOMAIN, TEST_MOTION_SID), entry.entry_id + ) + + assert gateway_device is not None + assert child_device is not None + assert child_device.via_device_id == gateway_device.id From d3d79d843cbca7788862effff0c6dee21ea77d12 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 17:36:40 +0200 Subject: [PATCH 094/109] Adapt withings to set via_device_id in DeviceInfo (#177851) --- homeassistant/components/withings/entity.py | 7 ++++++- tests/components/withings/test_init.py | 23 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py index bc7ce5c21f35..79b2e2b0e359 100644 --- a/homeassistant/components/withings/entity.py +++ b/homeassistant/components/withings/entity.py @@ -4,6 +4,7 @@ from typing import Any, override from aiowithings import Device +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -50,7 +51,11 @@ class WithingsDeviceEntity(WithingsEntity[WithingsDeviceDataUpdateCoordinator]): manufacturer="Withings", name=self.device.raw_model, model=self.device.raw_model, - via_device=(DOMAIN, str(coordinator.config_entry.unique_id)), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, str(coordinator.config_entry.unique_id)), + config_entry_id=coordinator.config_entry.entry_id, + ), ) @property diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index f287d2ae0af0..63c50983ae9b 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -715,6 +715,29 @@ async def test_devices( assert device == snapshot(name=device_id) +@pytest.mark.usefixtures("withings") +async def test_device_via_device_id( + hass: HomeAssistant, + webhook_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the physical device is linked to the account device via via_device_id.""" + await setup_integration(hass, webhook_config_entry) + await hass.async_block_till_done() + + account_device = device_registry.async_get_device_by_identifier( + (DOMAIN, str(webhook_config_entry.unique_id)), webhook_config_entry.entry_id + ) + assert account_device is not None + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d"), + webhook_config_entry.entry_id, + ) + assert device is not None + assert device.via_device_id == account_device.id + + async def test_oauth_implementation_not_available( hass: HomeAssistant, webhook_config_entry: MockConfigEntry, From ef2a825f77947b7473da65b4538961b7e5219c21 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 3 Aug 2026 17:36:57 +0200 Subject: [PATCH 095/109] Mention async_register_admin_service in AGENTS.md (#178097) --- .github/copilot-instructions.md | 1 + AGENTS.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8991307a9233..7d64835dc667 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -182,6 +182,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom - Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before. Comments in tests that explain why a function call or assertion is made are ok. - Do not add section or divider comments (e.g. `# --- XYZ Triggers ---`) inside or outside of functions, since those can easily become stale and be misleading. - When catching exceptions, try-clauses should be as small as possible, i.e. avoid wrapping large blocks of code in a try-clause, and avoid catching exceptions from functions that are not expected to raise them. +- Sensitive service actions, i.e. those that can change configuration or have security implications, should require an admin user. Register them with the `async_register_admin_service` service helper, which checks this for you. ## AI policy diff --git a/AGENTS.md b/AGENTS.md index 504af7dc10eb..882d266537a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom - Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before. Comments in tests that explain why a function call or assertion is made are ok. - Do not add section or divider comments (e.g. `# --- XYZ Triggers ---`) inside or outside of functions, since those can easily become stale and be misleading. - When catching exceptions, try-clauses should be as small as possible, i.e. avoid wrapping large blocks of code in a try-clause, and avoid catching exceptions from functions that are not expected to raise them. +- Sensitive service actions, i.e. those that can change configuration or have security implications, should require an admin user. Register them with the `async_register_admin_service` service helper, which checks this for you. ## AI policy From cffaf5d76624759c59d002a0791eb29a9ef96958 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 17:37:12 +0200 Subject: [PATCH 096/109] Adapt velux to set via_device_id in DeviceInfo (#177786) --- .../components/velux/binary_sensor.py | 4 ++- homeassistant/components/velux/button.py | 6 ++-- homeassistant/components/velux/cover.py | 28 +++++++++++-------- homeassistant/components/velux/entity.py | 16 ++++++++--- homeassistant/components/velux/light.py | 4 +-- homeassistant/components/velux/number.py | 6 ++-- homeassistant/components/velux/switch.py | 2 +- 7 files changed, 42 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/velux/binary_sensor.py b/homeassistant/components/velux/binary_sensor.py index 6216e75aa970..571552165719 100644 --- a/homeassistant/components/velux/binary_sensor.py +++ b/homeassistant/components/velux/binary_sensor.py @@ -53,7 +53,9 @@ class VeluxRainSensor( node = coordinator.node unique_id = velux_unique_id(node, config_entry_id) self._attr_unique_id = f"{unique_id}_rain_sensor" - self._attr_device_info = velux_device_info(node, config_entry_id) + self._attr_device_info = velux_device_info( + self.coordinator.hass, node, config_entry_id + ) @override async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/velux/button.py b/homeassistant/components/velux/button.py index 7ce2329573cc..9a50cff51864 100644 --- a/homeassistant/components/velux/button.py +++ b/homeassistant/components/velux/button.py @@ -29,7 +29,7 @@ async def async_setup_entry( VeluxGatewayRebootButton(config_entry.entry_id, pyvlx) ] entities.extend( - VeluxIdentifyButton(node, config_entry.entry_id) + VeluxIdentifyButton(hass, node, config_entry.entry_id) for node in pyvlx.nodes if isinstance(node, Node) ) @@ -42,9 +42,9 @@ class VeluxIdentifyButton(VeluxEntity, ButtonEntity): _attr_device_class = ButtonDeviceClass.IDENTIFY _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, node: Node, config_entry_id: str) -> None: + def __init__(self, hass: HomeAssistant, node: Node, config_entry_id: str) -> None: """Initialize the Velux identify button.""" - super().__init__(node, config_entry_id) + super().__init__(hass, node, config_entry_id) self._attr_unique_id = f"{self._attr_unique_id}_identify" @wrap_pyvlx_call_exceptions diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index e3a65660f554..cec443630cef 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -42,26 +42,26 @@ async def async_setup_entry( entities: list[VeluxCover] = [] for node in pyvlx.nodes: if isinstance(node, Blind): - entities.append(VeluxBlind(node, config_entry.entry_id)) + entities.append(VeluxBlind(hass, node, config_entry.entry_id)) elif isinstance(node, DualRollerShutter): # add three entities, one for each part and the "dual" control entities.append( VeluxDualRollerShutter( - node, config_entry.entry_id, VeluxDualRollerPart.DUAL + hass, node, config_entry.entry_id, VeluxDualRollerPart.DUAL ) ) entities.append( VeluxDualRollerShutter( - node, config_entry.entry_id, VeluxDualRollerPart.UPPER + hass, node, config_entry.entry_id, VeluxDualRollerPart.UPPER ) ) entities.append( VeluxDualRollerShutter( - node, config_entry.entry_id, VeluxDualRollerPart.LOWER + hass, node, config_entry.entry_id, VeluxDualRollerPart.LOWER ) ) elif isinstance(node, OpeningDevice): - entities.append(VeluxCover(node, config_entry.entry_id)) + entities.append(VeluxCover(hass, node, config_entry.entry_id)) async_add_entities(entities) @@ -79,9 +79,11 @@ class VeluxCover(VeluxEntity, CoverEntity): | CoverEntityFeature.STOP ) - def __init__(self, node: OpeningDevice, config_entry_id: str) -> None: + def __init__( + self, hass: HomeAssistant, node: OpeningDevice, config_entry_id: str + ) -> None: """Initialize VeluxCover.""" - super().__init__(node, config_entry_id) + super().__init__(hass, node, config_entry_id) match node: case Window(): self._attr_device_class = CoverDeviceClass.WINDOW @@ -166,10 +168,14 @@ class VeluxDualRollerShutter(VeluxCover): _attr_device_class = CoverDeviceClass.SHUTTER def __init__( - self, node: DualRollerShutter, config_entry_id: str, part: VeluxDualRollerPart + self, + hass: HomeAssistant, + node: DualRollerShutter, + config_entry_id: str, + part: VeluxDualRollerPart, ) -> None: """Initialize VeluxDualRollerShutter.""" - super().__init__(node, config_entry_id) + super().__init__(hass, node, config_entry_id) if part == VeluxDualRollerPart.DUAL: self._attr_name = None else: @@ -235,9 +241,9 @@ class VeluxBlind(VeluxCover): node: Blind _attr_device_class = CoverDeviceClass.BLIND - def __init__(self, node: Blind, config_entry_id: str) -> None: + def __init__(self, hass: HomeAssistant, node: Blind, config_entry_id: str) -> None: """Initialize VeluxBlind.""" - super().__init__(node, config_entry_id) + super().__init__(hass, node, config_entry_id) self._attr_supported_features |= ( CoverEntityFeature.OPEN_TILT diff --git a/homeassistant/components/velux/entity.py b/homeassistant/components/velux/entity.py index 0959d22a83a4..84c832b48e30 100644 --- a/homeassistant/components/velux/entity.py +++ b/homeassistant/components/velux/entity.py @@ -7,7 +7,9 @@ from typing import Any, override from pyvlx import Node, PyVLXException +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -21,14 +23,20 @@ def velux_unique_id(node: Node, config_entry_id: str) -> str: return node.serial_number or f"{config_entry_id}_{node.node_id}" -def velux_device_info(node: Node, config_entry_id: str) -> DeviceInfo: +def velux_device_info( + hass: HomeAssistant, node: Node, config_entry_id: str +) -> DeviceInfo: """Build DeviceInfo for a Velux node.""" unique_id = velux_unique_id(node, config_entry_id) return DeviceInfo( identifiers={(DOMAIN, unique_id)}, name=node.name or f"#{node.node_id}", serial_number=node.serial_number, - via_device=(DOMAIN, f"gateway_{config_entry_id}"), + via_device_id=dr.async_get_device_id_by_identifier( + hass, + (DOMAIN, f"gateway_{config_entry_id}"), + config_entry_id=config_entry_id, + ), ) @@ -65,11 +73,11 @@ class VeluxEntity(Entity): _attr_available = True _unavailable_logged = False - def __init__(self, node: Node, config_entry_id: str) -> None: + def __init__(self, hass: HomeAssistant, node: Node, config_entry_id: str) -> None: """Initialize the Velux device.""" self.node = node self._attr_unique_id = velux_unique_id(node, config_entry_id) - self._attr_device_info = velux_device_info(node, config_entry_id) + self._attr_device_info = velux_device_info(hass, node, config_entry_id) async def after_update_callback(self, _: Node) -> None: """Call after device was updated.""" diff --git a/homeassistant/components/velux/light.py b/homeassistant/components/velux/light.py index 67afe3ae12c0..197f7385da98 100644 --- a/homeassistant/components/velux/light.py +++ b/homeassistant/components/velux/light.py @@ -24,9 +24,9 @@ async def async_setup_entry( entities: list[VeluxOnOffLight] = [] for node in pyvlx.nodes: if isinstance(node, Light): - entities.append(VeluxDimmableLight(node, config_entry.entry_id)) + entities.append(VeluxDimmableLight(hass, node, config_entry.entry_id)) elif isinstance(node, OnOffLight): - entities.append(VeluxOnOffLight(node, config_entry.entry_id)) + entities.append(VeluxOnOffLight(hass, node, config_entry.entry_id)) async_add_entities(entities) diff --git a/homeassistant/components/velux/number.py b/homeassistant/components/velux/number.py index e026ef004883..290071579f7e 100644 --- a/homeassistant/components/velux/number.py +++ b/homeassistant/components/velux/number.py @@ -32,7 +32,7 @@ async def async_setup_entry( pyvlx = config_entry.runtime_data.pyvlx limitation_coordinators = config_entry.runtime_data.limitation_coordinators entities: list[NumberEntity] = [ - VeluxExteriorHeatingNumber(node, config_entry.entry_id) + VeluxExteriorHeatingNumber(hass, node, config_entry.entry_id) for node in pyvlx.nodes if isinstance(node, ExteriorHeating) ] @@ -105,7 +105,9 @@ class VeluxPositionLimitNumber( unique_id = velux_unique_id(node, config_entry_id) self._attr_unique_id = f"{unique_id}_{self._limitation_kind}_limitation" self._attr_translation_key = f"{self._limitation_kind}_position_limitation" - self._attr_device_info = velux_device_info(node, config_entry_id) + self._attr_device_info = velux_device_info( + self.coordinator.hass, node, config_entry_id + ) @override async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/velux/switch.py b/homeassistant/components/velux/switch.py index 8cd8ba459704..51d2362af7fc 100644 --- a/homeassistant/components/velux/switch.py +++ b/homeassistant/components/velux/switch.py @@ -22,7 +22,7 @@ async def async_setup_entry( """Set up switch(es) for Velux platform.""" pyvlx = config_entry.runtime_data.pyvlx async_add_entities( - VeluxOnOffSwitch(node, config_entry.entry_id) + VeluxOnOffSwitch(hass, node, config_entry.entry_id) for node in pyvlx.nodes if isinstance(node, OnOffSwitch) ) From cfae509a3ccd3a24341e1bd2ee600350f347a07e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 17:46:37 +0200 Subject: [PATCH 097/109] Fix via_device race in verisure (#177847) --- homeassistant/components/verisure/__init__.py | 15 ++++-- .../components/verisure/binary_sensor.py | 18 +++---- homeassistant/components/verisure/camera.py | 18 +++---- homeassistant/components/verisure/lock.py | 18 +++---- homeassistant/components/verisure/sensor.py | 47 +++++++++---------- homeassistant/components/verisure/switch.py | 20 ++++---- tests/components/verisure/conftest.py | 7 ++- tests/components/verisure/test_init.py | 27 ++++++++++- 8 files changed, 101 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/verisure/__init__.py b/homeassistant/components/verisure/__init__.py index 62b34130a444..ff890ffd9f0d 100644 --- a/homeassistant/components/verisure/__init__.py +++ b/homeassistant/components/verisure/__init__.py @@ -9,10 +9,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.storage import STORAGE_DIR -from .const import CONF_LOCK_DEFAULT_CODE, LOGGER +from .const import CONF_GIID, CONF_LOCK_DEFAULT_CODE, DOMAIN, LOGGER from .coordinator import VerisureConfigEntry, VerisureDataUpdateCoordinator PLATFORMS = [ @@ -38,7 +38,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: VerisureConfigEntry) -> entry.runtime_data = coordinator - # Migrate lock default code from config entry to lock entity + # Register the alarm (VBox) device so children can link to it via via_device_id + # regardless of platform setup order. + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.data[CONF_GIID])}, + manufacturer="Verisure", + model="VBox", + name="Verisure Alarm", + configuration_url="https://mypages.verisure.com", + ) # Set up all platforms for this device/entry. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index 9d8fd350ffe5..34d4af7b9a09 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -8,6 +8,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import ATTR_LAST_TRIP_TIME, EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -51,18 +52,17 @@ class VerisureDoorWindowSensor( super().__init__(coordinator) self._attr_unique_id = f"{serial_number}_door_window" self.serial_number = serial_number - - @property - @override - def device_info(self) -> DeviceInfo: - """Return device information about this entity.""" - area = self.coordinator.data["door_window"][self.serial_number]["area"] - return DeviceInfo( + area = coordinator.data["door_window"][serial_number]["area"] + self._attr_device_info = DeviceInfo( name=area, manufacturer="Verisure", model="Shock Sensor Detector", - identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), + identifiers={(DOMAIN, serial_number)}, + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.config_entry.data[CONF_GIID]), + config_entry_id=coordinator.config_entry.entry_id, + ), configuration_url="https://mypages.verisure.com", ) diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py index dd59ae36a16a..a7d11e5da57b 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -9,6 +9,7 @@ from verisure import Error as VerisureError from homeassistant.components.camera import Camera from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, @@ -63,18 +64,17 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) self._directory_path = directory_path self._image: str | None = None self._image_id: str | None = None - - @property - @override - def device_info(self) -> DeviceInfo: - """Return device information about this entity.""" - area = self.coordinator.data["cameras"][self.serial_number]["device"]["area"] - return DeviceInfo( + area = coordinator.data["cameras"][serial_number]["device"]["area"] + self._attr_device_info = DeviceInfo( name=area, manufacturer="Verisure", model="SmartCam", - identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), + identifiers={(DOMAIN, serial_number)}, + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.config_entry.data[CONF_GIID]), + config_entry_id=coordinator.config_entry.entry_id, + ), configuration_url="https://mypages.verisure.com", ) diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 54187011ce81..f1dfc81d214d 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -8,6 +8,7 @@ from verisure import Error as VerisureError from homeassistant.components.lock import LockEntity, LockState from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, @@ -70,18 +71,17 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt self._attr_is_locked = None self._attr_changed_by = None self._changed_method: str | None = None - - @property - @override - def device_info(self) -> DeviceInfo: - """Return device information about this entity.""" - area = self.coordinator.data["locks"][self.serial_number]["device"]["area"] - return DeviceInfo( + area = coordinator.data["locks"][serial_number]["device"]["area"] + self._attr_device_info = DeviceInfo( name=area, manufacturer="Verisure", model="Lockguard Smartlock", - identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), + identifiers={(DOMAIN, serial_number)}, + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.config_entry.data[CONF_GIID]), + config_entry_id=coordinator.config_entry.entry_id, + ), configuration_url="https://mypages.verisure.com", ) diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index b15dbcbe4557..ce98670d0a74 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -58,21 +59,18 @@ class VerisureThermometer( super().__init__(coordinator) self._attr_unique_id = f"{serial_number}_temperature" self.serial_number = serial_number - - @property - @override - def device_info(self) -> DeviceInfo: - """Return device information about this entity.""" - device_type = self.coordinator.data["climate"][self.serial_number]["device"][ - "gui" - ]["label"] - area = self.coordinator.data["climate"][self.serial_number]["device"]["area"] - return DeviceInfo( - name=area, + device = coordinator.data["climate"][serial_number]["device"] + device_type = device["gui"]["label"] + self._attr_device_info = DeviceInfo( + name=device["area"], manufacturer="Verisure", model=DEVICE_TYPE_NAME.get(device_type, device_type), - identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), + identifiers={(DOMAIN, serial_number)}, + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.config_entry.data[CONF_GIID]), + config_entry_id=coordinator.config_entry.entry_id, + ), configuration_url="https://mypages.verisure.com", ) @@ -111,21 +109,18 @@ class VerisureHygrometer( super().__init__(coordinator) self._attr_unique_id = f"{serial_number}_humidity" self.serial_number = serial_number - - @property - @override - def device_info(self) -> DeviceInfo: - """Return device information about this entity.""" - device_type = self.coordinator.data["climate"][self.serial_number]["device"][ - "gui" - ]["label"] - area = self.coordinator.data["climate"][self.serial_number]["device"]["area"] - return DeviceInfo( - name=area, + device = coordinator.data["climate"][serial_number]["device"] + device_type = device["gui"]["label"] + self._attr_device_info = DeviceInfo( + name=device["area"], manufacturer="Verisure", model=DEVICE_TYPE_NAME.get(device_type, device_type), - identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), + identifiers={(DOMAIN, serial_number)}, + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.config_entry.data[CONF_GIID]), + config_entry_id=coordinator.config_entry.entry_id, + ), configuration_url="https://mypages.verisure.com", ) diff --git a/homeassistant/components/verisure/switch.py b/homeassistant/components/verisure/switch.py index e7727fa52dc6..b855a784f5cd 100644 --- a/homeassistant/components/verisure/switch.py +++ b/homeassistant/components/verisure/switch.py @@ -5,6 +5,7 @@ from typing import Any, override from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -42,20 +43,17 @@ class VerisureSmartplug(CoordinatorEntity[VerisureDataUpdateCoordinator], Switch self.serial_number = serial_number self._change_timestamp: float = 0 self._state = False - - @property - @override - def device_info(self) -> DeviceInfo: - """Return device information about this entity.""" - area = self.coordinator.data["smart_plugs"][self.serial_number]["device"][ - "area" - ] - return DeviceInfo( + area = coordinator.data["smart_plugs"][serial_number]["device"]["area"] + self._attr_device_info = DeviceInfo( name=area, manufacturer="Verisure", model="SmartPlug", - identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), + identifiers={(DOMAIN, serial_number)}, + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.config_entry.data[CONF_GIID]), + config_entry_id=coordinator.config_entry.entry_id, + ), configuration_url="https://mypages.verisure.com", ) diff --git a/tests/components/verisure/conftest.py b/tests/components/verisure/conftest.py index 36bcd8982cff..9abb1462677e 100644 --- a/tests/components/verisure/conftest.py +++ b/tests/components/verisure/conftest.py @@ -28,7 +28,12 @@ OVERVIEW = [ "smartLocks": [ {"device": {"deviceLabel": "lock-1"}, "status": "LOCKED"} ], - "smartplugs": [{"device": {"deviceLabel": "plug-1"}, "status": "on"}], + "smartplugs": [ + { + "device": {"deviceLabel": "plug-1", "area": "Living room"}, + "currentState": "ON", + } + ], } } } diff --git a/tests/components/verisure/test_init.py b/tests/components/verisure/test_init.py index e748275fa0ee..52d1d9018152 100644 --- a/tests/components/verisure/test_init.py +++ b/tests/components/verisure/test_init.py @@ -16,6 +16,7 @@ from verisure import ( ) from homeassistant.components.verisure.const import ( + CONF_GIID, COOKIE_REFRESH_INTERVAL, DEFAULT_SCAN_INTERVAL, DOMAIN, @@ -24,7 +25,7 @@ from homeassistant.components.verisure.const import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import update_coordinator +from homeassistant.helpers import device_registry as dr, update_coordinator from tests.common import MockConfigEntry, async_fire_time_changed @@ -69,6 +70,30 @@ async def test_setup_success( assert hass.states.get(ALARM_ENTITY_ID).state == "disarmed" +@pytest.mark.usefixtures("mock_verisure") +async def test_child_device_links_to_alarm_via_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Child devices link to the alarm (VBox) device via via_device_id.""" + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.verisure.PLATFORMS", [Platform.SWITCH]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + giid = mock_config_entry.data[CONF_GIID] + alarm_device = device_registry.async_get_device_by_identifier( + (DOMAIN, giid), mock_config_entry.entry_id + ) + plug_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "plug-1"), mock_config_entry.entry_id + ) + assert alarm_device is not None + assert plug_device is not None + assert plug_device.via_device_id == alarm_device.id + + @pytest.mark.parametrize( "exc", [ From 653eef9b3fdc8eaf051fe23439a38847e0027e46 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 17:47:58 +0200 Subject: [PATCH 098/109] Adapt bosch_shc to set via_device_id in DeviceInfo (#177711) --- .../components/bosch_shc/binary_sensor.py | 14 +++- homeassistant/components/bosch_shc/cover.py | 1 + homeassistant/components/bosch_shc/entity.py | 50 ++++-------- homeassistant/components/bosch_shc/sensor.py | 8 +- homeassistant/components/bosch_shc/switch.py | 15 +++- tests/components/bosch_shc/test_entity.py | 78 +++++++++++++++++++ 6 files changed, 122 insertions(+), 44 deletions(-) create mode 100644 tests/components/bosch_shc/test_entity.py diff --git a/homeassistant/components/bosch_shc/binary_sensor.py b/homeassistant/components/bosch_shc/binary_sensor.py index f77313556719..7f5dc3dd7052 100644 --- a/homeassistant/components/bosch_shc/binary_sensor.py +++ b/homeassistant/components/bosch_shc/binary_sensor.py @@ -37,6 +37,7 @@ async def async_setup_entry( entities: list[BinarySensorEntity] = [ ShutterContactSensor( + hass=hass, device=binary_sensor, parent_id=shc_info.unique_id, entry_id=config_entry.entry_id, @@ -49,6 +50,7 @@ async def async_setup_entry( entities.extend( BatterySensor( + hass=hass, device=binary_sensor, parent_id=shc_info.unique_id, entry_id=config_entry.entry_id, @@ -75,9 +77,11 @@ class ShutterContactSensor(SHCEntity, BinarySensorEntity): _attr_name = None _device: SHCShutterContact - def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + def __init__( + self, hass: HomeAssistant, device: SHCDevice, parent_id: str, entry_id: str + ) -> None: """Initialize an SHC shutter contact sensor.""" - super().__init__(device, parent_id, entry_id) + super().__init__(hass, device, parent_id, entry_id) switcher: dict[str | None, BinarySensorDeviceClass] = { "ENTRANCE_DOOR": BinarySensorDeviceClass.DOOR, "REGULAR_WINDOW": BinarySensorDeviceClass.WINDOW, @@ -101,9 +105,11 @@ class BatterySensor(SHCEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.BATTERY _device: SHCBatteryDevice - def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + def __init__( + self, hass: HomeAssistant, device: SHCDevice, parent_id: str, entry_id: str + ) -> None: """Initialize an SHC battery reporting sensor.""" - super().__init__(device, parent_id, entry_id) + super().__init__(hass, device, parent_id, entry_id) self._attr_unique_id = f"{device.serial}_battery" @property diff --git a/homeassistant/components/bosch_shc/cover.py b/homeassistant/components/bosch_shc/cover.py index 536c36786879..9512db0db494 100644 --- a/homeassistant/components/bosch_shc/cover.py +++ b/homeassistant/components/bosch_shc/cover.py @@ -33,6 +33,7 @@ async def async_setup_entry( async_add_entities( ShutterControlCover( + hass=hass, device=cover, parent_id=shc_info.unique_id, entry_id=config_entry.entry_id, diff --git a/homeassistant/components/bosch_shc/entity.py b/homeassistant/components/bosch_shc/entity.py index ae719deb95ae..07098cfcb88c 100644 --- a/homeassistant/components/bosch_shc/entity.py +++ b/homeassistant/components/bosch_shc/entity.py @@ -2,7 +2,7 @@ from typing import override -from boschshcpy import SHCDevice, SHCIntrusionSystem +from boschshcpy import SHCDevice from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -30,9 +30,7 @@ class SHCBaseEntity(Entity): _attr_should_poll = False _attr_has_entity_name = True - def __init__( - self, device: SHCDevice | SHCIntrusionSystem, parent_id: str, entry_id: str - ) -> None: + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize the generic SHC device.""" self._device = device self._entry_id = entry_id @@ -67,16 +65,25 @@ class SHCEntity(SHCBaseEntity): _device: SHCDevice - def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + def __init__( + self, hass: HomeAssistant, device: SHCDevice, parent_id: str, entry_id: str + ) -> None: """Initialize generic SHC device.""" self._attr_unique_id = device.serial - self._attr_device_info = DeviceInfo( + device_info = DeviceInfo( identifiers={(DOMAIN, device.id)}, manufacturer=device.manufacturer, model=device.device_model, name=device.name, - via_device=(DOMAIN, device.root_device_id), ) + # boschshcpy may render the hub identifier (shc_info.unique_id) and a + # device's root_device_id differently, so the lookup can miss; link only + # when it resolves instead of raising out of setup. + if hub := dr.async_get(hass).async_get_device_by_identifier( + (DOMAIN, device.root_device_id), entry_id + ): + device_info["via_device_id"] = hub.id + self._attr_device_info = device_info super().__init__(device=device, parent_id=parent_id, entry_id=entry_id) @override @@ -102,32 +109,3 @@ class SHCEntity(SHCBaseEntity): def available(self) -> bool: """Return false if status is unavailable.""" return self._device.status == "AVAILABLE" - - -class SHCDomainEntity(SHCBaseEntity): - """Representation of a SHC domain service entity.""" - - _device: SHCIntrusionSystem - - def __init__( - self, domain: SHCIntrusionSystem, parent_id: str, entry_id: str - ) -> None: - """Initialize the generic SHC device.""" - self._attr_unique_id = domain.id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, domain.id)}, - manufacturer=domain.manufacturer, - model=domain.device_model, - name=domain.name, - via_device=( - DOMAIN, - parent_id, - ), - ) - super().__init__(device=domain, parent_id=parent_id, entry_id=entry_id) - - @property - @override - def available(self) -> bool: - """Return false if status is unavailable.""" - return self._device.system_availability diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index 56da180b2ebf..114decd4880b 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/sensor.py @@ -212,6 +212,7 @@ async def async_setup_entry( entities: list[SensorEntity] = [ SHCSensor( + hass, device, description, shc_info.unique_id, @@ -226,6 +227,7 @@ async def async_setup_entry( entities.extend( SHCSensor( + hass, device, description, shc_info.unique_id, @@ -240,6 +242,7 @@ async def async_setup_entry( entities.extend( SHCSensor( + hass, device, description, shc_info.unique_id, @@ -263,6 +266,7 @@ async def async_setup_entry( ] entities.extend( SHCSensor( + hass, device, description, shc_info.unique_id, @@ -274,6 +278,7 @@ async def async_setup_entry( entities.extend( SHCSensor( + hass, device, description, shc_info.unique_id, @@ -297,13 +302,14 @@ class SHCSensor[_DeviceT: SHCDevice](SHCEntity, SensorEntity): def __init__( self, + hass: HomeAssistant, device: _DeviceT, entity_description: SHCSensorEntityDescription[_DeviceT], parent_id: str, entry_id: str, ) -> None: """Initialize sensor.""" - super().__init__(device, parent_id, entry_id) + super().__init__(hass, device, parent_id, entry_id) self._device: _DeviceT = device self.entity_description = entity_description self._attr_unique_id = f"{device.serial}_{entity_description.key}" diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index ac3f9e110353..091ca8e5a74f 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -89,6 +89,7 @@ async def async_setup_entry( entities: list[SwitchEntity] = [ SHCSwitch( + hass=hass, device=switch, parent_id=shc_info.unique_id, entry_id=config_entry.entry_id, @@ -99,6 +100,7 @@ async def async_setup_entry( entities.extend( SHCRoutingSwitch( + hass=hass, device=switch, parent_id=shc_info.unique_id, entry_id=config_entry.entry_id, @@ -108,6 +110,7 @@ async def async_setup_entry( entities.extend( SHCSwitch( + hass=hass, device=switch, parent_id=shc_info.unique_id, entry_id=config_entry.entry_id, @@ -118,6 +121,7 @@ async def async_setup_entry( entities.extend( SHCSwitch( + hass=hass, device=switch, parent_id=shc_info.unique_id, entry_id=config_entry.entry_id, @@ -128,6 +132,7 @@ async def async_setup_entry( entities.extend( SHCSwitch( + hass=hass, device=switch, parent_id=shc_info.unique_id, entry_id=config_entry.entry_id, @@ -138,6 +143,7 @@ async def async_setup_entry( entities.extend( SHCSwitch( + hass=hass, device=switch, parent_id=shc_info.unique_id, entry_id=config_entry.entry_id, @@ -156,13 +162,14 @@ class SHCSwitch(SHCEntity, SwitchEntity): def __init__( self, + hass: HomeAssistant, device: SHCDevice, parent_id: str, entry_id: str, description: SHCSwitchEntityDescription, ) -> None: """Initialize a SHC switch.""" - super().__init__(device, parent_id, entry_id) + super().__init__(hass, device, parent_id, entry_id) self.entity_description = description @property @@ -202,9 +209,11 @@ class SHCRoutingSwitch(SHCEntity, SwitchEntity): _attr_entity_category = EntityCategory.CONFIG _device: SHCSmartPlug - def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + def __init__( + self, hass: HomeAssistant, device: SHCDevice, parent_id: str, entry_id: str + ) -> None: """Initialize an SHC routing switch.""" - super().__init__(device, parent_id, entry_id) + super().__init__(hass, device, parent_id, entry_id) self._attr_unique_id = f"{device.serial}_routing" @property diff --git a/tests/components/bosch_shc/test_entity.py b/tests/components/bosch_shc/test_entity.py new file mode 100644 index 000000000000..63904273ae28 --- /dev/null +++ b/tests/components/bosch_shc/test_entity.py @@ -0,0 +1,78 @@ +"""Tests for the Bosch SHC entity base classes.""" + +from unittest.mock import MagicMock + +from boschshcpy import SHCDevice + +from homeassistant.components.bosch_shc.const import DOMAIN +from homeassistant.components.bosch_shc.entity import SHCEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +async def test_shc_entity_via_device_id( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test SHCEntity links its device to the SHC hub via via_device_id.""" + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + hub_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "root-serial")}, + manufacturer="Bosch", + name="Bosch SHC", + model="SmartHomeController", + ) + + device = MagicMock(spec=SHCDevice) + device.serial = "child-serial" + device.manufacturer = "Bosch" + device.device_model = "SWD" + device.name = "Shutter Contact" + device.id = "child-id" + device.root_device_id = "root-serial" + + entity = SHCEntity( + hass=hass, device=device, parent_id="root-serial", entry_id=entry.entry_id + ) + + assert entity.device_info is not None + assert entity.device_info["via_device_id"] == hub_device.id + + +async def test_shc_entity_via_device_id_mismatch( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test SHCEntity sets up without a link when the hub identifier does not match. + + boschshcpy may render the hub identifier and a device's root_device_id + differently, so the lookup can miss; setup must not crash in that case. + """ + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "hub-serial")}, + manufacturer="Bosch", + name="Bosch SHC", + model="SmartHomeController", + ) + + device = MagicMock(spec=SHCDevice) + device.serial = "child-serial" + device.manufacturer = "Bosch" + device.device_model = "SWD" + device.name = "Shutter Contact" + device.id = "child-id" + device.root_device_id = "root-serial-mismatch" + + entity = SHCEntity( + hass=hass, device=device, parent_id="hub-serial", entry_id=entry.entry_id + ) + + assert entity.device_info is not None + assert "via_device_id" not in entity.device_info From fb75ab0eacf7c3611b79953f453523155891a620 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 17:48:33 +0200 Subject: [PATCH 099/109] Adapt velbus to set via_device_id in DeviceInfo (#178081) --- homeassistant/components/velbus/entity.py | 44 ++++++++++++++--------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py index d6bacaa242ba..f7374caa504f 100644 --- a/homeassistant/components/velbus/entity.py +++ b/homeassistant/components/velbus/entity.py @@ -2,12 +2,13 @@ from collections.abc import Awaitable, Callable, Coroutine from functools import wraps -from typing import Any, Concatenate, override +from typing import TYPE_CHECKING, Any, Concatenate, override from velbusaio.channels import Channel as VelbusChannel from velbusaio.properties import Property as VelbusProperty from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -31,22 +32,6 @@ class VelbusEntity(Entity): self._channel = channel self._module_address = str(channel.get_module_address()) self._attr_name = channel.get_name() - self._attr_device_info = DeviceInfo( - identifiers={ - (DOMAIN, self._get_identifier()), - }, - manufacturer="Velleman", - model=channel.get_module_type_name(), - model_id=str(channel.get_module_type()), - name=channel.get_full_name(), - sw_version=channel.get_module_sw_version(), - serial_number=channel.get_module_serial(), - ) - if self._channel.is_sub_device(): - self._attr_device_info["via_device"] = ( - DOMAIN, - self._module_address, - ) serial = channel.get_module_serial() or self._module_address self._attr_unique_id = f"{serial}-{channel.get_channel_number()}" @@ -56,6 +41,31 @@ class VelbusEntity(Entity): return self._module_address return f"{self._module_address}-{self._channel.get_channel_number()}" + @property + @override + def device_info(self) -> DeviceInfo: + """Return device info, linking a sub-device to its module device.""" + channel = self._channel + device_info = DeviceInfo( + identifiers={(DOMAIN, self._get_identifier())}, + manufacturer="Velleman", + model=channel.get_module_type_name(), + model_id=str(channel.get_module_type()), + name=channel.get_full_name(), + sw_version=channel.get_module_sw_version(), + serial_number=channel.get_module_serial(), + ) + if channel.is_sub_device(): + config_entry = self.platform.config_entry + if TYPE_CHECKING: + assert config_entry + device_info["via_device_id"] = dr.async_get_device_id_by_identifier( + self.hass, + (DOMAIN, self._module_address), + config_entry_id=config_entry.entry_id, + ) + return device_info + @override async def async_added_to_hass(self) -> None: """Add listener for state changes.""" From ab7553aef8e0a324b284e4a305aac733ebce3a9a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 17:49:06 +0200 Subject: [PATCH 100/109] Adapt wmspro to set via_device_id in DeviceInfo (#177852) --- homeassistant/components/wmspro/button.py | 6 ++++-- homeassistant/components/wmspro/cover.py | 14 +++++++++----- homeassistant/components/wmspro/entity.py | 10 ++++++++-- homeassistant/components/wmspro/light.py | 4 ++-- homeassistant/components/wmspro/number.py | 16 ++++++++++++---- homeassistant/components/wmspro/scene.py | 11 ++++++++--- homeassistant/components/wmspro/switch.py | 2 +- tests/components/wmspro/test_init.py | 6 ++++++ 8 files changed, 50 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/wmspro/button.py b/homeassistant/components/wmspro/button.py index 450f58571095..aa953fb534a1 100644 --- a/homeassistant/components/wmspro/button.py +++ b/homeassistant/components/wmspro/button.py @@ -24,10 +24,12 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [] for dest in hub.dests.values(): if dest.hasAction(ACTION_DESC.Identify): - entities.append(WebControlProIdentifyButton(config_entry.entry_id, dest)) + entities.append( + WebControlProIdentifyButton(hass, config_entry.entry_id, dest) + ) if dest.hasAction(ACTION_DESC.SlatRotate): entities.append( - WebControlProRotationResetButton(config_entry.entry_id, dest) + WebControlProRotationResetButton(hass, config_entry.entry_id, dest) ) async_add_entities(entities) diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index 15c4acb4fd31..4777a2a7892e 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -40,16 +40,20 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [] for dest in hub.dests.values(): if dest.hasAction(ACTION_DESC.AwningDrive): - entities.append(WebControlProAwning(config_entry.entry_id, dest)) + entities.append(WebControlProAwning(hass, config_entry.entry_id, dest)) if dest.hasAction(ACTION_DESC.ValanceDrive): - entities.append(WebControlProValance(config_entry.entry_id, dest)) + entities.append(WebControlProValance(hass, config_entry.entry_id, dest)) elif dest.hasAction(ACTION_DESC.RollerShutterBlindDrive): - entities.append(WebControlProRollerShutter(config_entry.entry_id, dest)) + entities.append( + WebControlProRollerShutter(hass, config_entry.entry_id, dest) + ) elif dest.hasAction(ACTION_DESC.SlatDrive): if dest.hasAction(ACTION_DESC.SlatRotate): - entities.append(WebControlProSlatRotate(config_entry.entry_id, dest)) + entities.append( + WebControlProSlatRotate(hass, config_entry.entry_id, dest) + ) else: - entities.append(WebControlProSlat(config_entry.entry_id, dest)) + entities.append(WebControlProSlat(hass, config_entry.entry_id, dest)) async_add_entities(entities) diff --git a/homeassistant/components/wmspro/entity.py b/homeassistant/components/wmspro/entity.py index 81346ce1e491..4ac92009db2e 100644 --- a/homeassistant/components/wmspro/entity.py +++ b/homeassistant/components/wmspro/entity.py @@ -4,6 +4,8 @@ from typing import override from wmspro.destination import Destination +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -16,7 +18,9 @@ class WebControlProGenericEntity(Entity): _attr_attribution = ATTRIBUTION _attr_has_entity_name = True - def __init__(self, config_entry_id: str, dest: Destination) -> None: + def __init__( + self, hass: HomeAssistant, config_entry_id: str, dest: Destination + ) -> None: """Initialize the entity with destination channel.""" dest_id_str = str(dest.id) self._dest = dest @@ -30,7 +34,9 @@ class WebControlProGenericEntity(Entity): name=dest.name, serial_number=dest_id_str, suggested_area=dest.room.name, - via_device=(DOMAIN, config_entry_id), + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, config_entry_id), config_entry_id=config_entry_id + ), configuration_url=f"http://{dest.host}/control", ) diff --git a/homeassistant/components/wmspro/light.py b/homeassistant/components/wmspro/light.py index a58e784f56f8..698d9aa19b60 100644 --- a/homeassistant/components/wmspro/light.py +++ b/homeassistant/components/wmspro/light.py @@ -32,9 +32,9 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [] for dest in hub.dests.values(): if dest.hasAction(ACTION_DESC.LightDimming): - entities.append(WebControlProDimmer(config_entry.entry_id, dest)) + entities.append(WebControlProDimmer(hass, config_entry.entry_id, dest)) elif dest.hasAction(ACTION_DESC.LightSwitch): - entities.append(WebControlProLight(config_entry.entry_id, dest)) + entities.append(WebControlProLight(hass, config_entry.entry_id, dest)) async_add_entities(entities) diff --git a/homeassistant/components/wmspro/number.py b/homeassistant/components/wmspro/number.py index b6b25cbfb22e..3b5df5c8c55a 100644 --- a/homeassistant/components/wmspro/number.py +++ b/homeassistant/components/wmspro/number.py @@ -29,12 +29,20 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [] for dest in hub.dests.values(): if dest.hasAction(ACTION_DESC.SlatRotate): - entities.append(WebControlProSlatRangeMin(config_entry.entry_id, dest)) - entities.append(WebControlProSlatRangeMax(config_entry.entry_id, dest)) - entities.append(WebControlProSlatRotationRaw(config_entry.entry_id, dest)) + entities.append( + WebControlProSlatRangeMin(hass, config_entry.entry_id, dest) + ) + entities.append( + WebControlProSlatRangeMax(hass, config_entry.entry_id, dest) + ) + entities.append( + WebControlProSlatRotationRaw(hass, config_entry.entry_id, dest) + ) if not dest.hasAction(ACTION_DESC.SlatDrive): # Only add the numeric slat rotation entity if no cover entity exists - entities.append(WebControlProSlatRotation(config_entry.entry_id, dest)) + entities.append( + WebControlProSlatRotation(hass, config_entry.entry_id, dest) + ) async_add_entities(entities) diff --git a/homeassistant/components/wmspro/scene.py b/homeassistant/components/wmspro/scene.py index ec6ac192e6d5..2f7917d037d8 100644 --- a/homeassistant/components/wmspro/scene.py +++ b/homeassistant/components/wmspro/scene.py @@ -6,6 +6,7 @@ from wmspro.scene import Scene as WMS_Scene from homeassistant.components.scene import Scene from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -22,7 +23,7 @@ async def async_setup_entry( hub = config_entry.runtime_data async_add_entities( - WebControlProScene(config_entry.entry_id, scene) + WebControlProScene(hass, config_entry.entry_id, scene) for scene in hub.scenes.values() ) @@ -33,7 +34,9 @@ class WebControlProScene(Scene): _attr_attribution = ATTRIBUTION _attr_has_entity_name = True - def __init__(self, config_entry_id: str, scene: WMS_Scene) -> None: + def __init__( + self, hass: HomeAssistant, config_entry_id: str, scene: WMS_Scene + ) -> None: """Initialize the entity with the configured scene.""" super().__init__() @@ -53,7 +56,9 @@ class WebControlProScene(Scene): name=room_name, serial_number=room_id_str, suggested_area=room_name, - via_device=(DOMAIN, config_entry_id), + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, config_entry_id), config_entry_id=config_entry_id + ), configuration_url=f"http://{scene.host}/control", ) diff --git a/homeassistant/components/wmspro/switch.py b/homeassistant/components/wmspro/switch.py index f681c56a5b47..98b52d95bee8 100644 --- a/homeassistant/components/wmspro/switch.py +++ b/homeassistant/components/wmspro/switch.py @@ -28,7 +28,7 @@ async def async_setup_entry( hub = config_entry.runtime_data async_add_entities( - WebControlProSwitch(config_entry.entry_id, dest) + WebControlProSwitch(hass, config_entry.entry_id, dest) for dest in hub.dests.values() if dest.hasAction(ACTION_DESC.LoadSwitch) ) diff --git a/tests/components/wmspro/test_init.py b/tests/components/wmspro/test_init.py index 948dfaf3a1e9..53653415ddf3 100644 --- a/tests/components/wmspro/test_init.py +++ b/tests/components/wmspro/test_init.py @@ -96,6 +96,11 @@ async def test_device_setup( ) assert len(device_entries) > len(mock_hub_configuration.destinations) + hub_device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, mock_config_entry.entry_id), mock_config_entry.entry_id + ) + assert hub_device_entry is not None + device_entries = list( filter( lambda e: e.identifiers != {(DOMAIN, mock_config_entry.entry_id)}, @@ -104,4 +109,5 @@ async def test_device_setup( ) assert len(device_entries) >= len(mock_hub_configuration.destinations) for device_entry in device_entries: + assert device_entry.via_device_id == hub_device_entry.id assert device_entry == snapshot(name=f"device-{device_entry.serial_number}") From 406d37b0b7b1d7a75edd672705c4e212a64c4c6f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 17:50:33 +0200 Subject: [PATCH 101/109] Fix via_device race in guardian (#177717) --- homeassistant/components/guardian/__init__.py | 13 ++++++ homeassistant/components/guardian/entity.py | 7 ++- tests/components/guardian/conftest.py | 11 ++++- tests/components/guardian/test_init.py | 45 +++++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 tests/components/guardian/test_init.py diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index afc47be08a3d..300308eaa71d 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -115,6 +115,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: GuardianConfigEntry) -> paired_sensor_manager=paired_sensor_manager, ) + # Register the valve controller device up front so paired sensors can resolve + # it as their via device when they are added: + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.data[CONF_UID])}, + manufacturer="Elexa", + name=f"Guardian valve controller {entry.data[CONF_UID]}", + sw_version=valve_controller_coordinators[API_SYSTEM_DIAGNOSTICS].data[ + "firmware" + ], + ) + # Set up all of the Guardian entity platforms: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/guardian/entity.py b/homeassistant/components/guardian/entity.py index 6f4c4f964e49..e8ce957a706c 100644 --- a/homeassistant/components/guardian/entity.py +++ b/homeassistant/components/guardian/entity.py @@ -2,6 +2,7 @@ from dataclasses import dataclass +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -43,7 +44,11 @@ class PairedSensorEntity(GuardianEntity): manufacturer="Elexa", model=coordinator.data["codename"], name=f"Guardian paired sensor {paired_sensor_uid}", - via_device=(DOMAIN, entry.data[CONF_UID]), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, entry.data[CONF_UID]), + config_entry_id=entry.entry_id, + ), ) self._attr_unique_id = f"{paired_sensor_uid}_{description.key}" diff --git a/tests/components/guardian/conftest.py b/tests/components/guardian/conftest.py index 61813cb1df55..2b9fd3aff814 100644 --- a/tests/components/guardian/conftest.py +++ b/tests/components/guardian/conftest.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch import pytest from homeassistant.components.guardian import CONF_UID, DOMAIN -from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.json import JsonObjectType @@ -97,10 +97,17 @@ def data_wifi_status_fixture() -> JsonObjectType: return load_json_object_fixture("wifi_status_data.json", "guardian") +@pytest.fixture(name="platforms") +def platforms_fixture() -> list[Platform]: + """Define a platforms fixture.""" + return [] + + @pytest.fixture(name="setup_guardian") async def setup_guardian_fixture( hass: HomeAssistant, config: dict[str, Any], + platforms: list[Platform], data_sensor_pair_dump: JsonObjectType, data_sensor_pair_sensor: JsonObjectType, data_sensor_paired_sensor_status: JsonObjectType, @@ -150,7 +157,7 @@ async def setup_guardian_fixture( ), patch( "homeassistant.components.guardian.PLATFORMS", - [], + platforms, ), ): assert await async_setup_component(hass, DOMAIN, config) diff --git a/tests/components/guardian/test_init.py b/tests/components/guardian/test_init.py new file mode 100644 index 000000000000..e2550a6a5dc8 --- /dev/null +++ b/tests/components/guardian/test_init.py @@ -0,0 +1,45 @@ +"""Test the Elexa Guardian init module.""" + +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.guardian import CONF_UID, DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.fixture(name="platforms") +def platforms_fixture() -> list[Platform]: + """Override to exercise the paired sensor entity setup.""" + return [Platform.SENSOR] + + +async def test_paired_sensor_via_device_id( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + setup_guardian: None, # relies on config_entry fixture +) -> None: + """Test that a paired sensor is linked to the valve controller via via_device_id.""" + # Advance time so the sensor pair dump coordinator refreshes and discovers the + # paired sensor from the fixture data: + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + valve_controller_device = device_registry.async_get_device_by_identifier( + (DOMAIN, config_entry.data[CONF_UID]), config_entry.entry_id + ) + paired_sensor_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "AABBCCDDEEFF"), config_entry.entry_id + ) + + assert valve_controller_device is not None + assert paired_sensor_device is not None + assert paired_sensor_device.via_device_id == valve_controller_device.id From addeac388e42220f9aaa5889db4cc7570d8ceebe Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 18:07:21 +0200 Subject: [PATCH 102/109] Fix via_device race in homewizard (#177934) --- .../components/homewizard/__init__.py | 9 +++++ homeassistant/components/homewizard/entity.py | 31 +++++++++-------- homeassistant/components/homewizard/sensor.py | 13 +++++--- tests/components/homewizard/test_init.py | 33 +++++++++++++++++++ tests/components/homewizard/test_sensor.py | 22 +++++++++++-- 5 files changed, 88 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index 50ca88bca4a0..d872d2b7572b 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -17,6 +17,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from .const import DOMAIN, PLATFORMS from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator +from .entity import create_main_device_info async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) -> bool: @@ -60,6 +61,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) - ): hass.config_entries.flow.async_abort(progress_flow["flow_id"]) + # Register the main device up front so external sub-device sensors can + # resolve it as their via_device parent regardless of entity add order. + if coordinator.data.device.serial is not None: + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + **create_main_device_info(coordinator), + ) + # Finalize entry.async_on_unload(coordinator.api.close) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/homewizard/entity.py b/homeassistant/components/homewizard/entity.py index 3523f4b9721d..26dbc1032a19 100644 --- a/homeassistant/components/homewizard/entity.py +++ b/homeassistant/components/homewizard/entity.py @@ -8,6 +8,23 @@ from .const import DOMAIN from .coordinator import HWEnergyDeviceUpdateCoordinator +def create_main_device_info( + coordinator: HWEnergyDeviceUpdateCoordinator, +) -> DeviceInfo: + """Return the device info for the main HomeWizard device.""" + device_info = DeviceInfo( + manufacturer="HomeWizard", + sw_version=coordinator.data.device.firmware_version, + model_id=coordinator.data.device.product_type, + model=coordinator.data.device.model_name, + ) + if (serial_number := coordinator.data.device.serial) is not None: + device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, serial_number)} + device_info[ATTR_IDENTIFIERS] = {(DOMAIN, serial_number)} + device_info[ATTR_SERIAL_NUMBER] = serial_number + return device_info + + class HomeWizardEntity(CoordinatorEntity[HWEnergyDeviceUpdateCoordinator]): """Defines a HomeWizard entity.""" @@ -16,16 +33,4 @@ class HomeWizardEntity(CoordinatorEntity[HWEnergyDeviceUpdateCoordinator]): def __init__(self, coordinator: HWEnergyDeviceUpdateCoordinator) -> None: """Initialize the HomeWizard entity.""" super().__init__(coordinator) - self._attr_device_info = DeviceInfo( - manufacturer="HomeWizard", - sw_version=coordinator.data.device.firmware_version, - model_id=coordinator.data.device.product_type, - model=coordinator.data.device.model_name, - ) - - if (serial_number := coordinator.data.device.serial) is not None: - self._attr_device_info[ATTR_CONNECTIONS] = { - (CONNECTION_NETWORK_MAC, serial_number) - } - self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, serial_number)} - self._attr_device_info[ATTR_SERIAL_NUMBER] = serial_number + self._attr_device_info = create_main_device_info(coordinator) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 9e632da96350..e36799113da2 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -16,7 +16,6 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_VIA_DEVICE, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, @@ -31,7 +30,10 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import ( + DeviceInfo, + async_get_device_id_by_identifier, +) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow @@ -839,9 +841,10 @@ class HomeWizardExternalSensorEntity(HomeWizardEntity, SensorEntity): serial_number=device_unique_id, ) if coordinator.data.device.serial is not None: - self._attr_device_info[ATTR_VIA_DEVICE] = ( - DOMAIN, - coordinator.data.device.serial, + self._attr_device_info["via_device_id"] = async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.data.device.serial), + config_entry_id=coordinator.config_entry.entry_id, ) @property diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index 3eba5a9b0dc7..51fc8cd4cd60 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -14,6 +14,7 @@ from homeassistant.components.homewizard.const import ( battery_mode_cloud_issue_id, ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, issue_registry as ir @@ -324,3 +325,35 @@ async def test_battery_cloud_issue_stale_issue_cleared_on_reload( await hass.config_entries.async_reload(mock_config_entry.entry_id) await hass.async_block_till_done() assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None # pylint: disable=home-assistant-tests-registry-fixtures + + +async def test_main_device_registered_before_platform_forwarding( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_homewizardenergy: MagicMock, +) -> None: + """Test the main device is registered before platforms are set up. + + Restricting setup to the sensor platform removes the button platform, + whose main-device entity would otherwise register the main device + before the external sensor needs it, masking a missing up-front + registration. + """ + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.homewizard.PLATFORMS", [Platform.SENSOR]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + main_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "5c2fafabcdef"), mock_config_entry.entry_id + ) + assert main_device is not None + + gas_meter_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "gas_meter_G001"), mock_config_entry.entry_id + ) + assert gas_meter_device is not None + assert gas_meter_device.via_device_id == main_device.id diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index a256bcde4baa..cd823934c95d 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -7,13 +7,13 @@ from homewizard_energy.models import CombinedModels, Measurement, State, System import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.homewizard.const import UPDATE_INTERVAL +from homeassistant.components.homewizard.const import DOMAIN, UPDATE_INTERVAL from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed pytestmark = [ pytest.mark.usefixtures("init_integration"), @@ -393,6 +393,24 @@ async def test_sensors( assert snapshot(name=f"{entity_id}:device-registry") == device_entry +async def test_external_device_via_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + init_integration: MockConfigEntry, +) -> None: + """Test an external device is linked to the main device via via_device_id.""" + main_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "5c2fafabcdef"), init_integration.entry_id + ) + assert main_device is not None + + gas_meter_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "gas_meter_G001"), init_integration.entry_id + ) + assert gas_meter_device is not None + assert gas_meter_device.via_device_id == main_device.id + + @pytest.mark.parametrize( ("device_fixture", "entity_ids"), [ From 16b81bfd5789552769b1be2b0567825bf8cb01dd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 18:07:40 +0200 Subject: [PATCH 103/109] Fix via_device race in lyric (#177935) --- homeassistant/components/lyric/__init__.py | 13 +++ homeassistant/components/lyric/entity.py | 25 +++-- tests/components/lyric/test_sensor.py | 104 +++++++++++++++++++++ 3 files changed, 134 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index af610f127372..54c843ae11f4 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -9,6 +9,7 @@ from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, config_validation as cv, + device_registry as dr, ) from .api import ( @@ -18,6 +19,7 @@ from .api import ( ) from .const import DOMAIN from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator +from .entity import create_thermostat_device_info CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -59,6 +61,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: LyricConfigEntry) -> boo await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator + # Register the thermostat devices up front so the accessory (room sensor) + # entities can resolve them as their via_device parent regardless of the + # order the platforms are set up in. + device_registry = dr.async_get(hass) + for location in coordinator.data.locations: + for device in location.devices: + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + **create_thermostat_device_info(device), + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/lyric/entity.py b/homeassistant/components/lyric/entity.py index 151de2c8d539..588fdbffa3eb 100644 --- a/homeassistant/components/lyric/entity.py +++ b/homeassistant/components/lyric/entity.py @@ -50,6 +50,17 @@ class LyricEntity(CoordinatorEntity[LyricDataUpdateCoordinator]): return self.location.devices_dict[self._mac_id] +def create_thermostat_device_info(device: LyricDevice) -> DeviceInfo: + """Return the device info for a Lyric thermostat.""" + return DeviceInfo( + identifiers={(dr.CONNECTION_NETWORK_MAC, device.mac_id)}, + connections={(dr.CONNECTION_NETWORK_MAC, device.mac_id)}, + manufacturer="Honeywell", + model=device.device_model, + name=f"{device.name} Thermostat", + ) + + class LyricDeviceEntity(LyricEntity): """Defines a Honeywell Lyric device entity.""" @@ -57,13 +68,7 @@ class LyricDeviceEntity(LyricEntity): @override def device_info(self) -> DeviceInfo: """Return device information about this Honeywell Lyric instance.""" - return DeviceInfo( - identifiers={(dr.CONNECTION_NETWORK_MAC, self._mac_id)}, - connections={(dr.CONNECTION_NETWORK_MAC, self._mac_id)}, - manufacturer="Honeywell", - model=self.device.device_model, - name=f"{self.device.name} Thermostat", - ) + return create_thermostat_device_info(self.device) class LyricAccessoryEntity(LyricDeviceEntity): @@ -97,7 +102,11 @@ class LyricAccessoryEntity(LyricDeviceEntity): manufacturer="Honeywell", model="RCHTSENSOR", name=f"{self.room.room_name} Sensor", - via_device=(dr.CONNECTION_NETWORK_MAC, self._mac_id), + via_device_id=dr.async_get_device_id_by_identifier( + self.hass, + (dr.CONNECTION_NETWORK_MAC, self._mac_id), + config_entry_id=self.coordinator.config_entry.entry_id, + ), ) @property diff --git a/tests/components/lyric/test_sensor.py b/tests/components/lyric/test_sensor.py index 7f74b5e8d63e..f01107a812a6 100644 --- a/tests/components/lyric/test_sensor.py +++ b/tests/components/lyric/test_sensor.py @@ -1,8 +1,23 @@ """Tests for the Honeywell Lyric sensor platform.""" from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch +from aiolyric import Lyric +from aiolyric.objects.location import LyricLocation +from aiolyric.objects.priority import LyricRoom + +from homeassistant.components.lyric.api import LyricLocalOAuth2Implementation +from homeassistant.components.lyric.const import DOMAIN from homeassistant.components.lyric.sensor import get_datetime_from_future_time +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + +_MAC = "AABBCCDDEEFF" def test_get_datetime_from_future_time_none() -> None: @@ -19,3 +34,92 @@ def test_get_datetime_from_future_time_valid() -> None: """Test that a valid time string returns a datetime.""" result = get_datetime_from_future_time("13:30:00") assert isinstance(result, datetime) + + +def _mock_lyric() -> MagicMock: + """Build a fake aiolyric Lyric client with one thermostat and one room accessory.""" + client = MagicMock() + location = LyricLocation( + client, + { + "locationID": 1234, + "name": "Home", + "devices": [ + # A thermostat with no device-level sensors: the sensor platform + # creates no thermostat entity, so the thermostat device can only + # come from the up-front registration in async_setup_entry. + { + "deviceID": f"LCC-{_MAC}", + "deviceClass": "Thermostat", + "macID": _MAC, + "name": "Thermostat", + "deviceModel": "T5-T6", + } + ], + }, + ) + room = LyricRoom( + { + "id": 1, + "roomName": "Living Room", + "roomAvgTemp": 21, + "roomAvgHumidity": 40, + "accessories": [{"id": 1, "type": "IndoorAirSensor", "temperature": 21}], + } + ) + + lyric = MagicMock(spec=Lyric) + lyric.get_locations = AsyncMock() + lyric.get_thermostat_rooms = AsyncMock() + lyric.locations = [location] + lyric.locations_dict = {1234: location} + lyric.rooms_dict = {_MAC: {1: room}} + return lyric + + +async def test_accessory_links_to_thermostat_via_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test room accessory devices resolve the thermostat as their via_device.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": 9999999999, + "token_type": "Bearer", + }, + }, + ) + entry.add_to_hass(hass) + + implementation = MagicMock(spec=LyricLocalOAuth2Implementation) + implementation.client_id = "client-id" + + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow." + "async_get_config_entry_implementation", + return_value=implementation, + ), + patch("homeassistant.components.lyric.PLATFORMS", [Platform.SENSOR]), + patch("homeassistant.components.lyric.Lyric", return_value=_mock_lyric()), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + thermostat = device_registry.async_get_device_by_identifier( + (dr.CONNECTION_NETWORK_MAC, _MAC), entry.entry_id + ) + assert thermostat is not None + + accessory = device_registry.async_get_device_by_identifier( + (f"{dr.CONNECTION_NETWORK_MAC}_room_accessory", f"{_MAC}_room1_accessory1"), + entry.entry_id, + ) + assert accessory is not None + assert accessory.via_device_id == thermostat.id From c5ce6efa45b7e6527bb2d7cd8f4198fc21878637 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 18:10:58 +0200 Subject: [PATCH 104/109] Migrate home_connect dhcp flow to call async_get_devices (#177972) --- homeassistant/components/home_connect/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/home_connect/config_flow.py b/homeassistant/components/home_connect/config_flow.py index dc8025c51369..897416b156b6 100644 --- a/homeassistant/components/home_connect/config_flow.py +++ b/homeassistant/components/home_connect/config_flow.py @@ -68,14 +68,14 @@ class OAuth2FlowHandler( ) -> ConfigFlowResult: """Handle a DHCP discovery.""" device_registry = dr.async_get(self.hass) - if device_entry := device_registry.async_get_device( + for device in device_registry.async_get_devices( identifiers={ (DOMAIN, discovery_info.hostname), (DOMAIN, discovery_info.hostname.split("-")[-1]), } ): device_registry.async_update_device( - device_entry.id, + device.id, new_connections={ (dr.CONNECTION_NETWORK_MAC, discovery_info.macaddress) }, From a80e856ea83aad9b4b7aec501d4c915fc97eb947 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 18:12:07 +0200 Subject: [PATCH 105/109] Adapt hive to set via_device_id in DeviceInfo (#177720) --- .../components/hive/binary_sensor.py | 12 ++- homeassistant/components/hive/climate.py | 14 +++- homeassistant/components/hive/entity.py | 21 ++++- homeassistant/components/hive/light.py | 14 +++- homeassistant/components/hive/sensor.py | 6 +- homeassistant/components/hive/switch.py | 6 +- homeassistant/components/hive/water_heater.py | 4 +- tests/components/hive/test_init.py | 77 +++++++++++++++---- 8 files changed, 122 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py index cdf6c253916c..fc931bebff05 100644 --- a/homeassistant/components/hive/binary_sensor.py +++ b/homeassistant/components/hive/binary_sensor.py @@ -79,7 +79,7 @@ async def async_setup_entry( devices = hive.session.deviceList.get("binary_sensor") sensors.extend( - HiveBinarySensorEntity(hive, dev, description) + HiveBinarySensorEntity(hass, entry, hive, dev, description) for dev in devices for description in BINARY_SENSOR_TYPES if dev["hiveType"] == description.key @@ -87,7 +87,7 @@ async def async_setup_entry( devices = hive.session.deviceList.get("sensor") sensors.extend( - HiveSensorEntity(hive, dev, description) + HiveSensorEntity(hass, entry, hive, dev, description) for dev in devices for description in SENSOR_TYPES if dev["hiveType"] == description.key @@ -101,12 +101,14 @@ class HiveBinarySensorEntity(HiveEntity, BinarySensorEntity): def __init__( self, + hass: HomeAssistant, + entry: HiveConfigEntry, hive: Hive, hive_device: dict[str, Any], entity_description: BinarySensorEntityDescription, ) -> None: """Initialise hive binary sensor.""" - super().__init__(hive, hive_device) + super().__init__(hass, entry, hive, hive_device) self.entity_description = entity_description async def async_update(self) -> None: @@ -131,12 +133,14 @@ class HiveSensorEntity(HiveEntity, BinarySensorEntity): def __init__( self, + hass: HomeAssistant, + entry: HiveConfigEntry, hive: Hive, hive_device: dict[str, Any], entity_description: BinarySensorEntityDescription, ) -> None: """Initialise hive sensor.""" - super().__init__(hive, hive_device) + super().__init__(hass, entry, hive, hive_device) self.entity_description = entity_description async def async_update(self) -> None: diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py index 3e636d825147..e057c6e84cd2 100644 --- a/homeassistant/components/hive/climate.py +++ b/homeassistant/components/hive/climate.py @@ -61,7 +61,9 @@ async def async_setup_entry( hive = entry.runtime_data devices = hive.session.deviceList.get("climate") if devices: - async_add_entities((HiveClimateEntity(hive, dev) for dev in devices), True) + async_add_entities( + (HiveClimateEntity(hass, entry, hive, dev) for dev in devices), True + ) platform = entity_platform.async_get_current_platform() @@ -97,9 +99,15 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): | ClimateEntityFeature.TURN_ON ) - def __init__(self, hive: Hive, hive_device: dict[str, Any]) -> None: + def __init__( + self, + hass: HomeAssistant, + entry: HiveConfigEntry, + hive: Hive, + hive_device: dict[str, Any], + ) -> None: """Initialize the Climate device.""" - super().__init__(hive, hive_device) + super().__init__(hass, entry, hive, hive_device) self.thermostat_node_id = hive_device["device_id"] self._attr_temperature_unit = TEMP_UNIT[hive_device["temperatureunit"]] diff --git a/homeassistant/components/hive/entity.py b/homeassistant/components/hive/entity.py index 474ddac73ece..1b0c85cc4332 100644 --- a/homeassistant/components/hive/entity.py +++ b/homeassistant/components/hive/entity.py @@ -1,20 +1,31 @@ """Support for the Hive devices and services.""" -from typing import Any, override +from typing import TYPE_CHECKING, Any, override from apyhiveapi import Hive +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from .const import DOMAIN +if TYPE_CHECKING: + from . import HiveConfigEntry + class HiveEntity(Entity): """Initiate Hive Base Class.""" - def __init__(self, hive: Hive, hive_device: dict[str, Any]) -> None: + def __init__( + self, + hass: HomeAssistant, + entry: HiveConfigEntry, + hive: Hive, + hive_device: dict[str, Any], + ) -> None: """Initialize the instance.""" self.hive = hive self.device = hive_device @@ -26,7 +37,11 @@ class HiveEntity(Entity): manufacturer=self.device["deviceData"]["manufacturer"], name=self.device["device_name"], sw_version=self.device["deviceData"]["version"], - via_device=(DOMAIN, self.device["parentDevice"]), + via_device_id=dr.async_get_device_id_by_identifier( + hass, + (DOMAIN, self.device["parentDevice"]), + config_entry_id=entry.entry_id, + ), ) self.attributes: dict[str, Any] = {} diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index 48c82638fdd0..773b5f50a5e5 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -35,7 +35,9 @@ async def async_setup_entry( devices = hive.session.deviceList.get("light") if not devices: return - async_add_entities((HiveDeviceLight(hive, dev) for dev in devices), True) + async_add_entities( + (HiveDeviceLight(hass, entry, hive, dev) for dev in devices), True + ) class HiveDeviceLight(HiveEntity, LightEntity): @@ -44,9 +46,15 @@ class HiveDeviceLight(HiveEntity, LightEntity): _attr_min_color_temp_kelvin = 2700 # 370 Mireds _attr_max_color_temp_kelvin = 6500 # 153 Mireds - def __init__(self, hive: Hive, hive_device: dict[str, Any]) -> None: + def __init__( + self, + hass: HomeAssistant, + entry: HiveConfigEntry, + hive: Hive, + hive_device: dict[str, Any], + ) -> None: """Initialise hive light.""" - super().__init__(hive, hive_device) + super().__init__(hass, entry, hive, hive_device) if self.device["hiveType"] == "warmwhitelight": self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} self._attr_color_mode = ColorMode.BRIGHTNESS diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py index 70a21038d67b..6e13046abb7c 100644 --- a/homeassistant/components/hive/sensor.py +++ b/homeassistant/components/hive/sensor.py @@ -99,7 +99,7 @@ async def async_setup_entry( return async_add_entities( ( - HiveSensorEntity(hive, dev, description) + HiveSensorEntity(hass, entry, hive, dev, description) for dev in devices for description in SENSOR_TYPES if dev["hiveType"] == description.key @@ -115,12 +115,14 @@ class HiveSensorEntity(HiveEntity, SensorEntity): def __init__( self, + hass: HomeAssistant, + entry: HiveConfigEntry, hive: Hive, hive_device: dict[str, Any], entity_description: HiveSensorEntityDescription, ) -> None: """Initialise hive sensor.""" - super().__init__(hive, hive_device) + super().__init__(hass, entry, hive, hive_device) self.entity_description = entity_description async def async_update(self) -> None: diff --git a/homeassistant/components/hive/switch.py b/homeassistant/components/hive/switch.py index 22fb848dcf9d..50db7a22dd9d 100644 --- a/homeassistant/components/hive/switch.py +++ b/homeassistant/components/hive/switch.py @@ -41,7 +41,7 @@ async def async_setup_entry( return async_add_entities( ( - HiveSwitch(hive, dev, description) + HiveSwitch(hass, entry, hive, dev, description) for dev in devices for description in SWITCH_TYPES if dev["hiveType"] == description.key @@ -55,12 +55,14 @@ class HiveSwitch(HiveEntity, SwitchEntity): def __init__( self, + hass: HomeAssistant, + entry: HiveConfigEntry, hive: Hive, hive_device: dict[str, Any], entity_description: SwitchEntityDescription, ) -> None: """Initialise hive switch.""" - super().__init__(hive, hive_device) + super().__init__(hass, entry, hive, hive_device) self.entity_description = entity_description @refresh_system diff --git a/homeassistant/components/hive/water_heater.py b/homeassistant/components/hive/water_heater.py index 9b4dbfbd36b5..1f244bac305f 100644 --- a/homeassistant/components/hive/water_heater.py +++ b/homeassistant/components/hive/water_heater.py @@ -52,7 +52,9 @@ async def async_setup_entry( hive = entry.runtime_data devices = hive.session.deviceList.get("water_heater") if devices: - async_add_entities((HiveWaterHeater(hive, dev) for dev in devices), True) + async_add_entities( + (HiveWaterHeater(hass, entry, hive, dev) for dev in devices), True + ) platform = entity_platform.async_get_current_platform() diff --git a/tests/components/hive/test_init.py b/tests/components/hive/test_init.py index 86eacdd1d638..2e505af81b3f 100644 --- a/tests/components/hive/test_init.py +++ b/tests/components/hive/test_init.py @@ -1,5 +1,6 @@ """Tests for the Hive integration __init__.""" +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from homeassistant.components.hive.const import DOMAIN @@ -32,15 +33,36 @@ _HUB_BASE = { }, } +_CHILD_BINARY_SENSOR = { + "device_id": "hive-child-id", + "hiveID": "hive-child-id", + "hiveName": "Hive Hub Connectivity", + "haName": "Hub Connectivity", + "device_name": "Hive Hub", + "hiveType": "Connectivity", + "parentDevice": "hive-hub-id", + "deviceData": { + "model": "Hub", + "version": "1.2.3", + "manufacturer": "Hive", + "online": True, + }, + "status": {"state": True}, +} -def _make_mock_hive(hub_extra: dict) -> MagicMock: + +def _make_mock_hive( + hub_extra: dict, extra_devices: dict[str, list[dict[str, Any]]] | None = None +) -> MagicMock: """Return a mocked Hive instance. startSession returns a minimal devices dict. """ hub_data = {**_HUB_BASE, **hub_extra} + devices = {"parent": [hub_data], **(extra_devices or {})} mock_hive = MagicMock() - mock_hive.session.startSession = AsyncMock(return_value={"parent": [hub_data]}) + mock_hive.session.startSession = AsyncMock(return_value=devices) + mock_hive.session.deviceList = devices return mock_hive @@ -54,12 +76,9 @@ async def test_hub_device_registers_mac_connection( mock_hive = _make_mock_hive({"macAddress": "00:1C:2B:1C:2E:68"}) - with ( - patch( - "homeassistant.components.hive.Hive", - return_value=mock_hive, - ), - patch("homeassistant.components.hive.aiohttp_client.async_get_clientsession"), + with patch( + "homeassistant.components.hive.Hive", + return_value=mock_hive, ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -79,12 +98,9 @@ async def test_hub_device_no_mac_connection_when_absent( mock_hive = _make_mock_hive({}) # no macAddress key - with ( - patch( - "homeassistant.components.hive.Hive", - return_value=mock_hive, - ), - patch("homeassistant.components.hive.aiohttp_client.async_get_clientsession"), + with patch( + "homeassistant.components.hive.Hive", + return_value=mock_hive, ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -94,3 +110,36 @@ async def test_hub_device_no_mac_connection_when_absent( assert not any( conn_type == dr.CONNECTION_NETWORK_MAC for conn_type, _ in device.connections ) + + +async def test_child_device_links_to_hub_via_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """A child device's via_device_id should point at the hub device's id.""" + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA) + entry.add_to_hass(hass) + + mock_hive = _make_mock_hive( + {"macAddress": "00:1C:2B:1C:2E:68"}, + {"binary_sensor": [_CHILD_BINARY_SENSOR], "sensor": []}, + ) + mock_hive.session.updateData = AsyncMock() + mock_hive.sensor.getSensor = AsyncMock(side_effect=lambda device: device) + + with patch( + "homeassistant.components.hive.Hive", + return_value=mock_hive, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + hub_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "hive-hub-id"), entry.entry_id + ) + child_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "hive-child-id"), entry.entry_id + ) + assert hub_device is not None + assert child_device is not None + assert child_device.via_device_id == hub_device.id From 628989846c84c922bdab0e589a98fc78235a4163 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 3 Aug 2026 18:15:25 +0200 Subject: [PATCH 106/109] Fix check requirements aw for non members (#178098) --- .github/workflows/check-requirements.lock.yml | 50 ++----------------- .github/workflows/check-requirements.md | 5 ++ 2 files changed, 10 insertions(+), 45 deletions(-) diff --git a/.github/workflows/check-requirements.lock.yml b/.github/workflows/check-requirements.lock.yml index 5be8eeb598fb..6d4c298e5e70 100644 --- a/.github/workflows/check-requirements.lock.yml +++ b/.github/workflows/check-requirements.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"36a7fc263a2ce868d74a266f23eb7772d82fd397806464384fe087479ddd4a70","body_hash":"bba8c011f2b82bb4d9847a359f43f0e7d91245b280678c20e5112b3c9e77d5cd","compiler_version":"v0.83.4","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.75"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"171579373098bfb339df0be86af6a29fc49de683760acbf795639518392f52c6","body_hash":"bba8c011f2b82bb4d9847a359f43f0e7d91245b280678c20e5112b3c9e77d5cd","compiler_version":"v0.83.4","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.75"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"e89c65e17eb281bbd5ff2ff9e9199a03e96654c7","version":"v0.83.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42","digest":"sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42","digest":"sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42","digest":"sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.6","digest":"sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.6@sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748","pinned_image":"ghcr.io/github/gh-aw-node@sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748"},{"image":"ghcr.io/github/github-mcp-server:v1.7.0","digest":"sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308","pinned_image":"ghcr.io/github/github-mcp-server:v1.7.0@sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308"}]} # This file was automatically generated by gh-aw (v0.83.4). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -52,6 +52,7 @@ name: "Check requirements (AW)" on: + # roles: all # Roles processed as role check in pre-activation job workflow_run: # zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation types: @@ -69,11 +70,10 @@ run-name: "Check requirements (AW)" jobs: activation: - needs: pre_activation # zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation if: > - (needs.pre_activation.outputs.activated == 'true') && (github.event_name != 'workflow_run' || github.event.workflow_run.repository.id == github.repository_id && - (!(github.event.workflow_run.repository.fork))) + ${{ github.event_name != 'workflow_run' || github.event.workflow_run.repository.id == github.repository_id && + (!(github.event.workflow_run.repository.fork)) }} runs-on: ubuntu-slim permissions: actions: read @@ -103,8 +103,6 @@ jobs: with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} - trace-id: ${{ needs.pre_activation.outputs.setup-trace-id }} - parent-span-id: ${{ needs.pre_activation.outputs.setup-parent-span-id || needs.pre_activation.outputs.setup-span-id }} safe-output-artifact-client: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} env: GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)" @@ -329,7 +327,6 @@ jobs: GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} GH_AW_MCP_CLI_SERVERS_LIST: "- `github` — run `github --help` to see available tools\n- `safeoutputs` — run `safeoutputs --help` to see available tools" - GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }} with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -349,8 +346,7 @@ jobs: GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, - GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST, - GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED + GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST } }); - name: Validate prompt placeholders @@ -1510,42 +1506,6 @@ jobs: } } - pre_activation: - runs-on: ubuntu-slim - env: - GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} - outputs: - activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} - matched_command: '' - setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} - setup-span-id: ${{ steps.setup.outputs.span-id }} - setup-trace-id: ${{ steps.setup.outputs.trace-id }} - steps: - - name: Setup Scripts - id: setup - uses: github/gh-aw-actions/setup@e89c65e17eb281bbd5ff2ff9e9199a03e96654c7 # v0.83.4 - with: - destination: ${{ runner.temp }}/gh-aw/actions - job-name: ${{ github.job }} - env: - GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)" - GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.75" - GH_AW_INFO_AWF_VERSION: "v0.27.42" - GH_AW_INFO_ENGINE_ID: "copilot" - - name: Check team membership for workflow - id: check_membership - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - GH_AW_REQUIRED_ROLES: "admin,maintainer,write" - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/check_membership.cjs'); - await main(); - prepare: needs: activation if: github.event.workflow_run.conclusion == 'success' diff --git a/.github/workflows/check-requirements.md b/.github/workflows/check-requirements.md index 6d7e788264b0..cd406bde4eb0 100644 --- a/.github/workflows/check-requirements.md +++ b/.github/workflows/check-requirements.md @@ -3,6 +3,11 @@ on: workflow_run: workflows: ["Check requirements (deterministic)"] types: [completed] + # On workflow_run the actor is the PR author, so the default role gate + # (admin/maintainer/write) skips every PR from an outside contributor — which is + # exactly who this review is for. The job is read-only and its single safe-output + # is a sanitized comment on the PR recorded in the trusted upstream artifact. + roles: all permissions: contents: read actions: read From 0fafe5e1d213d9a2050cbae303eda8c3e4acd2a5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 18:30:41 +0200 Subject: [PATCH 107/109] Migrate insteon to lookup devices scoped to config entry (#177964) --- homeassistant/components/insteon/api/aldb.py | 10 +++++--- .../components/insteon/api/config.py | 13 +++++++--- homeassistant/components/insteon/services.py | 5 +++- homeassistant/components/insteon/utils.py | 8 ++++-- tests/components/insteon/test_api_aldb.py | 11 +++++++- tests/components/insteon/test_api_device.py | 25 ++++++++++++++++--- 6 files changed, 58 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/insteon/api/aldb.py b/homeassistant/components/insteon/api/aldb.py index ffc846fe6c32..759172329c8f 100644 --- a/homeassistant/components/insteon/api/aldb.py +++ b/homeassistant/components/insteon/api/aldb.py @@ -12,6 +12,7 @@ from homeassistant.helpers import device_registry as dr from ..const import DEVICE_ADDRESS, ID, INSTEON_DEVICE_NOT_FOUND, TYPE from ..utils import async_device_name +from .config import get_insteon_config_entry from .device import notify_device_not_found ALDB_RECORD = "record" @@ -32,7 +33,7 @@ ALDB_RECORD_SCHEMA = vol.Schema( ) -async def async_aldb_record_to_dict(dev_registry, record, dirty=False): +async def async_aldb_record_to_dict(dev_registry, record, config_entry_id, dirty=False): """Convert an ALDB record to a dict.""" return ALDB_RECORD_SCHEMA( { @@ -42,7 +43,9 @@ async def async_aldb_record_to_dict(dev_registry, record, dirty=False): "highwater": record.is_high_water_mark, "group": record.group, "target": str(record.target), - "target_name": await async_device_name(dev_registry, record.target), + "target_name": await async_device_name( + dev_registry, record.target, config_entry_id + ), "data1": record.data1, "data2": record.data2, "data3": record.data3, @@ -88,10 +91,11 @@ async def websocket_get_aldb( changed_records = list(device.aldb.pending_changes.keys()) dev_registry = dr.async_get(hass) + config_entry_id = get_insteon_config_entry(hass).entry_id records = [ await async_aldb_record_to_dict( - dev_registry, aldb[mem_addr], mem_addr in changed_records + dev_registry, aldb[mem_addr], config_entry_id, mem_addr in changed_records ) for mem_addr in aldb ] diff --git a/homeassistant/components/insteon/api/config.py b/homeassistant/components/insteon/api/config.py index 70a827d64cfc..4db44bd95eeb 100644 --- a/homeassistant/components/insteon/api/config.py +++ b/homeassistant/components/insteon/api/config.py @@ -137,12 +137,16 @@ def remove_device_override(hass: HomeAssistant, address: Address): async def async_link_to_dict( - address: Address, record: ALDBRecord, dev_registry: dr.DeviceRegistry, status=None + address: Address, + record: ALDBRecord, + dev_registry: dr.DeviceRegistry, + config_entry_id: str, + status=None, ) -> dict[str, str | int]: """Convert a link to a dictionary.""" link_dict: dict[str, str | int] = {} - device_name = await async_device_name(dev_registry, address) - target_name = await async_device_name(dev_registry, record.target) + device_name = await async_device_name(dev_registry, address, config_entry_id) + target_name = await async_device_name(dev_registry, record.target, config_entry_id) link_dict["address"] = str(address) link_dict["device_name"] = device_name or str(address) link_dict["mem_addr"] = record.mem_addr @@ -311,8 +315,9 @@ async def websocket_get_broken_links( """Get any broken links between devices.""" broken_links = get_broken_links(devices=devices) dev_registry = dr.async_get(hass) + config_entry_id = get_insteon_config_entry(hass).entry_id broken_links_list = [ - await async_link_to_dict(address, record, dev_registry, status) + await async_link_to_dict(address, record, dev_registry, config_entry_id, status) for address, record, status in broken_links if status != LinkStatus.MISSING_TARGET ] diff --git a/homeassistant/components/insteon/services.py b/homeassistant/components/insteon/services.py index 448a268e6077..ad215fa42f39 100644 --- a/homeassistant/components/insteon/services.py +++ b/homeassistant/components/insteon/services.py @@ -217,7 +217,10 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 signal = f"{address.id}_{SIGNAL_REMOVE_ENTITY}" async_dispatcher_send(hass, signal) dev_registry = dr.async_get(hass) - device = dev_registry.async_get_device(identifiers={(DOMAIN, str(address))}) + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + device = dev_registry.async_get_device_by_identifier( + (DOMAIN, str(address)), config_entry.entry_id + ) if device: dev_registry.async_remove_device(device.id) diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index ffda09b4ce99..7197c442d8bf 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -190,9 +190,13 @@ def compute_device_name(ha_device) -> str: return ha_device.name_by_user or ha_device.name -async def async_device_name(dev_registry: dr.DeviceRegistry, address: Address) -> str: +async def async_device_name( + dev_registry: dr.DeviceRegistry, address: Address, config_entry_id: str +) -> str: """Get the Insteon device name from a device registry id.""" - ha_device = dev_registry.async_get_device(identifiers={(DOMAIN, str(address))}) + ha_device = dev_registry.async_get_device_by_identifier( + (DOMAIN, str(address)), config_entry_id + ) if not ha_device: if device := devices[address]: return f"{device.description} ({device.model})" diff --git a/tests/components/insteon/test_api_aldb.py b/tests/components/insteon/test_api_aldb.py index bdb749836e27..682bbc2c3bc7 100644 --- a/tests/components/insteon/test_api_aldb.py +++ b/tests/components/insteon/test_api_aldb.py @@ -20,11 +20,13 @@ from homeassistant.components.insteon.api.aldb import ( TYPE, ) from homeassistant.components.insteon.api.device import INSTEON_DEVICE_NOT_FOUND +from homeassistant.components.insteon.const import DOMAIN from homeassistant.core import HomeAssistant +from .const import MOCK_USER_INPUT_PLM from .mock_devices import MockDevices -from tests.common import load_fixture +from tests.common import MockConfigEntry, load_fixture from tests.typing import MockHAClientWebSocket, WebSocketGenerator @@ -38,6 +40,13 @@ async def _setup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data: dict[str, Any] ) -> tuple[MockHAClientWebSocket, MockDevices]: """Set up tests.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="abcde12345", + data=MOCK_USER_INPUT_PLM, + options={}, + ) + config_entry.add_to_hass(hass) ws_client = await hass_ws_client(hass) devices = MockDevices() await devices.async_load() diff --git a/tests/components/insteon/test_api_device.py b/tests/components/insteon/test_api_device.py index 6f1a174f024a..11b5f4e03233 100644 --- a/tests/components/insteon/test_api_device.py +++ b/tests/components/insteon/test_api_device.py @@ -123,14 +123,33 @@ async def test_get_ha_device_name( """Test getting the HA device name from an Insteon address.""" _, devices, _, device_reg = await async_mock_setup(hass, hass_ws_client) + config_entry_id = hass.config_entries.async_entries(DOMAIN)[0].entry_id + + # Register a colliding device sharing the same identifier but owned by a second + # config entry. The lookup must be scoped to the supplied config entry: an + # unscoped lookup could return either device, so returning the correct name for + # each config entry proves the config entry controls the lookup. + other_config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT_PLM) + other_config_entry.add_to_hass(hass) + device_reg.async_get_or_create( + config_entry_id=other_config_entry.entry_id, + identifiers={(DOMAIN, "11.11.11")}, + name="Device 11.11.11 second entry", + ) with patch.object(insteon.api.device, "devices", devices): - # Test a real HA and Insteon device - name = await async_device_name(device_reg, "11.11.11") + # The scoped lookup returns the device owned by the supplied config entry + name = await async_device_name(device_reg, "11.11.11", config_entry_id) assert name == "Device 11.11.11" + # The same identifier scoped to the second config entry returns its device + name = await async_device_name( + device_reg, "11.11.11", other_config_entry.entry_id + ) + assert name == "Device 11.11.11 second entry" + # Test no HA or Insteon device - name = await async_device_name(device_reg, "BB.BB.BB") + name = await async_device_name(device_reg, "BB.BB.BB", config_entry_id) assert name == "" From 9d24d8a1d9b2bc655c19b46ffff485b47fb4e79c Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Mon, 3 Aug 2026 10:31:06 -0700 Subject: [PATCH 108/109] Avoid naive datetime.now() in weatherkit coordinator (#178103) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- homeassistant/components/weatherkit/coordinator.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/weatherkit/coordinator.py b/homeassistant/components/weatherkit/coordinator.py index de80c3bc1a53..bbbc54b2a0dc 100644 --- a/homeassistant/components/weatherkit/coordinator.py +++ b/homeassistant/components/weatherkit/coordinator.py @@ -1,6 +1,7 @@ """DataUpdateCoordinator for WeatherKit integration.""" -from datetime import datetime, timedelta +from datetime import timedelta +import time from typing import override from apple_weatherkit import DataSetType @@ -20,7 +21,7 @@ REQUESTED_DATA_SETS = [ DataSetType.HOURLY_FORECAST, ] -STALE_DATA_THRESHOLD = timedelta(hours=1) +STALE_DATA_THRESHOLD = timedelta(hours=1).total_seconds() HOURLY_FORECAST_DURATION = timedelta(days=7) @@ -32,7 +33,7 @@ class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): config_entry: WeatherKitConfigEntry supported_data_sets: list[DataSetType] | None = None - last_updated_at: datetime | None = None + last_updated_at: float | None = None def __init__( self, @@ -83,12 +84,12 @@ class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): except WeatherKitApiClientError as exception: if self.data is None or ( self.last_updated_at is not None - and datetime.now() - self.last_updated_at > STALE_DATA_THRESHOLD # pylint: disable=home-assistant-enforce-naive-now + and time.time() - self.last_updated_at > STALE_DATA_THRESHOLD ): raise UpdateFailed(exception) from exception LOGGER.debug("Using stale data because update failed: %s", exception) return self.data else: - self.last_updated_at = datetime.now() # pylint: disable=home-assistant-enforce-naive-now + self.last_updated_at = time.time() return updated_data From 9f74948e26a653888489eacae14e3fa338fa318e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Aug 2026 13:01:34 -0500 Subject: [PATCH 109/109] Fix ESPHome dashboard adoption locking HA out of provisioned devices (#178039) --- homeassistant/components/esphome/manager.py | 107 +++- tests/components/esphome/test_manager.py | 546 ++++++++++++++++++++ 2 files changed, 652 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 2f45d283e12b..362b8981a73f 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -3,6 +3,7 @@ import asyncio import base64 from functools import partial +import json import logging import secrets import struct @@ -29,6 +30,7 @@ from aioesphomeapi import ( ZWaveProxyRequestType, parse_log_message, ) +import aiohttp from awesomeversion import AwesomeVersion import voluptuous as vol @@ -135,6 +137,9 @@ _LOGGER = logging.getLogger(__name__) # Max time to wait at startup for a BLE proxy to register its scanner. STARTUP_SCANNER_WAIT: Final = 3.0 +# Dashboard responses that mean the encryption-key handoff landed. +_DASHBOARD_KEY_SYNC_OK: Final = frozenset({"stored", "updated", "unchanged"}) + LOG_LEVEL_TO_LOGGER = { LogLevel.LOG_LEVEL_NONE: logging.DEBUG, LogLevel.LOG_LEVEL_ERROR: logging.ERROR, @@ -215,6 +220,7 @@ class ESPHomeManager: __slots__ = ( "_cancel_subscribe_logs", + "_dashboard_key_sync_warned", "_log_level", "cli", "device_id", @@ -250,6 +256,7 @@ class ESPHomeManager: self.zeroconf_instance = zeroconf_instance self.entry_data = entry.runtime_data self._cancel_subscribe_logs: CALLBACK_TYPE | None = None + self._dashboard_key_sync_warned = False self._log_level = LogLevel.LOG_LEVEL_NONE async def on_stop(self, event: Event) -> None: @@ -882,6 +889,84 @@ class ESPHomeManager: await cli.disconnect(force=True) return False + @callback + def _async_schedule_dashboard_key_sync( + self, device_info: EsphomeDeviceInfo, key: str + ) -> None: + """Schedule the best-effort dashboard key sync off the connect path.""" + self.entry.async_create_background_task( + self.hass, + self._async_sync_encryption_key_to_dashboard(device_info, key), + "esphome-sync-encryption-key", + ) + + async def _async_sync_encryption_key_to_dashboard( + self, device_info: EsphomeDeviceInfo, key: str + ) -> None: + """Best effort: tell the ESPHome dashboard about the provisioned key. + + Without this the dashboard has no way to know the key HA set on + the device, so adopting the device generates a competing key and + the next flash locks HA out. Never fails the connect flow: a + dashboard without the endpoint (404/405) logs at debug, any + other failure warns once per manager. + """ + if (dashboard := async_get_dashboard(self.hass)) is None: + return + try: + result = await dashboard.api.post_encryption_key( + device_info.name, key, mac=self.entry.unique_id + ) + except (aiohttp.ClientError, TimeoutError, json.JSONDecodeError) as err: + if isinstance(err, aiohttp.ClientResponseError) and err.status in ( + 404, + 405, + ): + # Endpoint absent — an old dashboard, not a failed store. + _LOGGER.debug( + "The ESPHome dashboard does not support the encryption key " + "handoff for %s: %s", + device_info.name, + err, + ) + else: + self._async_warn_dashboard_key_sync_failed(device_info, err) + return + result_value = result.get("result") if isinstance(result, dict) else None + reason = result.get("reason") if isinstance(result, dict) else None + if isinstance(result_value, str) and result_value in _DASHBOARD_KEY_SYNC_OK: + if reason: + # Partial success: a duplicate-name sibling refused the + # key and flashing it can still lock HA out. + self._async_warn_dashboard_key_sync_failed(device_info, reason) + return + _LOGGER.debug( + "Synced encryption key for %s to the ESPHome dashboard: %s", + device_info.name, + result, + ) + return + self._async_warn_dashboard_key_sync_failed( + device_info, reason or f"unexpected response {result}" + ) + + @callback + def _async_warn_dashboard_key_sync_failed( + self, device_info: EsphomeDeviceInfo, cause: Exception | str + ) -> None: + """Warn once that the dashboard did not store the key.""" + if self._dashboard_key_sync_warned: + return + self._dashboard_key_sync_warned = True + _LOGGER.warning( + "The ESPHome dashboard could not store the encryption key for " + "%s (%s), so installing that configuration may use a different " + "key and lock Home Assistant out: %s", + device_info.name, + self.entry.unique_id, + cause, + ) + async def _handle_dynamic_encryption_key( self, device_info: EsphomeDeviceInfo ) -> None: @@ -892,7 +977,22 @@ class ESPHomeManager: """ noise_psk: str | None = self.entry.data.get(CONF_NOISE_PSK) if noise_psk: - # we're already connected with a noise PSK - nothing to do + # We're already connected with this key, so it's proven valid; + # re-offer it so a dashboard that missed the original handoff + # (added later, upgraded, or temporarily unreachable) catches + # up. Deliberately re-offered on every connect (no success + # latch): the dashboard no-ops on an identical key, and a + # dashboard whose copy was deleted out from under it gets it + # back on the next connect. + # Only keys in our storage are ours to push — a user-authored + # YAML key is not (mirrors _async_clear_dynamic_encryption_key). + # Background task: this runs on every connect and must not + # delay entity setup. + storage = await async_get_encryption_key_storage(self.hass) + if self.entry.unique_id and ( + await storage.async_get_key(self.entry.unique_id) == noise_psk + ): + self._async_schedule_dashboard_key_sync(device_info, noise_psk) return if not device_info.api_encryption_supported: @@ -962,6 +1062,11 @@ class ESPHomeManager: data={**self.entry.data, CONF_NOISE_PSK: new_key_str}, ) + # The dashboard must learn the key or its next adoption/flash of + # this device bakes in a competing key and locks HA out. Background + # task: an unreachable dashboard must not stall entity setup. + self._async_schedule_dashboard_key_sync(device_info, new_key_str) + if from_storage: _LOGGER.info( "Set encryption key from storage on device %s (%s)", diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 2f5ef95ce8c8..12f779c189fe 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -3,6 +3,7 @@ import asyncio import base64 from collections.abc import Generator +import json import logging from typing import Any from unittest.mock import AsyncMock, Mock, call, patch @@ -31,6 +32,7 @@ from aioesphomeapi import ( ZWaveProxyRequest, ZWaveProxyRequestType, ) +import aiohttp import pytest import voluptuous as vol @@ -2886,6 +2888,550 @@ async def test_dynamic_encryption_key_provisioned_over_zero_psk_from_storage( assert entry.data[CONF_NOISE_PSK] == test_key +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_dynamic_encryption_key_synced_to_dashboard( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], + mock_dashboard: dict[str, Any], +) -> None: + """Test a newly generated key is pushed to the dashboard.""" + mac_address = "11:22:33:44:55:aa" + test_key_bytes = b"test_key_32_bytes_long_exactly!" + mock_token_bytes.return_value = test_key_bytes + expected_key = base64.b64encode(test_key_bytes).decode() + + entry = _make_provisionable_entry(hass, mac_address) + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + with patch( + "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", + new_callable=AsyncMock, + return_value={"result": "stored"}, + ) as mock_post_key: + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + await device.mock_disconnect(True) + await device.mock_connect() + await hass.async_block_till_done(wait_background_tasks=True) + + assert entry.data[CONF_NOISE_PSK] == expected_key + # Provisioning-time push first; the reconnect re-offers the same key. + assert mock_post_key.await_args_list[0] == call( + "test-device", expected_key, mac=mac_address + ) + + +async def test_dynamic_encryption_key_from_storage_synced_to_dashboard( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], + mock_dashboard: dict[str, Any], +) -> None: + """Test a storage-recovered key is pushed to the dashboard too.""" + mac_address = "11:22:33:44:55:aa" + test_key = base64.b64encode(b"existing_key_32_bytes_long!!!").decode() + + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": {"keys": {mac_address: test_key}}, + } + + entry = _make_provisionable_entry(hass, mac_address) + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + with patch( + "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", + new_callable=AsyncMock, + return_value={"result": "stored"}, + ) as mock_post_key: + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + await device.mock_disconnect(True) + await device.mock_connect() + await hass.async_block_till_done(wait_background_tasks=True) + + assert entry.data[CONF_NOISE_PSK] == test_key + assert mock_post_key.await_args_list[0] == call( + "test-device", test_key, mac=mac_address + ) + + +@pytest.mark.parametrize( + ("sync_error", "expect_warning"), + [ + ( + aiohttp.ClientResponseError(request_info=Mock(), history=(), status=404), + False, + ), + ( + aiohttp.ClientResponseError(request_info=Mock(), history=(), status=405), + False, + ), + ( + aiohttp.ClientResponseError(request_info=Mock(), history=(), status=500), + True, + ), + (aiohttp.ClientError("boom"), True), + (TimeoutError(), True), + (json.JSONDecodeError("boom", "x", 0), True), + ], +) +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_dynamic_encryption_key_dashboard_sync_failure_is_not_fatal( + mock_token_bytes: Mock, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], + mock_dashboard: dict[str, Any], + sync_error: Exception, + expect_warning: bool, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test an old dashboard (404) or a flaky one never fails the connect flow.""" + mac_address = "11:22:33:44:55:aa" + test_key_bytes = b"test_key_32_bytes_long_exactly!" + mock_token_bytes.return_value = test_key_bytes + expected_key = base64.b64encode(test_key_bytes).decode() + + entry = _make_provisionable_entry(hass, mac_address) + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + with patch( + "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", + new_callable=AsyncMock, + side_effect=sync_error, + ) as mock_post_key: + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + await device.mock_disconnect(True) + await device.mock_connect() + await hass.async_block_till_done(wait_background_tasks=True) + + assert mock_post_key.await_count >= 1 + # The flow must have run to completion — a sync exception escaping + # _on_connect would skip the device-registry setup after the handoff. + assert ( + device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac_address)} + ) + is not None + ) + assert entry.data[CONF_NOISE_PSK] == expected_key + assert ( + hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"][mac_address] + == expected_key + ) + # Endpoint-absent (404/405) stays debug; real failures warn so the + # possible lockout is visible. + assert ("could not store the encryption key" in caplog.text) is expect_warning + + +@pytest.mark.parametrize( + "unexpected", + [ + pytest.param({"error": "unknown device"}, id="not_an_outcome"), + pytest.param(["valid", "json", "wrong", "shape"], id="not_a_dict"), + pytest.param({"result": ["updated"]}, id="unhashable_result"), + ], +) +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_dashboard_unexpected_sync_result_logs_warning( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], + mock_dashboard: dict[str, Any], + caplog: pytest.LogCaptureFixture, + unexpected: Any, +) -> None: + """Test an unrecognized dashboard response is treated as a failed handoff.""" + mac_address = "11:22:33:44:55:aa" + mock_token_bytes.return_value = b"test_key_32_bytes_long_exactly!" + + entry = _make_provisionable_entry(hass, mac_address) + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + with patch( + "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", + new_callable=AsyncMock, + return_value=unexpected, + ): + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + await device.mock_disconnect(True) + await device.mock_connect() + await hass.async_block_till_done(wait_background_tasks=True) + + assert caplog.text.count("could not store the encryption key") == 1 + assert "unexpected response" in caplog.text + + +async def test_existing_key_resynced_to_dashboard_on_connect( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], + mock_dashboard: dict[str, Any], +) -> None: + """Test a connect with a proven-valid HA-provisioned key re-offers it.""" + mac_address = "11:22:33:44:55:aa" + test_key = base64.b64encode(b"existing_key_32_bytes_long!!!").decode() + + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": {"keys": {mac_address: test_key}}, + } + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + CONF_NOISE_PSK: test_key, + }, + unique_id=mac_address, + ) + entry.add_to_hass(hass) + + with patch( + "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", + new_callable=AsyncMock, + return_value={"result": "unchanged", "configurations": ["test-device.yaml"]}, + ) as mock_post_key: + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + await device.mock_disconnect(True) + await device.mock_connect() + await hass.async_block_till_done(wait_background_tasks=True) + + mock_post_key.assert_awaited_with("test-device", test_key, mac=mac_address) + # No success latch: every connect re-offers so a dashboard whose + # copy was deleted gets it back; the dashboard no-ops otherwise. + assert mock_post_key.await_count == 2 + + +async def test_user_provided_key_not_resynced_to_dashboard( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], + mock_dashboard: dict[str, Any], +) -> None: + """Test a user-authored YAML key (absent from storage) is never pushed.""" + mac_address = "11:22:33:44:55:aa" + test_key = base64.b64encode(b"existing_key_32_bytes_long!!!").decode() + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + CONF_NOISE_PSK: test_key, + }, + unique_id=mac_address, + ) + entry.add_to_hass(hass) + + with patch( + "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", + new_callable=AsyncMock, + ) as mock_post_key: + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + await device.mock_disconnect(True) + await device.mock_connect() + await hass.async_block_till_done(wait_background_tasks=True) + + mock_post_key.assert_not_awaited() + + +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_dashboard_not_writable_response_logs_warning( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], + mock_dashboard: dict[str, Any], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a dashboard that declined the key surfaces an actionable warning.""" + mac_address = "11:22:33:44:55:aa" + test_key_bytes = b"test_key_32_bytes_long_exactly!" + mock_token_bytes.return_value = test_key_bytes + expected_key = base64.b64encode(test_key_bytes).decode() + + entry = _make_provisionable_entry(hass, mac_address) + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + with patch( + "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", + new_callable=AsyncMock, + return_value={ + "result": "not_writable", + "configurations": ["test-device.yaml"], + "reason": "the key is provided via !secret or a substitution", + }, + ): + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + await device.mock_disconnect(True) + await device.mock_connect() + await hass.async_block_till_done(wait_background_tasks=True) + + assert entry.data[CONF_NOISE_PSK] == expected_key + assert caplog.text.count("could not store the encryption key") == 1 + assert "!secret" in caplog.text + + +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_dashboard_partial_success_warns_and_keeps_retrying( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], + mock_dashboard: dict[str, Any], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test an updated-with-reason response warns and does not latch success.""" + mac_address = "11:22:33:44:55:aa" + mock_token_bytes.return_value = b"test_key_32_bytes_long_exactly!" + + entry = _make_provisionable_entry(hass, mac_address) + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + with patch( + "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", + new_callable=AsyncMock, + return_value={ + "result": "updated", + "configurations": ["test-device.yaml", "test-device (1).yaml"], + "reason": "the key is provided via !secret or a substitution", + }, + ) as mock_post_key: + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + await device.mock_disconnect(True) + await device.mock_connect() + await hass.async_block_till_done(wait_background_tasks=True) + + # The duplicate-name sibling still carries a competing key: warn + # (once) while reconnects keep retrying. + assert caplog.text.count("could not store the encryption key") == 1 + assert "!secret" in caplog.text + assert mock_post_key.await_count == 2 + + +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_dashboard_sync_warns_once_across_causes( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], + mock_dashboard: dict[str, Any], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the handoff warns exactly once regardless of failure causes.""" + mac_address = "11:22:33:44:55:aa" + mock_token_bytes.return_value = b"test_key_32_bytes_long_exactly!" + + entry = _make_provisionable_entry(hass, mac_address) + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + with patch( + "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", + new_callable=AsyncMock, + side_effect=[ + aiohttp.ClientError("boom"), + {"result": "not_writable", "reason": "the key is provided via !secret"}, + ], + ): + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + await device.mock_disconnect(True) + await device.mock_connect() + await hass.async_block_till_done(wait_background_tasks=True) + + assert caplog.text.count("could not store the encryption key") == 1 + assert "boom" in caplog.text + assert "!secret" not in caplog.text + + +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_dynamic_encryption_key_not_synced_without_dashboard( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test no dashboard registered means no push is attempted.""" + mac_address = "11:22:33:44:55:aa" + mock_token_bytes.return_value = b"test_key_32_bytes_long_exactly!" + + entry = _make_provisionable_entry(hass, mac_address) + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + with patch( + "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", + new_callable=AsyncMock, + ) as mock_post_key: + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + await device.mock_disconnect(True) + await device.mock_connect() + await hass.async_block_till_done(wait_background_tasks=True) + + assert entry.data[CONF_NOISE_PSK] != "" + mock_post_key.assert_not_awaited() + + +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_dynamic_encryption_key_not_synced_when_provisioning_fails( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], + mock_dashboard: dict[str, Any], +) -> None: + """Test a key the device rejected is never pushed to the dashboard.""" + mac_address = "11:22:33:44:55:aa" + mock_token_bytes.return_value = b"test_key_32_bytes_long_exactly!" + + entry = _make_provisionable_entry(hass, mac_address) + mock_client.noise_encryption_set_key = AsyncMock(return_value=False) + + with patch( + "esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key", + new_callable=AsyncMock, + ) as mock_post_key: + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + await device.mock_disconnect(True) + await device.mock_connect() + await hass.async_block_till_done(wait_background_tasks=True) + + assert CONF_NOISE_PSK not in entry.data or entry.data[CONF_NOISE_PSK] == "" + mock_post_key.assert_not_awaited() + + @pytest.mark.parametrize( ("connect_error", "set_key_result"), [