From 5ad805de3cb6a02cad516b7dfd5bcfaad3de1c15 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 6 Oct 2025 17:29:33 +0200 Subject: [PATCH 01/14] Add motion presets to SmartThings AC (#153830) --- .../components/smartthings/climate.py | 2 + .../components/smartthings/strings.json | 4 +- .../device_status/da_ac_rac_000003.json | 258 +++++++++++------- .../fixtures/devices/da_ac_rac_000003.json | 31 ++- .../smartthings/snapshots/test_climate.ambr | 28 +- .../smartthings/snapshots/test_init.ambr | 4 +- .../smartthings/snapshots/test_sensor.ambr | 112 ++++---- tests/components/smartthings/test_climate.py | 4 +- 8 files changed, 261 insertions(+), 182 deletions(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 28c1c9c378257..526c5840881da 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -109,6 +109,8 @@ "quiet": "quiet", "longWind": "long_wind", "smart": "smart", + "motionIndirect": "motion_indirect", + "motionDirect": "motion_direct", } HA_MODE_TO_PRESET_MODE = {v: k for k, v in PRESET_MODE_TO_HA.items()} diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index fb6b84651865e..c81b5f6135484 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -87,7 +87,9 @@ "wind_free_sleep": "WindFree sleep", "quiet": "Quiet", "long_wind": "Long wind", - "smart": "Smart" + "smart": "Smart", + "motion_direct": "Motion direct", + "motion_indirect": "Motion indirect" } }, "fan_mode": { diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json index 98434aa2c5ad0..42fd78cd86219 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json @@ -3,9 +3,9 @@ "main": { "relativeHumidityMeasurement": { "humidity": { - "value": 48, + "value": 59, "unit": "%", - "timestamp": "2025-03-27T05:12:16.158Z" + "timestamp": "2025-10-04T13:03:19.251Z" } }, "custom.airConditionerOdorController": { @@ -20,12 +20,12 @@ "minimumSetpoint": { "value": 16, "unit": "C", - "timestamp": "2025-03-13T09:29:37.008Z" + "timestamp": "2025-08-20T15:31:02.655Z" }, "maximumSetpoint": { "value": 30, "unit": "C", - "timestamp": "2024-06-21T13:45:16.785Z" + "timestamp": "2025-06-18T03:34:18.116Z" } }, "airConditionerMode": { @@ -33,23 +33,24 @@ "value": null }, "supportedAcModes": { - "value": ["cool", "dry", "wind", "auto"], - "timestamp": "2024-06-21T13:45:16.785Z" + "value": ["cool", "dry", "wind", "auto", "heat"], + "timestamp": "2025-06-18T03:34:18.116Z" }, "airConditionerMode": { - "value": "cool", - "timestamp": "2025-03-13T09:29:36.789Z" + "value": "heat", + "timestamp": "2025-10-04T12:59:27.205Z" } }, "custom.spiMode": { "spiMode": { "value": "off", - "timestamp": "2025-02-08T08:54:15.661Z" + "timestamp": "2025-10-03T18:10:05.905Z" } }, "samsungce.deviceIdentification": { "micomAssayCode": { - "value": null + "value": "10217841", + "timestamp": "2025-06-18T03:34:18.116Z" }, "modelName": { "value": null @@ -61,17 +62,20 @@ "value": null }, "modelClassificationCode": { - "value": null + "value": "60010523001411010200001000000000", + "timestamp": "2025-06-18T03:34:18.116Z" }, "description": { - "value": null + "value": "ARTIK051_PRAC_20K", + "timestamp": "2025-06-18T03:34:18.116Z" }, "releaseYear": { - "value": null + "value": 20, + "timestamp": "2025-06-12T09:25:21.264Z" }, "binaryId": { "value": "ARTIK051_PRAC_20K", - "timestamp": "2025-03-27T05:12:15.284Z" + "timestamp": "2025-10-04T03:34:13.732Z" } }, "airQualitySensor": { @@ -87,26 +91,28 @@ "quiet", "smart", "speed", + "motionIndirect", + "motionDirect", "windFree", "windFreeSleep" ], - "timestamp": "2024-06-21T13:45:16.785Z" + "timestamp": "2025-06-18T03:34:18.116Z" }, "acOptionalMode": { "value": "off", - "timestamp": "2025-03-26T12:20:41.095Z" + "timestamp": "2025-09-28T10:20:26.885Z" } }, "switch": { "switch": { "value": "on", - "timestamp": "2025-03-27T05:41:42.291Z" + "timestamp": "2025-10-04T12:59:27.185Z" } }, "custom.airConditionerTropicalNightMode": { "acTropicalNightModeLevel": { "value": 0, - "timestamp": "2025-02-08T08:54:15.789Z" + "timestamp": "2025-06-17T10:45:00.985Z" } }, "ocf": { @@ -118,65 +124,65 @@ }, "mnfv": { "value": "ARTIK051_PRAC_20K_11230313", - "timestamp": "2024-06-21T13:58:04.085Z" + "timestamp": "2025-10-04T03:34:13.484Z" }, "mnhw": { "value": "ARTIK051", - "timestamp": "2024-06-21T13:51:35.294Z" + "timestamp": "2025-10-04T03:34:13.484Z" }, "di": { - "value": "c76d6f38-1b7f-13dd-37b5-db18d5272783", - "timestamp": "2024-06-21T13:45:16.329Z" + "value": "1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977", + "timestamp": "2025-10-04T03:34:13.486Z" }, "mnsl": { "value": "http://www.samsung.com", - "timestamp": "2024-06-21T13:51:35.980Z" + "timestamp": "2025-10-04T03:34:13.484Z" }, "dmv": { "value": "res.1.1.0,sh.1.1.0", - "timestamp": "2024-06-21T13:58:04.698Z" + "timestamp": "2025-10-04T03:34:13.486Z" }, "n": { "value": "Samsung Room A/C", - "timestamp": "2024-06-21T13:58:04.085Z" + "timestamp": "2025-10-04T03:34:13.486Z" }, "mnmo": { - "value": "ARTIK051_PRAC_20K|10256941|60010534001411014600003200800000", - "timestamp": "2024-06-21T13:45:16.329Z" + "value": "ARTIK051_PRAC_20K|10217841|60010523001411010200001000000000", + "timestamp": "2025-10-04T03:34:13.732Z" }, "vid": { "value": "DA-AC-RAC-000003", - "timestamp": "2024-06-21T13:45:16.329Z" + "timestamp": "2025-10-04T03:34:13.484Z" }, "mnmn": { "value": "Samsung Electronics", - "timestamp": "2024-06-21T13:45:16.329Z" + "timestamp": "2025-10-04T03:34:13.484Z" }, "mnml": { "value": "http://www.samsung.com", - "timestamp": "2024-06-21T13:45:16.329Z" + "timestamp": "2025-10-04T03:34:13.484Z" }, "mnpv": { "value": "DAWIT 2.0", - "timestamp": "2024-06-21T13:51:35.294Z" + "timestamp": "2025-10-04T03:34:13.484Z" }, "mnos": { "value": "TizenRT 1.0 + IPv6", - "timestamp": "2024-06-21T13:51:35.294Z" + "timestamp": "2025-10-04T03:34:13.484Z" }, "pi": { - "value": "c76d6f38-1b7f-13dd-37b5-db18d5272783", - "timestamp": "2024-06-21T13:45:16.329Z" + "value": "1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977", + "timestamp": "2025-10-04T03:34:13.484Z" }, "icv": { "value": "core.1.1.0", - "timestamp": "2024-06-21T13:45:16.329Z" + "timestamp": "2025-10-04T03:34:13.486Z" } }, "airConditionerFanMode": { "fanMode": { - "value": "low", - "timestamp": "2025-03-26T12:20:41.393Z" + "value": "auto", + "timestamp": "2025-10-04T12:59:27.323Z" }, "supportedAcFanModes": { "value": ["auto", "low", "medium", "high", "turbo"], @@ -190,12 +196,12 @@ "alarmThreshold": { "value": 500, "unit": "Hour", - "timestamp": "2025-02-08T08:54:15.473Z" + "timestamp": "2025-06-17T12:15:04.968Z" }, "supportedAlarmThresholds": { "value": [180, 300, 500, 700], "unit": "Hour", - "timestamp": "2025-02-08T08:54:15.473Z" + "timestamp": "2025-06-18T03:34:18.116Z" } }, "custom.electricHepaFilter": { @@ -231,10 +237,12 @@ "custom.electricHepaFilter", "custom.periodicSensing", "custom.doNotDisturbMode", + "demandResponseLoadControl", "custom.airConditionerOdorController", - "samsungce.individualControlLock" + "samsungce.individualControlLock", + "samsungce.alwaysOnSensing" ], - "timestamp": "2025-02-08T08:54:15.355Z" + "timestamp": "2025-10-04T12:59:27.205Z" } }, "custom.ocfResourceVersion": { @@ -247,8 +255,8 @@ }, "samsungce.driverVersion": { "versionNumber": { - "value": 24040101, - "timestamp": "2024-06-21T13:45:16.348Z" + "value": 25040101, + "timestamp": "2025-06-12T10:13:31.862Z" } }, "fanOscillationMode": { @@ -261,7 +269,7 @@ }, "fanOscillationMode": { "value": "fixed", - "timestamp": "2025-02-25T15:40:11.773Z" + "timestamp": "2025-10-04T15:53:27.427Z" } }, "temperatureMeasurement": { @@ -269,9 +277,9 @@ "value": null }, "temperature": { - "value": 26, + "value": 20, "unit": "C", - "timestamp": "2025-03-26T14:19:08.047Z" + "timestamp": "2025-10-04T13:07:50.163Z" } }, "dustSensor": { @@ -285,7 +293,7 @@ "custom.deviceReportStateConfiguration": { "reportStateRealtimePeriod": { "value": "disabled", - "timestamp": "2025-02-08T08:54:15.726Z" + "timestamp": "2025-06-18T03:34:18.116Z" }, "reportStateRealtime": { "value": { @@ -293,40 +301,48 @@ "duration": 10, "unit": "minute" }, - "timestamp": "2025-03-24T08:28:07.030Z" + "timestamp": "2025-10-04T15:53:14.562Z" }, "reportStatePeriod": { "value": "enabled", - "timestamp": "2025-02-08T08:54:15.726Z" + "timestamp": "2025-06-18T03:34:18.116Z" } }, "custom.periodicSensing": { "automaticExecutionSetting": { - "value": null + "value": "NotSupported", + "timestamp": "2021-05-28T08:57:59.311Z" }, "automaticExecutionMode": { - "value": null + "value": "NotSupported", + "timestamp": "2021-05-28T08:57:59.311Z" }, "supportedAutomaticExecutionSetting": { - "value": null + "value": ["NotSupported"], + "timestamp": "2021-05-28T08:57:59.311Z" }, "supportedAutomaticExecutionMode": { - "value": null + "value": ["NotSupported"], + "timestamp": "2021-05-28T08:57:59.311Z" }, "periodicSensing": { - "value": null + "value": "off", + "timestamp": "2021-12-22T07:01:09.979Z" }, "periodicSensingInterval": { - "value": null + "value": 600, + "timestamp": "2021-05-28T08:57:59.311Z" }, "lastSensingTime": { "value": null }, "lastSensingLevel": { - "value": null + "value": "", + "timestamp": "2021-05-28T08:57:59.311Z" }, "periodicSensingStatus": { - "value": null + "value": "nonprocessing", + "timestamp": "2021-05-28T08:57:59.311Z" } }, "thermostatCoolingSetpoint": { @@ -334,66 +350,72 @@ "value": null }, "coolingSetpoint": { - "value": 24, + "value": 19, "unit": "C", - "timestamp": "2025-03-26T12:20:41.346Z" + "timestamp": "2025-10-04T12:59:27.582Z" } }, "demandResponseLoadControl": { "drlcStatus": { "value": { "drlcType": 1, + "drlcLevel": -1, + "start": "1970-01-01T00:00:00Z", "duration": 0, "override": false }, - "timestamp": "2025-03-24T04:56:36.855Z" + "timestamp": "2025-06-18T03:34:18.116Z" } }, "audioVolume": { "volume": { "value": 100, "unit": "%", - "timestamp": "2025-02-08T08:54:15.789Z" + "timestamp": "2025-06-17T10:45:00.985Z" } }, "powerConsumptionReport": { "powerConsumption": { "value": { - "energy": 602171, - "deltaEnergy": 0, - "power": 0, - "powerEnergy": 0.0, - "persistedEnergy": 602171, + "energy": 6652713, + "deltaEnergy": 2, + "power": 143, + "powerEnergy": 1.747048611111111, + "persistedEnergy": 6652713, "energySaved": 0, - "persistedSavedEnergy": 0, - "start": "2025-03-27T05:29:22Z", - "end": "2025-03-27T05:40:02Z" + "start": "2025-10-04T15:54:24Z", + "end": "2025-10-04T15:55:07Z" }, - "timestamp": "2025-03-27T05:40:02.686Z" + "timestamp": "2025-10-04T15:55:07.378Z" } }, "custom.autoCleaningMode": { "supportedAutoCleaningModes": { - "value": null + "value": ["on", "off"], + "timestamp": "2025-06-18T03:34:18.116Z" }, "timedCleanDuration": { "value": null }, "operatingState": { - "value": null + "value": "ready", + "timestamp": "2025-08-20T17:44:52.796Z" }, "timedCleanDurationRange": { "value": null }, "supportedOperatingStates": { - "value": null + "value": ["autoClean", "ready"], + "timestamp": "2025-06-18T03:34:18.116Z" }, "progress": { - "value": null + "value": 0, + "unit": "%", + "timestamp": "2025-08-20T17:44:52.796Z" }, "autoCleaningMode": { - "value": "off", - "timestamp": "2025-03-15T05:30:11.075Z" + "value": "on", + "timestamp": "2025-08-18T18:16:46.505Z" } }, "samsungce.individualControlLock": { @@ -401,12 +423,45 @@ "value": null } }, + "samsungce.alwaysOnSensing": { + "origins": { + "value": null + }, + "alwaysOn": { + "value": null + } + }, "refresh": {}, "execute": { "data": { "value": null } }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "02181A230313", + "description": "Version" + }, + { + "id": "1", + "swType": "Firmware", + "versionNumber": "20082000,FFFFFFFF", + "description": "Version" + }, + { + "id": "2", + "swType": "Outdoor", + "versionNumber": "19112900,10000400", + "description": "Version" + } + ], + "timestamp": "2025-06-18T03:34:18.116Z" + } + }, "samsungce.selfCheck": { "result": { "value": null @@ -420,36 +475,37 @@ }, "errors": { "value": [], - "timestamp": "2025-02-08T08:54:15.048Z" + "timestamp": "2025-10-03T16:56:38.154Z" }, "status": { - "value": null + "value": "ready", + "timestamp": "2025-10-03T18:10:05.915Z" } }, "custom.dustFilter": { "dustFilterUsageStep": { "value": 1, - "timestamp": "2025-02-08T08:54:15.473Z" + "timestamp": "2025-06-17T12:15:04.968Z" }, "dustFilterUsage": { - "value": 69, - "timestamp": "2025-03-26T10:57:41.097Z" + "value": 22, + "timestamp": "2025-10-03T15:20:27.649Z" }, "dustFilterLastResetDate": { "value": null }, "dustFilterStatus": { "value": "normal", - "timestamp": "2025-02-08T08:54:15.473Z" + "timestamp": "2025-06-17T12:15:04.968Z" }, "dustFilterCapacity": { "value": 500, "unit": "Hour", - "timestamp": "2025-02-08T08:54:15.473Z" + "timestamp": "2025-06-17T12:15:04.968Z" }, "dustFilterResetType": { "value": ["replaceable", "washable"], - "timestamp": "2025-02-08T08:54:15.473Z" + "timestamp": "2025-06-17T12:15:04.968Z" } }, "odorSensor": { @@ -485,16 +541,16 @@ "custom.energyType": { "energyType": { "value": "1.0", - "timestamp": "2024-06-21T13:45:16.785Z" + "timestamp": "2024-12-14T03:34:07.681Z" }, "energySavingSupport": { - "value": true, - "timestamp": "2024-06-21T13:58:08.419Z" + "value": false, + "timestamp": "2021-12-29T01:15:12.093Z" }, "drMaxDuration": { - "value": 99999999, + "value": 1440, "unit": "min", - "timestamp": "2024-06-21T13:51:39.304Z" + "timestamp": "2022-01-01T12:19:18.649Z" }, "energySavingLevel": { "value": null @@ -506,36 +562,35 @@ "value": null }, "energySavingOperation": { - "value": false, - "timestamp": "2025-02-08T08:54:16.767Z" + "value": null }, "notificationTemplateID": { "value": null }, "energySavingOperationSupport": { "value": false, - "timestamp": "2025-03-24T04:56:36.855Z" + "timestamp": "2022-01-01T12:19:18.649Z" } }, "samsungce.softwareUpdate": { "targetModule": { "value": {}, - "timestamp": "2025-02-08T08:54:16.685Z" + "timestamp": "2025-06-19T07:55:10.859Z" }, "otnDUID": { - "value": "MTCPH4AI4MTYO", - "timestamp": "2025-02-08T08:54:15.626Z" + "value": "ZPCNQWBWA22VW", + "timestamp": "2025-06-18T03:34:18.116Z" }, "lastUpdatedDate": { "value": null }, "availableModules": { "value": [], - "timestamp": "2025-02-08T08:54:15.626Z" + "timestamp": "2024-12-13T22:55:32.254Z" }, "newVersionAvailable": { "value": false, - "timestamp": "2025-02-08T08:54:15.626Z" + "timestamp": "2025-06-18T03:34:18.116Z" }, "operatingState": { "value": null @@ -571,13 +626,16 @@ }, "custom.doNotDisturbMode": { "doNotDisturb": { - "value": null + "value": "off", + "timestamp": "2021-05-28T08:57:59.311Z" }, "startTime": { - "value": null + "value": "0000", + "timestamp": "2021-05-28T08:57:59.311Z" }, "endTime": { - "value": null + "value": "0000", + "timestamp": "2021-05-28T08:57:59.311Z" } } } diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_000003.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_000003.json index 44dafc213f0b3..16374ab250384 100644 --- a/tests/components/smartthings/fixtures/devices/da_ac_rac_000003.json +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_000003.json @@ -1,15 +1,15 @@ { "items": [ { - "deviceId": "c76d6f38-1b7f-13dd-37b5-db18d5272783", + "deviceId": "1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977", "name": "Samsung Room A/C", - "label": "Office AirFree", + "label": "Clim Salon", "manufacturerName": "Samsung Electronics", "presentationId": "DA-AC-RAC-000003", "deviceManufacturerCode": "Samsung Electronics", - "locationId": "403cd42e-f692-416c-91fd-1883c00e3262", - "ownerId": "dd474e5c-59c0-4bea-a319-ff5287fd3373", - "roomId": "dffe353e-b3c5-4a97-8a8a-797ccc649fab", + "locationId": "460f8f20-0428-491d-8ead-8d901cc9f7eb", + "ownerId": "88597a89-5117-0b3c-264f-396fbc9072a4", + "roomId": "e7a14810-8688-4d37-bf02-72a2c673c0d7", "deviceTypeName": "Samsung OCF Air Conditioner", "components": [ { @@ -152,6 +152,10 @@ "id": "custom.disabledCapabilities", "version": 1 }, + { + "id": "samsungce.alwaysOnSensing", + "version": 1 + }, { "id": "samsungce.deviceIdentification", "version": 1 @@ -168,6 +172,10 @@ "id": "samsungce.softwareUpdate", "version": 1 }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, { "id": "samsungce.selfCheck", "version": 1 @@ -182,27 +190,28 @@ "name": "AirConditioner", "categoryType": "manufacturer" } - ] + ], + "optional": false } ], - "createTime": "2024-06-21T13:45:16.238Z", + "createTime": "2021-05-28T08:51:37.616Z", "profile": { - "id": "cedae6e3-1ec9-37e3-9aba-f717518156b8" + "id": "bb4a6df4-6e0f-303a-ac35-445ea78a41fe" }, "ocf": { "ocfDeviceType": "oic.d.airconditioner", "name": "Samsung Room A/C", "specVersion": "core.1.1.0", - "verticalDomainSpecVersion": "1.2.1", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", "manufacturerName": "Samsung Electronics", - "modelNumber": "ARTIK051_PRAC_20K|10256941|60010534001411014600003200800000", + "modelNumber": "ARTIK051_PRAC_20K|10217841|60010523001411010200001000000000", "platformVersion": "DAWIT 2.0", "platformOS": "TizenRT 1.0 + IPv6", "hwVersion": "ARTIK051", "firmwareVersion": "ARTIK051_PRAC_20K_11230313", "vendorId": "DA-AC-RAC-000003", "vendorResourceClientServerVersion": "ARTIK051 Release 2.211222.1", - "lastSignupTime": "2024-06-21T13:45:08.592221Z", + "lastSignupTime": "2021-05-28T08:51:30.356392Z", "transferCandidate": false, "additionalAuthCodeRequired": false }, diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 293aa961ca74c..e8250a6d1a22a 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -420,7 +420,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_ac_rac_000003][climate.office_airfree-entry] +# name: test_all_entities[da_ac_rac_000003][climate.clim_salon-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -439,6 +439,7 @@ , , , + , ]), 'max_temp': 35, 'min_temp': 7, @@ -448,6 +449,8 @@ 'quiet', 'smart', 'boost', + 'motion_indirect', + 'motion_direct', 'wind_free', 'wind_free_sleep', ]), @@ -465,7 +468,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.office_airfree', + 'entity_id': 'climate.clim_salon', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -483,17 +486,19 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': 'air_conditioner', - 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main', + 'unique_id': '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977_main', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ac_rac_000003][climate.office_airfree-state] +# name: test_all_entities[da_ac_rac_000003][climate.clim_salon-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_temperature': 26, + 'current_temperature': 20, 'drlc_status_duration': 0, + 'drlc_status_level': -1, 'drlc_status_override': False, - 'fan_mode': 'low', + 'drlc_status_start': '1970-01-01T00:00:00Z', + 'fan_mode': 'auto', 'fan_modes': list([ 'auto', 'low', @@ -501,13 +506,14 @@ 'high', 'turbo', ]), - 'friendly_name': 'Office AirFree', + 'friendly_name': 'Clim Salon', 'hvac_modes': list([ , , , , , + , ]), 'max_temp': 35, 'min_temp': 7, @@ -518,6 +524,8 @@ 'quiet', 'smart', 'boost', + 'motion_indirect', + 'motion_direct', 'wind_free', 'wind_free_sleep', ]), @@ -529,14 +537,14 @@ 'vertical', 'horizontal', ]), - 'temperature': 24, + 'temperature': 19, }), 'context': , - 'entity_id': 'climate.office_airfree', + 'entity_id': 'climate.clim_salon', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'cool', + 'state': 'heat', }) # --- # name: test_all_entities[da_ac_rac_01001][climate.aire_dormitorio_principal-entry] diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 42eaf548b36dd..4caa2952cbc0c 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -451,7 +451,7 @@ 'identifiers': set({ tuple( 'smartthings', - 'c76d6f38-1b7f-13dd-37b5-db18d5272783', + '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977', ), }), 'labels': set({ @@ -459,7 +459,7 @@ 'manufacturer': 'Samsung Electronics', 'model': 'ARTIK051_PRAC_20K', 'model_id': None, - 'name': 'Office AirFree', + 'name': 'Clim Salon', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index c573ccbbc27a3..5e9d093eb791d 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2509,7 +2509,7 @@ 'state': '100', }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy-entry] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2524,7 +2524,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.office_airfree_energy', + 'entity_id': 'sensor.clim_salon_energy', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2545,27 +2545,27 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unique_id': '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy-state] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Office AirFree Energy', + 'friendly_name': 'Clim Salon Energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.office_airfree_energy', + 'entity_id': 'sensor.clim_salon_energy', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '602.171', + 'state': '6652.713', }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy_difference-entry] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2580,7 +2580,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.office_airfree_energy_difference', + 'entity_id': 'sensor.clim_salon_energy_difference', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2601,27 +2601,27 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unique_id': '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy_difference-state] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_energy_difference-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Office AirFree Energy difference', + 'friendly_name': 'Clim Salon Energy difference', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.office_airfree_energy_difference', + 'entity_id': 'sensor.clim_salon_energy_difference', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': '0.002', }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy_saved-entry] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_energy_saved-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2636,7 +2636,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.office_airfree_energy_saved', + 'entity_id': 'sensor.clim_salon_energy_saved', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2657,27 +2657,27 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unique_id': '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy_saved-state] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_energy_saved-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Office AirFree Energy saved', + 'friendly_name': 'Clim Salon Energy saved', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.office_airfree_energy_saved', + 'entity_id': 'sensor.clim_salon_energy_saved', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_humidity-entry] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2692,7 +2692,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.office_airfree_humidity', + 'entity_id': 'sensor.clim_salon_humidity', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2710,27 +2710,27 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_relativeHumidityMeasurement_humidity_humidity', + 'unique_id': '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977_main_relativeHumidityMeasurement_humidity_humidity', 'unit_of_measurement': '%', }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_humidity-state] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_humidity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', - 'friendly_name': 'Office AirFree Humidity', + 'friendly_name': 'Clim Salon Humidity', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.office_airfree_humidity', + 'entity_id': 'sensor.clim_salon_humidity', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '48', + 'state': '59', }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_power-entry] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2745,7 +2745,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.office_airfree_power', + 'entity_id': 'sensor.clim_salon_power', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2766,29 +2766,29 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_power_meter', + 'unique_id': '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_power-state] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Office AirFree Power', - 'power_consumption_end': '2025-03-27T05:40:02Z', - 'power_consumption_start': '2025-03-27T05:29:22Z', + 'friendly_name': 'Clim Salon Power', + 'power_consumption_end': '2025-10-04T15:55:07Z', + 'power_consumption_start': '2025-10-04T15:54:24Z', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.office_airfree_power', + 'entity_id': 'sensor.clim_salon_power', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '143', }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_power_energy-entry] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_power_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2803,7 +2803,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.office_airfree_power_energy', + 'entity_id': 'sensor.clim_salon_power_energy', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2824,27 +2824,27 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unique_id': '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_power_energy-state] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_power_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Office AirFree Power energy', + 'friendly_name': 'Clim Salon Power energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.office_airfree_power_energy', + 'entity_id': 'sensor.clim_salon_power_energy', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': '0.00174704861111111', }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_temperature-entry] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2859,7 +2859,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.office_airfree_temperature', + 'entity_id': 'sensor.clim_salon_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2880,27 +2880,27 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_temperatureMeasurement_temperature_temperature', + 'unique_id': '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_temperature-state] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Office AirFree Temperature', + 'friendly_name': 'Clim Salon Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.office_airfree_temperature', + 'entity_id': 'sensor.clim_salon_temperature', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '26', + 'state': '20', }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_volume-entry] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2913,7 +2913,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.office_airfree_volume', + 'entity_id': 'sensor.clim_salon_volume', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2931,18 +2931,18 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'audio_volume', - 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_audioVolume_volume_volume', + 'unique_id': '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977_main_audioVolume_volume_volume', 'unit_of_measurement': '%', }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_volume-state] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_volume-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Office AirFree Volume', + 'friendly_name': 'Clim Salon Volume', 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.office_airfree_volume', + 'entity_id': 'sensor.clim_salon_volume', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index d27bd042b119b..a8373eb287037 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -469,11 +469,11 @@ async def test_ac_set_preset_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.office_airfree", ATTR_PRESET_MODE: mode}, + {ATTR_ENTITY_ID: "climate.clim_salon", ATTR_PRESET_MODE: mode}, blocking=True, ) devices.execute_device_command.assert_called_with( - "c76d6f38-1b7f-13dd-37b5-db18d5272783", + "1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977", Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, Command.SET_AC_OPTIONAL_MODE, MAIN, From ade424c0743f9a812a5f7494fa5e257ac08a2d8f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 6 Oct 2025 17:54:19 +0200 Subject: [PATCH 02/14] Update attrs to 25.4.0 (#153849) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c47ff2c605e99..9bbda0ac21822 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ astral==2.2 async-interrupt==1.2.2 async-upnp-client==0.45.0 atomicwrites-homeassistant==1.4.1 -attrs==25.3.0 +attrs==25.4.0 audioop-lts==0.2.1 av==13.1.0 awesomeversion==25.5.0 diff --git a/pyproject.toml b/pyproject.toml index 3ce2b9a4c64f6..4ae80d64f5991 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ "annotatedyaml==1.0.2", "astral==2.2", "async-interrupt==1.2.2", - "attrs==25.3.0", + "attrs==25.4.0", "atomicwrites-homeassistant==1.4.1", "audioop-lts==0.2.1", "awesomeversion==25.5.0", diff --git a/requirements.txt b/requirements.txt index d10b789c4e3e6..9c52c940bb704 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ aiozoneinfo==0.2.3 annotatedyaml==1.0.2 astral==2.2 async-interrupt==1.2.2 -attrs==25.3.0 +attrs==25.4.0 atomicwrites-homeassistant==1.4.1 audioop-lts==0.2.1 awesomeversion==25.5.0 From f72047eb021a6e42f0e6f09adeca4d107fcd25b0 Mon Sep 17 00:00:00 2001 From: Jordan Harvey Date: Mon, 6 Oct 2025 17:36:46 +0100 Subject: [PATCH 03/14] Add new Nintendo Parental Controls integration (#145343) Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + .../components/nintendo_parental/__init__.py | 51 +++++++++ .../nintendo_parental/config_flow.py | 61 +++++++++++ .../components/nintendo_parental/const.py | 5 + .../nintendo_parental/coordinator.py | 52 +++++++++ .../components/nintendo_parental/entity.py | 41 +++++++ .../nintendo_parental/manifest.json | 11 ++ .../nintendo_parental/quality_scale.yaml | 81 ++++++++++++++ .../components/nintendo_parental/sensor.py | 91 ++++++++++++++++ .../components/nintendo_parental/strings.json | 38 +++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + .../components/nintendo_parental/__init__.py | 1 + .../components/nintendo_parental/conftest.py | 93 ++++++++++++++++ tests/components/nintendo_parental/const.py | 5 + .../nintendo_parental/test_config_flow.py | 101 ++++++++++++++++++ 18 files changed, 646 insertions(+) create mode 100644 homeassistant/components/nintendo_parental/__init__.py create mode 100644 homeassistant/components/nintendo_parental/config_flow.py create mode 100644 homeassistant/components/nintendo_parental/const.py create mode 100644 homeassistant/components/nintendo_parental/coordinator.py create mode 100644 homeassistant/components/nintendo_parental/entity.py create mode 100644 homeassistant/components/nintendo_parental/manifest.json create mode 100644 homeassistant/components/nintendo_parental/quality_scale.yaml create mode 100644 homeassistant/components/nintendo_parental/sensor.py create mode 100644 homeassistant/components/nintendo_parental/strings.json create mode 100644 tests/components/nintendo_parental/__init__.py create mode 100644 tests/components/nintendo_parental/conftest.py create mode 100644 tests/components/nintendo_parental/const.py create mode 100644 tests/components/nintendo_parental/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 3235a5b73dff0..f518040f55ba7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1065,6 +1065,8 @@ build.json @home-assistant/supervisor /homeassistant/components/nilu/ @hfurubotten /homeassistant/components/nina/ @DeerMaximum /tests/components/nina/ @DeerMaximum +/homeassistant/components/nintendo_parental/ @pantherale0 +/tests/components/nintendo_parental/ @pantherale0 /homeassistant/components/nissan_leaf/ @filcole /homeassistant/components/noaa_tides/ @jdelaney72 /homeassistant/components/nobo_hub/ @echoromeo @oyvindwe diff --git a/homeassistant/components/nintendo_parental/__init__.py b/homeassistant/components/nintendo_parental/__init__.py new file mode 100644 index 0000000000000..91b4ebee1cb41 --- /dev/null +++ b/homeassistant/components/nintendo_parental/__init__.py @@ -0,0 +1,51 @@ +"""The Nintendo Switch Parental Controls integration.""" + +from __future__ import annotations + +from pynintendoparental import Authenticator +from pynintendoparental.exceptions import ( + InvalidOAuthConfigurationException, + InvalidSessionTokenException, +) + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_SESSION_TOKEN, DOMAIN +from .coordinator import NintendoParentalConfigEntry, NintendoUpdateCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry( + hass: HomeAssistant, entry: NintendoParentalConfigEntry +) -> bool: + """Set up Nintendo Switch Parental Controls from a config entry.""" + try: + nintendo_auth = await Authenticator.complete_login( + auth=None, + response_token=entry.data[CONF_SESSION_TOKEN], + is_session_token=True, + client_session=async_get_clientsession(hass), + ) + except (InvalidSessionTokenException, InvalidOAuthConfigurationException) as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="auth_expired", + ) from err + entry.runtime_data = coordinator = NintendoUpdateCoordinator( + hass, nintendo_auth, entry + ) + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: NintendoParentalConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/nintendo_parental/config_flow.py b/homeassistant/components/nintendo_parental/config_flow.py new file mode 100644 index 0000000000000..1bb16e6bb1148 --- /dev/null +++ b/homeassistant/components/nintendo_parental/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for the Nintendo Switch Parental Controls integration.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from pynintendoparental import Authenticator +from pynintendoparental.exceptions import HttpException, InvalidSessionTokenException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_SESSION_TOKEN, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class NintendoConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Nintendo Switch Parental Controls.""" + + def __init__(self) -> None: + """Initialize a new config flow instance.""" + self.auth: Authenticator | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors = {} + if self.auth is None: + self.auth = Authenticator.generate_login( + client_session=async_get_clientsession(self.hass) + ) + + if user_input is not None: + try: + await self.auth.complete_login( + self.auth, user_input[CONF_API_TOKEN], False + ) + except (ValueError, InvalidSessionTokenException, HttpException): + errors["base"] = "invalid_auth" + else: + if TYPE_CHECKING: + assert self.auth.account_id + await self.async_set_unique_id(self.auth.account_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self.auth.account_id, + data={ + CONF_SESSION_TOKEN: self.auth.get_session_token, + }, + ) + return self.async_show_form( + step_id="user", + description_placeholders={"link": self.auth.login_url}, + data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), + errors=errors, + ) diff --git a/homeassistant/components/nintendo_parental/const.py b/homeassistant/components/nintendo_parental/const.py new file mode 100644 index 0000000000000..0cea2e56ac8a5 --- /dev/null +++ b/homeassistant/components/nintendo_parental/const.py @@ -0,0 +1,5 @@ +"""Constants for the Nintendo Switch Parental Controls integration.""" + +DOMAIN = "nintendo_parental" +CONF_UPDATE_INTERVAL = "update_interval" +CONF_SESSION_TOKEN = "session_token" diff --git a/homeassistant/components/nintendo_parental/coordinator.py b/homeassistant/components/nintendo_parental/coordinator.py new file mode 100644 index 0000000000000..49b4fae60f352 --- /dev/null +++ b/homeassistant/components/nintendo_parental/coordinator.py @@ -0,0 +1,52 @@ +"""Nintendo Parental Controls data coordinator.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from pynintendoparental import Authenticator, NintendoParental +from pynintendoparental.exceptions import InvalidOAuthConfigurationException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +type NintendoParentalConfigEntry = ConfigEntry[NintendoUpdateCoordinator] + +_LOGGER = logging.getLogger(__name__) +UPDATE_INTERVAL = timedelta(seconds=60) + + +class NintendoUpdateCoordinator(DataUpdateCoordinator[None]): + """Nintendo data update coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + authenticator: Authenticator, + config_entry: NintendoParentalConfigEntry, + ) -> None: + """Initialize update coordinator.""" + super().__init__( + hass=hass, + logger=_LOGGER, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + config_entry=config_entry, + ) + self.api = NintendoParental( + authenticator, hass.config.time_zone, hass.config.language + ) + + async def _async_update_data(self) -> None: + """Update data from Nintendo's API.""" + try: + return await self.api.update() + except InvalidOAuthConfigurationException as err: + raise ConfigEntryError( + err, translation_domain=DOMAIN, translation_key="invalid_auth" + ) from err diff --git a/homeassistant/components/nintendo_parental/entity.py b/homeassistant/components/nintendo_parental/entity.py new file mode 100644 index 0000000000000..74d3bcae8a766 --- /dev/null +++ b/homeassistant/components/nintendo_parental/entity.py @@ -0,0 +1,41 @@ +"""Base entity definition for Nintendo Parental.""" + +from __future__ import annotations + +from pynintendoparental.device import Device + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import NintendoUpdateCoordinator + + +class NintendoDevice(CoordinatorEntity[NintendoUpdateCoordinator]): + """Represent a Nintendo Switch.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: NintendoUpdateCoordinator, device: Device, key: str + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self._device = device + self._attr_unique_id = f"{device.device_id}_{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_id)}, + manufacturer="Nintendo", + name=device.name, + sw_version=device.extra["firmwareVersion"]["displayedVersion"], + ) + + async def async_added_to_hass(self) -> None: + """When entity is loaded.""" + await super().async_added_to_hass() + self._device.add_device_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """When will be removed from HASS.""" + self._device.remove_device_callback(self.async_write_ha_state) + await super().async_will_remove_from_hass() diff --git a/homeassistant/components/nintendo_parental/manifest.json b/homeassistant/components/nintendo_parental/manifest.json new file mode 100644 index 0000000000000..1e4dbdb342a35 --- /dev/null +++ b/homeassistant/components/nintendo_parental/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "nintendo_parental", + "name": "Nintendo Switch Parental Controls", + "codeowners": ["@pantherale0"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/nintendo_parental", + "iot_class": "cloud_polling", + "loggers": ["pynintendoparental"], + "quality_scale": "bronze", + "requirements": ["pynintendoparental==1.0.1"] +} diff --git a/homeassistant/components/nintendo_parental/quality_scale.yaml b/homeassistant/components/nintendo_parental/quality_scale.yaml new file mode 100644 index 0000000000000..523d4fe68ce3a --- /dev/null +++ b/homeassistant/components/nintendo_parental/quality_scale.yaml @@ -0,0 +1,81 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions are defined. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + No IP discovery. + discovery: + status: exempt + comment: | + No discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: | + No specific icons defined. + reconfiguration-flow: todo + repair-issues: + comment: | + No issues in integration + status: exempt + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/nintendo_parental/sensor.py b/homeassistant/components/nintendo_parental/sensor.py new file mode 100644 index 0000000000000..803fb39bcb4af --- /dev/null +++ b/homeassistant/components/nintendo_parental/sensor.py @@ -0,0 +1,91 @@ +"""Sensor platform for Nintendo Parental.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import NintendoParentalConfigEntry, NintendoUpdateCoordinator +from .entity import Device, NintendoDevice + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +class NintendoParentalSensor(StrEnum): + """Store keys for Nintendo Parental sensors.""" + + PLAYING_TIME = "playing_time" + TIME_REMAINING = "time_remaining" + + +@dataclass(kw_only=True, frozen=True) +class NintendoParentalSensorEntityDescription(SensorEntityDescription): + """Description for Nintendo Parental sensor entities.""" + + value_fn: Callable[[Device], int | float | None] + + +SENSOR_DESCRIPTIONS: tuple[NintendoParentalSensorEntityDescription, ...] = ( + NintendoParentalSensorEntityDescription( + key=NintendoParentalSensor.PLAYING_TIME, + translation_key=NintendoParentalSensor.PLAYING_TIME, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.today_playing_time, + ), + NintendoParentalSensorEntityDescription( + key=NintendoParentalSensor.TIME_REMAINING, + translation_key=NintendoParentalSensor.TIME_REMAINING, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.today_time_remaining, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NintendoParentalConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + async_add_devices( + NintendoParentalSensorEntity(entry.runtime_data, device, sensor) + for device in entry.runtime_data.api.devices.values() + for sensor in SENSOR_DESCRIPTIONS + ) + + +class NintendoParentalSensorEntity(NintendoDevice, SensorEntity): + """Represent a single sensor.""" + + entity_description: NintendoParentalSensorEntityDescription + + def __init__( + self, + coordinator: NintendoUpdateCoordinator, + device: Device, + description: NintendoParentalSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator=coordinator, device=device, key=description.key) + self.entity_description = description + + @property + def native_value(self) -> int | float | None: + """Return the native value.""" + return self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/nintendo_parental/strings.json b/homeassistant/components/nintendo_parental/strings.json new file mode 100644 index 0000000000000..f35746b41f3cb --- /dev/null +++ b/homeassistant/components/nintendo_parental/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "step": { + "user": { + "description": "To obtain your access token, click [Nintendo Login]({link}) to sign in to your Nintendo account. Then, for the account you want to link, right-click on the red **Select this person** button and choose **Copy Link Address**.", + "data": { + "api_token": "Access token" + }, + "data_description": { + "api_token": "The link copied from the Nintendo website" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + }, + "entity": { + "sensor": { + "playing_time": { + "name": "Used screen time" + }, + "time_remaining": { + "name": "Screen time remaining" + } + } + }, + "exceptions": { + "auth_expired": { + "message": "Authentication expired. Please remove and re-add the integration to reconnect." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index dbd749370ca72..fad6bf969391f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -440,6 +440,7 @@ "nightscout", "niko_home_control", "nina", + "nintendo_parental", "nmap_tracker", "nmbs", "nobo_hub", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4f49dad82dcfb..3cf28545b785f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4459,6 +4459,12 @@ "iot_class": "cloud_polling", "single_config_entry": true }, + "nintendo_parental": { + "name": "Nintendo Switch Parental Controls", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "nissan_leaf": { "name": "Nissan Leaf", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 7f9fac77716a4..a35aa909e9d7f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2209,6 +2209,9 @@ pynetio==0.1.9.1 # homeassistant.components.nina pynina==0.3.6 +# homeassistant.components.nintendo_parental +pynintendoparental==1.0.1 + # homeassistant.components.nobo_hub pynobo==1.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index daf40ae32ded6..bd497b6bd513e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1845,6 +1845,9 @@ pynetgear==0.10.10 # homeassistant.components.nina pynina==0.3.6 +# homeassistant.components.nintendo_parental +pynintendoparental==1.0.1 + # homeassistant.components.nobo_hub pynobo==1.8.1 diff --git a/tests/components/nintendo_parental/__init__.py b/tests/components/nintendo_parental/__init__.py new file mode 100644 index 0000000000000..89853538f8e5a --- /dev/null +++ b/tests/components/nintendo_parental/__init__.py @@ -0,0 +1 @@ +"""Tests for the Nintendo Switch Parental Controls integration.""" diff --git a/tests/components/nintendo_parental/conftest.py b/tests/components/nintendo_parental/conftest.py new file mode 100644 index 0000000000000..c6da3c8748b66 --- /dev/null +++ b/tests/components/nintendo_parental/conftest.py @@ -0,0 +1,93 @@ +"""Common fixtures for the Nintendo Switch Parental Controls tests.""" + +from collections.abc import Generator +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +from pynintendoparental.device import Device +import pytest + +from homeassistant.components.nintendo_parental.const import DOMAIN + +from .const import ACCOUNT_ID, API_TOKEN, LOGIN_URL + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={"session_token": API_TOKEN}, + unique_id=ACCOUNT_ID, + ) + + +@pytest.fixture +def mock_nintendo_device() -> Device: + """Return a mocked device.""" + mock = AsyncMock(spec=Device) + mock.device_id = "testdevid" + mock.name = "Home Assistant Test" + mock.extra = {"device": {"firmwareVersion": {"displayedVersion": "99.99.99"}}} + mock.limit_time = 120 + mock.today_playing_time = 110 + return mock + + +@pytest.fixture +def mock_nintendo_authenticator() -> Generator[MagicMock]: + """Mock Nintendo Authenticator.""" + with ( + patch( + "homeassistant.components.nintendo_parental.Authenticator", + autospec=True, + ) as mock_auth_class, + patch( + "homeassistant.components.nintendo_parental.config_flow.Authenticator", + new=mock_auth_class, + ), + ): + mock_auth = MagicMock() + mock_auth._id_token = API_TOKEN + mock_auth._at_expiry = datetime(2099, 12, 31, 23, 59, 59) + mock_auth.account_id = ACCOUNT_ID + mock_auth.login_url = LOGIN_URL + mock_auth.get_session_token = API_TOKEN + # Patch complete_login as an AsyncMock on both instance and class as this is a class method + mock_auth.complete_login = AsyncMock() + type(mock_auth).complete_login = mock_auth.complete_login + mock_auth_class.generate_login.return_value = mock_auth + yield mock_auth + + +@pytest.fixture +def mock_nintendo_client( + mock_nintendo_device: Device, +) -> Generator[AsyncMock]: + """Mock a Nintendo client.""" + with ( + patch( + "homeassistant.components.nintendo_parental.NintendoParental", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.nintendo_parental.config_flow.NintendoParental", + new=mock_client, + ), + ): + client = mock_client.return_value + client.update.return_value = True + client.devices.return_value = {"testdevid": mock_nintendo_device} + yield client + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nintendo_parental.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/nintendo_parental/const.py b/tests/components/nintendo_parental/const.py new file mode 100644 index 0000000000000..5d8e3f7b7134c --- /dev/null +++ b/tests/components/nintendo_parental/const.py @@ -0,0 +1,5 @@ +"""Constants for the Nintendo Parental Controls test suite.""" + +ACCOUNT_ID = "aabbccddee112233" +API_TOKEN = "valid_token" +LOGIN_URL = "http://example.com" diff --git a/tests/components/nintendo_parental/test_config_flow.py b/tests/components/nintendo_parental/test_config_flow.py new file mode 100644 index 0000000000000..13216257572f7 --- /dev/null +++ b/tests/components/nintendo_parental/test_config_flow.py @@ -0,0 +1,101 @@ +"""Test the Nintendo Switch Parental Controls config flow.""" + +from unittest.mock import AsyncMock + +from pynintendoparental.exceptions import InvalidSessionTokenException + +from homeassistant import config_entries +from homeassistant.components.nintendo_parental.const import CONF_SESSION_TOKEN, DOMAIN +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import ACCOUNT_ID, API_TOKEN, LOGIN_URL + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_nintendo_authenticator: AsyncMock, +) -> None: + """Test a full and successful config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result is not None + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert "link" in result["description_placeholders"] + assert result["description_placeholders"]["link"] == LOGIN_URL + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: API_TOKEN} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ACCOUNT_ID + assert result["data"][CONF_SESSION_TOKEN] == API_TOKEN + assert result["result"].unique_id == ACCOUNT_ID + + +async def test_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nintendo_authenticator: AsyncMock, +) -> None: + """Test that the flow aborts if the account is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: API_TOKEN} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_invalid_auth( + hass: HomeAssistant, + mock_nintendo_authenticator: AsyncMock, +) -> None: + """Test handling of invalid authentication.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result is not None + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert "link" in result["description_placeholders"] + + # Simulate invalid authentication by raising an exception + mock_nintendo_authenticator.complete_login.side_effect = ( + InvalidSessionTokenException + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: "invalid_token"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + # Now ensure that the flow can be recovered + mock_nintendo_authenticator.complete_login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: API_TOKEN} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ACCOUNT_ID + assert result["data"][CONF_SESSION_TOKEN] == API_TOKEN + assert result["result"].unique_id == ACCOUNT_ID From 7c665c53b521dd7dfa65d6397e22a344b031c840 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 6 Oct 2025 19:07:48 +0200 Subject: [PATCH 04/14] Change translation of `box` in `number` to "Input field" for consistency (#153850) --- homeassistant/components/number/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 8c94269f069b9..b3cc5f7c814c7 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -22,7 +22,7 @@ "name": "Mode", "state": { "auto": "Automatic", - "box": "Box", + "box": "Input field", "slider": "Slider" } }, From 75e900606e8e43020e23e1e6e1ae16e4aa291b20 Mon Sep 17 00:00:00 2001 From: William Scanlon <6432770+w1ll1am23@users.noreply.github.com> Date: Mon, 6 Oct 2025 13:21:21 -0400 Subject: [PATCH 05/14] Update water heater max temperature (#150970) --- homeassistant/components/water_heater/services.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/water_heater/services.yaml b/homeassistant/components/water_heater/services.yaml index b60cfdd8c4846..3600545175e45 100644 --- a/homeassistant/components/water_heater/services.yaml +++ b/homeassistant/components/water_heater/services.yaml @@ -20,8 +20,9 @@ set_temperature: selector: number: min: 0 - max: 100 + max: 250 step: 0.5 + mode: box unit_of_measurement: "°" operation_mode: example: eco From 1c7b9cc354f87f7714b488e135852f5ad2fe3267 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Mon, 6 Oct 2025 14:52:24 -0300 Subject: [PATCH 06/14] Avoid storing entities list in ONVIF binary_sensor and sensor (#153857) --- .../components/onvif/binary_sensor.py | 33 ++++++++++--------- homeassistant/components/onvif/sensor.py | 30 ++++++++--------- 2 files changed, 32 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/onvif/binary_sensor.py b/homeassistant/components/onvif/binary_sensor.py index 48c595e9905f9..3c740d445d8e4 100644 --- a/homeassistant/components/onvif/binary_sensor.py +++ b/homeassistant/components/onvif/binary_sensor.py @@ -31,38 +31,39 @@ async def async_setup_entry( events = device.events.get_platform("binary_sensor") entity_names = build_event_entity_names(events) - entities = { - event.uid: ONVIFBinarySensor(event.uid, device, name=entity_names[event.uid]) - for event in events - } + uids = set() + entities = [] + for event in events: + uids.add(event.uid) + entities.append( + ONVIFBinarySensor(event.uid, device, name=entity_names[event.uid]) + ) ent_reg = er.async_get(hass) for entry in er.async_entries_for_config_entry(ent_reg, config_entry.entry_id): - if entry.domain == "binary_sensor" and entry.unique_id not in entities: - entities[entry.unique_id] = ONVIFBinarySensor( - entry.unique_id, device, entry=entry - ) + if entry.domain == "binary_sensor" and entry.unique_id not in uids: + uids.add(entry.unique_id) + entities.append(ONVIFBinarySensor(entry.unique_id, device, entry=entry)) - async_add_entities(entities.values()) + async_add_entities(entities) uids_by_platform = device.events.get_uids_by_platform("binary_sensor") @callback def async_check_entities() -> None: """Check if we have added an entity for the event.""" nonlocal uids_by_platform - if not (missing := uids_by_platform.difference(entities)): + if not (missing := uids_by_platform.difference(uids)): return events = device.events.get_platform("binary_sensor") entity_names = build_event_entity_names(events) - new_entities: dict[str, ONVIFBinarySensor] = { - uid: ONVIFBinarySensor(uid, device, name=entity_names[uid]) - for uid in missing - } + new_entities = [ + ONVIFBinarySensor(uid, device, name=entity_names[uid]) for uid in missing + ] if new_entities: - entities.update(new_entities) - async_add_entities(new_entities.values()) + uids.update(missing) + async_add_entities(new_entities) device.events.async_add_listener(async_check_entities) diff --git a/homeassistant/components/onvif/sensor.py b/homeassistant/components/onvif/sensor.py index 228fd1bbdd37e..15e2144b51038 100644 --- a/homeassistant/components/onvif/sensor.py +++ b/homeassistant/components/onvif/sensor.py @@ -30,37 +30,37 @@ async def async_setup_entry( events = device.events.get_platform("sensor") entity_names = build_event_entity_names(events) - entities = { - event.uid: ONVIFSensor(event.uid, device, name=entity_names[event.uid]) - for event in events - } + uids = set() + entities = [] + for event in events: + uids.add(event.uid) + entities.append(ONVIFSensor(event.uid, device, name=entity_names[event.uid])) ent_reg = er.async_get(hass) for entry in er.async_entries_for_config_entry(ent_reg, config_entry.entry_id): - if entry.domain == "sensor" and entry.unique_id not in entities: - entities[entry.unique_id] = ONVIFSensor( - entry.unique_id, device, entry=entry - ) + if entry.domain == "sensor" and entry.unique_id not in uids: + uids.add(entry.unique_id) + entities.append(ONVIFSensor(entry.unique_id, device, entry=entry)) - async_add_entities(entities.values()) + async_add_entities(entities) uids_by_platform = device.events.get_uids_by_platform("sensor") @callback def async_check_entities() -> None: """Check if we have added an entity for the event.""" nonlocal uids_by_platform - if not (missing := uids_by_platform.difference(entities)): + if not (missing := uids_by_platform.difference(uids)): return events = device.events.get_platform("sensor") entity_names = build_event_entity_names(events) - new_entities: dict[str, ONVIFSensor] = { - uid: ONVIFSensor(uid, device, name=entity_names[uid]) for uid in missing - } + new_entities = [ + ONVIFSensor(uid, device, name=entity_names[uid]) for uid in missing + ] if new_entities: - entities.update(new_entities) - async_add_entities(new_entities.values()) + uids.update(missing) + async_add_entities(new_entities) device.events.async_add_listener(async_check_entities) From fbcf0eb94cf339d64a513d9d2bad200da8ff75fd Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Mon, 6 Oct 2025 19:25:44 +0100 Subject: [PATCH 07/14] Increase connect and configuration time for rfxtrx (#153834) Increase the allowed time for connection and configuration. Some devices take a long time to respond to configuration changes and this time is counted for both network and configuration of the device. --- homeassistant/components/rfxtrx/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index d100999527fa8..8692ff4036696 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -48,7 +48,7 @@ DEFAULT_OFF_DELAY = 2.0 -CONNECT_TIMEOUT = 30.0 +CONNECT_TIMEOUT = 60.0 _LOGGER = logging.getLogger(__name__) From 5613be39809b0d590313daea4aa49289a7d565af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Oct 2025 13:43:37 -0500 Subject: [PATCH 08/14] Bump yarl to 1.22.0 (#153860) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9bbda0ac21822..e613a62d85839 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -74,7 +74,7 @@ voluptuous-openapi==0.1.0 voluptuous-serialize==2.7.0 voluptuous==0.15.2 webrtc-models==0.3.0 -yarl==1.20.1 +yarl==1.22.0 zeroconf==0.148.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index 4ae80d64f5991..0afcac91d9d59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,7 +78,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.7.0", "voluptuous-openapi==0.1.0", - "yarl==1.20.1", + "yarl==1.22.0", "webrtc-models==0.3.0", "zeroconf==0.148.0", ] diff --git a/requirements.txt b/requirements.txt index 9c52c940bb704..f9b4d5a43d96e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -50,6 +50,6 @@ uv==0.8.9 voluptuous==0.15.2 voluptuous-serialize==2.7.0 voluptuous-openapi==0.1.0 -yarl==1.20.1 +yarl==1.22.0 webrtc-models==0.3.0 zeroconf==0.148.0 From 81b134608061bd94f939259c0dc551d619cf941d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 6 Oct 2025 21:15:38 +0200 Subject: [PATCH 09/14] Handle timeout errors gracefully in Nord Pool services (#153856) --- homeassistant/components/nordpool/services.py | 2 +- tests/components/nordpool/test_services.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nordpool/services.py b/homeassistant/components/nordpool/services.py index e568764871af6..f84694d636491 100644 --- a/homeassistant/components/nordpool/services.py +++ b/homeassistant/components/nordpool/services.py @@ -157,7 +157,7 @@ async def get_prices(func: Callable, call: ServiceCall) -> ServiceResponse: ) from error except NordPoolEmptyResponseError: return {area: [] for area in areas} - except NordPoolError as error: + except (NordPoolError, TimeoutError) as error: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="connection_error", diff --git a/tests/components/nordpool/test_services.py b/tests/components/nordpool/test_services.py index 9d940af4ad77a..d4cc3085efd61 100644 --- a/tests/components/nordpool/test_services.py +++ b/tests/components/nordpool/test_services.py @@ -94,6 +94,7 @@ async def test_service_call( [ (NordPoolAuthenticationError, "authentication_error"), (NordPoolError, "connection_error"), + (TimeoutError, "connection_error"), ], ) @pytest.mark.freeze_time("2025-10-01T18:00:00+00:00") From d341065c34cdd0dd541b34df960e010387cc20f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 6 Oct 2025 20:25:10 +0100 Subject: [PATCH 10/14] Replace inner function with lambda in Idasen Desk (#153862) --- homeassistant/components/idasen_desk/coordinator.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/idasen_desk/coordinator.py b/homeassistant/components/idasen_desk/coordinator.py index f7b7edd2cc1a7..ee15a90c66739 100644 --- a/homeassistant/components/idasen_desk/coordinator.py +++ b/homeassistant/components/idasen_desk/coordinator.py @@ -41,16 +41,12 @@ def __init__( self._expected_connected = False self._height: int | None = None - @callback - def async_update_data() -> None: - self.async_set_updated_data(self._height) - self._debouncer = Debouncer( hass=self.hass, logger=_LOGGER, cooldown=UPDATE_DEBOUNCE_TIME, immediate=True, - function=async_update_data, + function=callback(lambda: self.async_set_updated_data(self._height)), ) async def async_connect(self) -> bool: From 6c8b1f3618024ebb6e4d91ad42a24fe15f5ad5ae Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 6 Oct 2025 21:31:55 +0200 Subject: [PATCH 11/14] Catch update exception in AirGradient (#153828) --- .../components/airgradient/update.py | 29 +++++++-- tests/components/airgradient/test_update.py | 65 ++++++++++++++++++- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/airgradient/update.py b/homeassistant/components/airgradient/update.py index 97cb8576e794b..3f2422078d497 100644 --- a/homeassistant/components/airgradient/update.py +++ b/homeassistant/components/airgradient/update.py @@ -1,7 +1,9 @@ """Airgradient Update platform.""" from datetime import timedelta +import logging +from airgradient import AirGradientConnectionError from propcache.api import cached_property from homeassistant.components.update import UpdateDeviceClass, UpdateEntity @@ -13,6 +15,7 @@ PARALLEL_UPDATES = 1 SCAN_INTERVAL = timedelta(hours=1) +_LOGGER = logging.getLogger(__name__) async def async_setup_entry( @@ -31,6 +34,7 @@ class AirGradientUpdate(AirGradientEntity, UpdateEntity): """Representation of Airgradient Update.""" _attr_device_class = UpdateDeviceClass.FIRMWARE + _server_unreachable_logged = False def __init__(self, coordinator: AirGradientCoordinator) -> None: """Initialize the entity.""" @@ -47,10 +51,27 @@ def installed_version(self) -> str: """Return the installed version of the entity.""" return self.coordinator.data.measures.firmware_version + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self._attr_available + async def async_update(self) -> None: """Update the entity.""" - self._attr_latest_version = ( - await self.coordinator.client.get_latest_firmware_version( - self.coordinator.serial_number + try: + self._attr_latest_version = ( + await self.coordinator.client.get_latest_firmware_version( + self.coordinator.serial_number + ) ) - ) + except AirGradientConnectionError: + self._attr_latest_version = None + self._attr_available = False + if not self._server_unreachable_logged: + _LOGGER.error( + "Unable to connect to AirGradient server to check for updates" + ) + self._server_unreachable_logged = True + else: + self._server_unreachable_logged = False + self._attr_available = True diff --git a/tests/components/airgradient/test_update.py b/tests/components/airgradient/test_update.py index 65614312b4646..1ef2122f94840 100644 --- a/tests/components/airgradient/test_update.py +++ b/tests/components/airgradient/test_update.py @@ -3,10 +3,12 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch +from airgradient import AirGradientConnectionError from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -67,3 +69,64 @@ async def test_update_mechanism( assert state.state == STATE_ON assert state.attributes["installed_version"] == "3.1.4" assert state.attributes["latest_version"] == "3.1.5" + + +async def test_update_errors( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update entity errors.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("update.airgradient_firmware") + assert state.state == STATE_ON + mock_airgradient_client.get_latest_firmware_version.side_effect = ( + AirGradientConnectionError("Boom") + ) + + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("update.airgradient_firmware") + assert state.state == STATE_UNAVAILABLE + + assert "Unable to connect to AirGradient server to check for updates" in caplog.text + + caplog.clear() + + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("update.airgradient_firmware") + assert state.state == STATE_UNAVAILABLE + + assert ( + "Unable to connect to AirGradient server to check for updates" + not in caplog.text + ) + + mock_airgradient_client.get_latest_firmware_version.side_effect = None + + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("update.airgradient_firmware") + assert state.state == STATE_ON + mock_airgradient_client.get_latest_firmware_version.side_effect = ( + AirGradientConnectionError("Boom") + ) + + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("update.airgradient_firmware") + assert state.state == STATE_UNAVAILABLE + + assert "Unable to connect to AirGradient server to check for updates" in caplog.text From 681211b1a5facef948edb3bc0199688a44d750c1 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 6 Oct 2025 12:32:42 -0700 Subject: [PATCH 12/14] Add Model Context Protocol support for OAuth scopes (#153150) --- homeassistant/components/mcp/config_flow.py | 59 ++++++++++++++++----- homeassistant/components/mcp/const.py | 1 + tests/components/mcp/conftest.py | 2 + tests/components/mcp/test_config_flow.py | 31 +++++++++-- 4 files changed, 77 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/mcp/config_flow.py b/homeassistant/components/mcp/config_flow.py index 0f34962f7ee5a..064fff32b24d1 100644 --- a/homeassistant/components/mcp/config_flow.py +++ b/homeassistant/components/mcp/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from dataclasses import dataclass import logging from typing import Any, cast @@ -23,7 +24,13 @@ from . import async_get_config_entry_implementation from .application_credentials import authorization_server_context -from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN +from .const import ( + CONF_ACCESS_TOKEN, + CONF_AUTHORIZATION_URL, + CONF_SCOPE, + CONF_TOKEN_URL, + DOMAIN, +) from .coordinator import TokenManager, mcp_client _LOGGER = logging.getLogger(__name__) @@ -41,9 +48,17 @@ } +@dataclass +class OAuthConfig: + """Class to hold OAuth configuration.""" + + authorization_server: AuthorizationServer + scopes: list[str] | None = None + + async def async_discover_oauth_config( hass: HomeAssistant, mcp_server_url: str -) -> AuthorizationServer: +) -> OAuthConfig: """Discover the OAuth configuration for the MCP server. This implements the functionality in the MCP spec for discovery. If the MCP server URL @@ -65,9 +80,11 @@ async def async_discover_oauth_config( except httpx.HTTPStatusError as error: if error.response.status_code == 404: _LOGGER.info("Authorization Server Metadata not found, using default paths") - return AuthorizationServer( - authorize_url=str(parsed_url.with_path("/authorize")), - token_url=str(parsed_url.with_path("/token")), + return OAuthConfig( + authorization_server=AuthorizationServer( + authorize_url=str(parsed_url.with_path("/authorize")), + token_url=str(parsed_url.with_path("/token")), + ) ) raise CannotConnect from error except httpx.HTTPError as error: @@ -81,9 +98,15 @@ async def async_discover_oauth_config( authorize_url = str(parsed_url.with_path(authorize_url)) if token_url.startswith("/"): token_url = str(parsed_url.with_path(token_url)) - return AuthorizationServer( - authorize_url=authorize_url, - token_url=token_url, + # We have no way to know the minimum set of scopes needed, so request + # all of them and let the user limit during the authorization step. + scopes = data.get("scopes_supported") + return OAuthConfig( + authorization_server=AuthorizationServer( + authorize_url=authorize_url, + token_url=token_url, + ), + scopes=scopes, ) @@ -130,6 +153,7 @@ def __init__(self) -> None: """Initialize the config flow.""" super().__init__() self.data: dict[str, Any] = {} + self.oauth_config: OAuthConfig | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -170,7 +194,7 @@ async def async_step_auth_discovery( to find the OAuth medata then run the OAuth authentication flow. """ try: - authorization_server = await async_discover_oauth_config( + oauth_config = await async_discover_oauth_config( self.hass, self.data[CONF_URL] ) except TimeoutConnectError: @@ -181,11 +205,13 @@ async def async_step_auth_discovery( _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") else: - _LOGGER.info("OAuth configuration: %s", authorization_server) + _LOGGER.info("OAuth configuration: %s", oauth_config) + self.oauth_config = oauth_config self.data.update( { - CONF_AUTHORIZATION_URL: authorization_server.authorize_url, - CONF_TOKEN_URL: authorization_server.token_url, + CONF_AUTHORIZATION_URL: oauth_config.authorization_server.authorize_url, + CONF_TOKEN_URL: oauth_config.authorization_server.token_url, + CONF_SCOPE: oauth_config.scopes, } ) return await self.async_step_credentials_choice() @@ -197,6 +223,15 @@ def authorization_server(self) -> AuthorizationServer: self.data[CONF_TOKEN_URL], ) + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + data = {} + if self.data and (scopes := self.data[CONF_SCOPE]) is not None: + data[CONF_SCOPE] = " ".join(scopes) + data.update(super().extra_authorize_data) + return data + async def async_step_credentials_choice( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/mcp/const.py b/homeassistant/components/mcp/const.py index 13f63b02c7349..19fad8f47369d 100644 --- a/homeassistant/components/mcp/const.py +++ b/homeassistant/components/mcp/const.py @@ -5,3 +5,4 @@ CONF_ACCESS_TOKEN = "access_token" CONF_AUTHORIZATION_URL = "authorization_url" CONF_TOKEN_URL = "token_url" +CONF_SCOPE = "scope" diff --git a/tests/components/mcp/conftest.py b/tests/components/mcp/conftest.py index b6d6958d3d912..c179936f7d681 100644 --- a/tests/components/mcp/conftest.py +++ b/tests/components/mcp/conftest.py @@ -13,6 +13,7 @@ from homeassistant.components.mcp.const import ( CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, + CONF_SCOPE, CONF_TOKEN_URL, DOMAIN, ) @@ -100,6 +101,7 @@ def mock_config_entry_with_auth( "refresh_token": "test-refresh-token", "expires_at": config_entry_token_expiration.timestamp(), }, + CONF_SCOPE: ["read", "write"], }, title=TEST_API_NAME, ) diff --git a/tests/components/mcp/test_config_flow.py b/tests/components/mcp/test_config_flow.py index 426b3267195a8..678447a58efc5 100644 --- a/tests/components/mcp/test_config_flow.py +++ b/tests/components/mcp/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant import config_entries from homeassistant.components.mcp.const import ( CONF_AUTHORIZATION_URL, + CONF_SCOPE, CONF_TOKEN_URL, DOMAIN, ) @@ -42,9 +43,11 @@ { "authorization_endpoint": OAUTH_AUTHORIZE_URL, "token_endpoint": OAUTH_TOKEN_URL, + "scopes_supported": ["read", "write"], } ), ) +SCOPES = ["read", "write"] CALLBACK_PATH = "/auth/external/callback" OAUTH_CALLBACK_URL = f"https://example.com{CALLBACK_PATH}" OAUTH_CODE = "abcd" @@ -53,6 +56,7 @@ "access_token": "mock-access-token", "type": "Bearer", "expires_in": 60, + "scope": " ".join(SCOPES), } @@ -295,6 +299,7 @@ async def perform_oauth_flow( result: config_entries.ConfigFlowResult, authorize_url: str = OAUTH_AUTHORIZE_URL, token_url: str = OAUTH_TOKEN_URL, + scopes: list[str] | None = None, ) -> config_entries.ConfigFlowResult: """Perform the common steps of the OAuth flow. @@ -307,10 +312,13 @@ async def perform_oauth_flow( "redirect_uri": OAUTH_CALLBACK_URL, }, ) + scope_param = "" + if scopes: + scope_param = "&scope=" + "+".join(scopes) assert result["url"] == ( f"{authorize_url}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={OAUTH_CALLBACK_URL}" - f"&state={state}" + f"&state={state}{scope_param}" ) client = await hass_client_no_auth() @@ -327,9 +335,14 @@ async def perform_oauth_flow( @pytest.mark.parametrize( - ("oauth_server_metadata_response", "expected_authorize_url", "expected_token_url"), + ( + "oauth_server_metadata_response", + "expected_authorize_url", + "expected_token_url", + "scopes", + ), [ - (OAUTH_SERVER_METADATA_RESPONSE, OAUTH_AUTHORIZE_URL, OAUTH_TOKEN_URL), + (OAUTH_SERVER_METADATA_RESPONSE, OAUTH_AUTHORIZE_URL, OAUTH_TOKEN_URL, SCOPES), ( httpx.Response( status_code=200, @@ -342,11 +355,13 @@ async def perform_oauth_flow( ), f"{MCP_SERVER_BASE_URL}/authorize-path", f"{MCP_SERVER_BASE_URL}/token-path", + None, ), ( httpx.Response(status_code=404), f"{MCP_SERVER_BASE_URL}/authorize", f"{MCP_SERVER_BASE_URL}/token", + None, ), ], ids=( @@ -367,6 +382,7 @@ async def test_authentication_flow( oauth_server_metadata_response: httpx.Response, expected_authorize_url: str, expected_token_url: str, + scopes: list[str] | None, ) -> None: """Test for an OAuth authentication flow for an MCP server.""" @@ -405,6 +421,7 @@ async def test_authentication_flow( result, authorize_url=expected_authorize_url, token_url=expected_token_url, + scopes=scopes, ) # Client now accepts credentials @@ -423,6 +440,7 @@ async def test_authentication_flow( CONF_URL: MCP_SERVER_URL, CONF_AUTHORIZATION_URL: expected_authorize_url, CONF_TOKEN_URL: expected_token_url, + CONF_SCOPE: scopes, } assert token token.pop("expires_at") @@ -536,6 +554,7 @@ async def test_authentication_flow_server_failure_abort( aioclient_mock, hass_client_no_auth, result, + scopes=SCOPES, ) # Client fails with an error @@ -591,6 +610,7 @@ async def test_authentication_flow_server_missing_tool_capabilities( aioclient_mock, hass_client_no_auth, result, + scopes=SCOPES, ) # Client can now authenticate @@ -628,7 +648,9 @@ async def test_reauth_flow( result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - result = await perform_oauth_flow(hass, aioclient_mock, hass_client_no_auth, result) + result = await perform_oauth_flow( + hass, aioclient_mock, hass_client_no_auth, result, scopes=SCOPES + ) # Verify we can connect to the server response = Mock() @@ -648,6 +670,7 @@ async def test_reauth_flow( CONF_URL: MCP_SERVER_URL, CONF_AUTHORIZATION_URL: OAUTH_AUTHORIZE_URL, CONF_TOKEN_URL: OAUTH_TOKEN_URL, + CONF_SCOPE: ["read", "write"], } assert token token.pop("expires_at") From d140b82a70d36d471be829f804aab72190ec9f12 Mon Sep 17 00:00:00 2001 From: derytive <145337489+derytive@users.noreply.github.com> Date: Mon, 6 Oct 2025 21:53:09 +0200 Subject: [PATCH 13/14] Add plate_count for Miele KM7575 (#153868) --- homeassistant/components/miele/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index 60e7fba5969cb..d66e29d8f4637 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -54,6 +54,7 @@ DEFAULT_PLATE_COUNT = 4 PLATE_COUNT = { + "KM7575": 6, "KM7678": 6, "KM7697": 6, "KM7878": 6, From f63504af013cce0974e32b3444658c178e699fe9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 6 Oct 2025 22:47:33 +0200 Subject: [PATCH 14/14] Update aiohttp to 3.13.0 (#153875) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e613a62d85839..b879a9aac2048 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.5.0 aiohasupervisor==0.3.3 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.12.15 +aiohttp==3.13.0 aiohttp_cors==0.8.1 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 0afcac91d9d59..dc448a3254f0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.3", - "aiohttp==3.12.15", + "aiohttp==3.13.0", "aiohttp_cors==0.8.1", "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index f9b4d5a43d96e..7412151259b12 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.5.0 aiohasupervisor==0.3.3 -aiohttp==3.12.15 +aiohttp==3.13.0 aiohttp_cors==0.8.1 aiohttp-fast-zlib==0.3.0 aiohttp-asyncmdnsresolver==0.1.1