From 6a3bace8249853725f37ff62010e06bddaf6ba8e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:35:39 +0100 Subject: [PATCH 01/20] Use shorthand attributes in hp_ilo (#163282) --- homeassistant/components/hp_ilo/sensor.py | 32 +++-------------------- 1 file changed, 3 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/hp_ilo/sensor.py b/homeassistant/components/hp_ilo/sensor.py index b4263f53d24cd4..e812535c936fbb 100644 --- a/homeassistant/components/hp_ilo/sensor.py +++ b/homeassistant/components/hp_ilo/sensor.py @@ -101,7 +101,6 @@ def setup_platform( devices = [] for monitored_variable in monitored_variables: new_device = HpIloSensor( - hass=hass, hp_ilo_data=hp_ilo_data, sensor_name=f"{config[CONF_NAME]} {monitored_variable[CONF_NAME]}", sensor_type=monitored_variable[CONF_SENSOR_TYPE], @@ -118,7 +117,6 @@ class HpIloSensor(SensorEntity): def __init__( self, - hass, hp_ilo_data, sensor_type, sensor_name, @@ -126,38 +124,14 @@ def __init__( unit_of_measurement, ): """Initialize the HP iLO sensor.""" - self._hass = hass - self._name = sensor_name - self._unit_of_measurement = unit_of_measurement + self._attr_name = sensor_name + self._attr_native_unit_of_measurement = unit_of_measurement self._ilo_function = SENSOR_TYPES[sensor_type][1] self.hp_ilo_data = hp_ilo_data self._sensor_value_template = sensor_value_template - self._state = None - self._state_attributes = None - _LOGGER.debug("Created HP iLO sensor %r", self) - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of the sensor.""" - return self._unit_of_measurement - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - return self._state_attributes - def update(self) -> None: """Get the latest data from HP iLO and updates the states.""" # Call the API for new data. Each sensor will re-trigger this @@ -171,7 +145,7 @@ def update(self) -> None: ilo_data=ilo_data, parse_result=False ) - self._state = ilo_data + self._attr_native_value = ilo_data class HpIloData: From 889467e4c2b1bf921d6e1ff6eb60e0d97886a10b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:35:58 +0100 Subject: [PATCH 02/20] Use shorthand attributes in openhardwaremonitor (#163284) --- .../components/openhardwaremonitor/sensor.py | 36 ++++--------------- 1 file changed, 6 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/openhardwaremonitor/sensor.py b/homeassistant/components/openhardwaremonitor/sensor.py index 4aa334da3a7cb5..fe8511b4416ff8 100644 --- a/homeassistant/components/openhardwaremonitor/sensor.py +++ b/homeassistant/components/openhardwaremonitor/sensor.py @@ -65,38 +65,13 @@ class OpenHardwareMonitorDevice(SensorEntity): def __init__(self, data, name, path, unit_of_measurement): """Initialize an OpenHardwareMonitor sensor.""" - self._name = name + self._attr_name = name self._data = data self.path = path - self.attributes = {} - self._unit_of_measurement = unit_of_measurement - - self.value = None - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement - - @property - def native_value(self): - """Return the state of the device.""" - if self.value == "-": - return None - return self.value - - @property - def extra_state_attributes(self): - """Return the state attributes of the entity.""" - return self.attributes + self._attr_native_unit_of_measurement = unit_of_measurement @classmethod - def parse_number(cls, string): + def parse_number(cls, string: str) -> str: """In some locales a decimal numbers uses ',' instead of '.'.""" return string.replace(",", ".") @@ -111,7 +86,8 @@ def update(self) -> None: values = array[path_number] if path_index == len(self.path) - 1: - self.value = self.parse_number(values[OHM_VALUE].split(" ")[0]) + value = self.parse_number(values[OHM_VALUE].split(" ")[0]) + self._attr_native_value = None if value == "-" else value _attributes.update( { "name": values[OHM_NAME], @@ -124,7 +100,7 @@ def update(self) -> None: } ) - self.attributes = _attributes + self._attr_extra_state_attributes = _attributes return array = array[path_number][OHM_CHILDREN] _attributes.update({f"level_{path_index}": values[OHM_NAME]}) From 3b3c081703cba08ffd9bedb41f79971600eba5ed Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:41:02 +0100 Subject: [PATCH 03/20] Use shorthand attributes in sigfox (#163286) --- homeassistant/components/sigfox/sensor.py | 26 +++++------------------ 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/sigfox/sensor.py b/homeassistant/components/sigfox/sensor.py index aece5675cbca52..667d4a50602b76 100644 --- a/homeassistant/components/sigfox/sensor.py +++ b/homeassistant/components/sigfox/sensor.py @@ -6,6 +6,7 @@ from http import HTTPStatus import json import logging +from typing import Any from urllib.parse import urljoin import requests @@ -123,11 +124,9 @@ def __init__(self, device_id, auth, name): """Initialise the device object.""" self._device_id = device_id self._auth = auth - self._message_data = {} - self._name = f"{name}_{device_id}" - self._state = None + self._attr_name = f"{name}_{device_id}" - def get_last_message(self): + def get_last_message(self) -> dict[str, Any]: """Return the last message from a device.""" device_url = f"devices/{self._device_id}/messages?limit=1" url = urljoin(API_URL, device_url) @@ -148,20 +147,5 @@ def get_last_message(self): def update(self) -> None: """Fetch the latest device message.""" - self._message_data = self.get_last_message() - self._state = self._message_data["payload"] - - @property - def name(self): - """Return the HA name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the payload of the last message.""" - return self._state - - @property - def extra_state_attributes(self): - """Return other details about the last message.""" - return self._message_data + self._attr_extra_state_attributes = self.get_last_message() + self._attr_native_value = self._attr_extra_state_attributes["payload"] From 7168e2df5a8804274ff0992b359be26ac37a7bc9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:42:10 +0100 Subject: [PATCH 04/20] Use shorthand attributes in repetier (#163291) --- homeassistant/components/repetier/sensor.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/repetier/sensor.py b/homeassistant/components/repetier/sensor.py index 3903ab8adfb3c2..4cfa0799960815 100644 --- a/homeassistant/components/repetier/sensor.py +++ b/homeassistant/components/repetier/sensor.py @@ -75,18 +75,13 @@ def __init__( """Init new sensor.""" self.entity_description = description self._api = api - self._attributes: dict = {} + self._attr_extra_state_attributes = {} self._temp_id = temp_id self._printer_id = printer_id self._attr_name = name self._attr_available = False - @property - def extra_state_attributes(self): - """Return sensor attributes.""" - return self._attributes - @callback def update_callback(self): """Get new data and update state.""" @@ -115,7 +110,7 @@ def update(self) -> None: return state = data.pop("state") _LOGGER.debug("Printer %s State %s", self.name, state) - self._attributes.update(data) + self._attr_extra_state_attributes.update(data) self._attr_native_value = state @@ -136,7 +131,7 @@ def update(self) -> None: state = data.pop("state") temp_set = data["temp_set"] _LOGGER.debug("Printer %s Setpoint: %s, Temp: %s", self.name, temp_set, state) - self._attributes.update(data) + self._attr_extra_state_attributes.update(data) self._attr_native_value = state From 398a6222cd9ea5db5f3c76a15ab2b935a4233850 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:44:43 +0100 Subject: [PATCH 05/20] Remove deprecated starline state attribute (#163289) --- homeassistant/components/starline/switch.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index 79d4fa86ddfdb6..3a457c6ffdee45 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -71,15 +71,7 @@ def available(self) -> bool: return super().available and self._device.online @property - def extra_state_attributes(self): - """Return the state attributes of the switch.""" - if self._key == "ign": - # Deprecated and should be removed in 2025.8 - return self._account.engine_attrs(self._device) - return None - - @property - def is_on(self): + def is_on(self) -> bool | None: """Return True if entity is on.""" return self._device.car_state.get(self._key) From d298eb033a285414a5a65100c516d26f11c81720 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:58:36 +0100 Subject: [PATCH 06/20] Use shorthand attributes in vasttrafik (#163285) --- homeassistant/components/vasttrafik/sensor.py | 35 ++++++------------- 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index be2559d4863947..7059eb2f438a57 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -93,14 +93,12 @@ class VasttrafikDepartureSensor(SensorEntity): def __init__(self, planner, name, departure, heading, lines, delay): """Initialize the sensor.""" self._planner = planner - self._name = name or departure + self._attr_name = name or departure self._departure = self.get_station_id(departure) self._heading = self.get_station_id(heading) if heading else None self._lines = lines or None self._delay = timedelta(minutes=delay) self._departureboard = None - self._state = None - self._attributes = None def get_station_id(self, location): """Get the station ID.""" @@ -111,21 +109,6 @@ def get_station_id(self, location): station_info = {"station_name": location, "station_id": station_id} return station_info - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._attributes - - @property - def native_value(self): - """Return the next departure time.""" - return self._state - @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self) -> None: """Get the departure board.""" @@ -145,8 +128,8 @@ def update(self) -> None: self._departure["station_name"], self._heading["station_name"] if self._heading else "ANY", ) - self._state = None - self._attributes = {} + self._attr_native_value = None + self._attr_extra_state_attributes = {} else: for departure in self._departureboard: service_journey = departure.get("serviceJourney", {}) @@ -157,13 +140,15 @@ def update(self) -> None: if not self._lines or line.get("shortName") in self._lines: if "estimatedOtherwisePlannedTime" in departure: try: - self._state = datetime.fromisoformat( + self._attr_native_value = datetime.fromisoformat( departure["estimatedOtherwisePlannedTime"] ).strftime("%H:%M") except ValueError: - self._state = departure["estimatedOtherwisePlannedTime"] + self._attr_native_value = departure[ + "estimatedOtherwisePlannedTime" + ] else: - self._state = None + self._attr_native_value = None stop_point = departure.get("stopPoint", {}) @@ -181,5 +166,7 @@ def update(self) -> None: ATTR_DELAY: self._delay.seconds // 60 % 60, } - self._attributes = {k: v for k, v in params.items() if v} + self._attr_extra_state_attributes = { + k: v for k, v in params.items() if v + } break From 13139608934b65c98c59194748fad379890889cc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:59:53 +0100 Subject: [PATCH 07/20] Use shorthand attributes in skybeacon (#163295) --- homeassistant/components/skybeacon/sensor.py | 34 +++++--------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/skybeacon/sensor.py b/homeassistant/components/skybeacon/sensor.py index 650e62bc4a1b59..108539c1cef785 100644 --- a/homeassistant/components/skybeacon/sensor.py +++ b/homeassistant/components/skybeacon/sensor.py @@ -59,8 +59,8 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Skybeacon sensor.""" - name = config.get(CONF_NAME) - mac = config.get(CONF_MAC) + name: str = config[CONF_NAME] + mac: str = config[CONF_MAC] _LOGGER.debug("Setting up") mon = Monitor(hass, mac, name) @@ -79,55 +79,37 @@ def monitor_stop(_service_or_event): class SkybeaconHumid(SensorEntity): """Representation of a Skybeacon humidity sensor.""" + _attr_extra_state_attributes = {ATTR_DEVICE: "SKYBEACON", ATTR_MODEL: 1} _attr_native_unit_of_measurement = PERCENTAGE - def __init__(self, name, mon): + def __init__(self, name: str, mon: Monitor) -> None: """Initialize a sensor.""" self.mon = mon - self._name = name - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + self._attr_name = name @property def native_value(self): """Return the state of the device.""" return self.mon.data["humid"] - @property - def extra_state_attributes(self): - """Return the state attributes of the sensor.""" - return {ATTR_DEVICE: "SKYBEACON", ATTR_MODEL: 1} - class SkybeaconTemp(SensorEntity): """Representation of a Skybeacon temperature sensor.""" _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_extra_state_attributes = {ATTR_DEVICE: "SKYBEACON", ATTR_MODEL: 1} _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - def __init__(self, name, mon): + def __init__(self, name: str, mon: Monitor) -> None: """Initialize a sensor.""" self.mon = mon - self._name = name - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + self._attr_name = name @property def native_value(self): """Return the state of the device.""" return self.mon.data["temp"] - @property - def extra_state_attributes(self): - """Return the state attributes of the sensor.""" - return {ATTR_DEVICE: "SKYBEACON", ATTR_MODEL: 1} - class Monitor(threading.Thread, SensorEntity): """Connection handling.""" From f7752686df62624b25653d643964c132e99ba8a4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:01:42 +0100 Subject: [PATCH 08/20] Use shorthand attributes in sony_projector (#163293) --- .../components/sony_projector/switch.py | 38 ++++--------------- 1 file changed, 8 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/sony_projector/switch.py b/homeassistant/components/sony_projector/switch.py index c4d993cc22aa69..7aa76245aec94c 100644 --- a/homeassistant/components/sony_projector/switch.py +++ b/homeassistant/components/sony_projector/switch.py @@ -12,7 +12,7 @@ PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA, SwitchEntity, ) -from homeassistant.const import CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -58,46 +58,24 @@ class SonyProjector(SwitchEntity): def __init__(self, sdcp_connection, name): """Init of the Sony projector.""" self._sdcp = sdcp_connection - self._name = name - self._state = None - self._available = False - self._attributes = {} - - @property - def available(self) -> bool: - """Return if projector is available.""" - return self._available - - @property - def name(self): - """Return name of the projector.""" - return self._name - - @property - def is_on(self): - """Return if the projector is turned on.""" - return self._state - - @property - def extra_state_attributes(self): - """Return state attributes.""" - return self._attributes + self._attr_available = False + self._attr_name = name def update(self) -> None: """Get the latest state from the projector.""" try: - self._state = self._sdcp.get_power() - self._available = True + self._attr_is_on = self._sdcp.get_power() + self._attr_available = True except ConnectionRefusedError: _LOGGER.error("Projector connection refused") - self._available = False + self._attr_available = False def turn_on(self, **kwargs: Any) -> None: """Turn the projector on.""" _LOGGER.debug("Powering on projector '%s'", self.name) if self._sdcp.set_power(True): _LOGGER.debug("Powered on successfully") - self._state = STATE_ON + self._attr_is_on = True else: _LOGGER.error("Power on command was not successful") @@ -106,6 +84,6 @@ def turn_off(self, **kwargs: Any) -> None: _LOGGER.debug("Powering off projector '%s'", self.name) if self._sdcp.set_power(False): _LOGGER.debug("Powered off successfully") - self._state = STATE_OFF + self._attr_is_on = False else: _LOGGER.error("Power off command was not successful") From 413e2970228b112cb495083aac1238701884e834 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:05:04 +0100 Subject: [PATCH 09/20] Use shorthand attributes in tank_utility (#163288) Co-authored-by: Josef Zweck --- .../components/tank_utility/sensor.py | 42 ++++--------------- 1 file changed, 9 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/tank_utility/sensor.py b/homeassistant/components/tank_utility/sensor.py index e9377e346d4f60..2ccfb48b32d185 100644 --- a/homeassistant/components/tank_utility/sensor.py +++ b/homeassistant/components/tank_utility/sensor.py @@ -74,41 +74,15 @@ def setup_platform( class TankUtilitySensor(SensorEntity): """Representation of a Tank Utility sensor.""" + _attr_native_unit_of_measurement = PERCENTAGE + def __init__(self, email, password, token, device): """Initialize the sensor.""" self._email = email self._password = password self._token = token self._device = device - self._state = None - self._name = f"Tank Utility {self.device}" - self._unit_of_measurement = PERCENTAGE - self._attributes = {} - - @property - def device(self): - """Return the device identifier.""" - return self._device - - @property - def native_value(self): - """Return the state of the device.""" - return self._state - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of the device.""" - return self._unit_of_measurement - - @property - def extra_state_attributes(self): - """Return the attributes of the device.""" - return self._attributes + self._attr_name = f"Tank Utility {device}" def get_data(self): """Get data from the device. @@ -119,7 +93,7 @@ def get_data(self): data = {} try: - data = tank_monitor.get_device_data(self._token, self.device) + data = tank_monitor.get_device_data(self._token, self._device) except requests.exceptions.HTTPError as http_error: if http_error.response.status_code in ( requests.codes.unauthorized, @@ -127,7 +101,7 @@ def get_data(self): ): _LOGGER.debug("Getting new token") self._token = auth.get_token(self._email, self._password, force=True) - data = tank_monitor.get_device_data(self._token, self.device) + data = tank_monitor.get_device_data(self._token, self._device) else: raise data.update(data.pop("device", {})) @@ -137,5 +111,7 @@ def get_data(self): def update(self) -> None: """Set the device state and attributes.""" data = self.get_data() - self._state = round(data[SENSOR_TYPE], SENSOR_ROUNDING_PRECISION) - self._attributes = {k: v for k, v in data.items() if k in SENSOR_ATTRS} + self._attr_native_value = round(data[SENSOR_TYPE], SENSOR_ROUNDING_PRECISION) + self._attr_extra_state_attributes = { + k: v for k, v in data.items() if k in SENSOR_ATTRS + } From 0b8312d942b94b0830dcd923797e7b0a77955eb9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:05:48 +0100 Subject: [PATCH 10/20] Use shorthand attributes in serial (#163287) --- homeassistant/components/serial/sensor.py | 27 +++++------------------ 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/serial/sensor.py b/homeassistant/components/serial/sensor.py index 4d43408397f272..f4bfea72cb8061 100644 --- a/homeassistant/components/serial/sensor.py +++ b/homeassistant/components/serial/sensor.py @@ -131,8 +131,7 @@ def __init__( value_template, ): """Initialize the Serial sensor.""" - self._name = name - self._state = None + self._attr_name = name self._port = port self._baudrate = baudrate self._bytesize = bytesize @@ -143,7 +142,6 @@ def __init__( self._dsrdtr = dsrdtr self._serial_loop_task = None self._template = value_template - self._attributes = None async def async_added_to_hass(self) -> None: """Handle when an entity is about to be added to Home Assistant.""" @@ -215,7 +213,7 @@ async def serial_read( pass else: if isinstance(data, dict): - self._attributes = data + self._attr_extra_state_attributes = data if self._template is not None: line = self._template.async_render_with_possible_json_value( @@ -223,13 +221,13 @@ async def serial_read( ) _LOGGER.debug("Received: %s", line) - self._state = line + self._attr_native_value = line self.async_write_ha_state() async def _handle_error(self): """Handle error for serial connection.""" - self._state = None - self._attributes = None + self._attr_native_value = None + self._attr_extra_state_attributes = None self.async_write_ha_state() await asyncio.sleep(5) @@ -238,18 +236,3 @@ def stop_serial_read(self, event): """Close resources.""" if self._serial_loop_task: self._serial_loop_task.cancel() - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def extra_state_attributes(self): - """Return the attributes of the entity (if any JSON present).""" - return self._attributes - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state From 9f551f3d5b13ab4413c335fc08d1dfbf7891f2c9 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Tue, 17 Feb 2026 08:08:59 -0800 Subject: [PATCH 11/20] Improve derivative units and auto-device_class (#157369) --- homeassistant/components/derivative/sensor.py | 71 +++++++- tests/components/derivative/test_sensor.py | 171 ++++++++++++++++-- 2 files changed, 223 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index e0b4a19de647b2..8515b54295a1dc 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -10,13 +10,16 @@ from homeassistant.components.sensor import ( ATTR_STATE_CLASS, + DEVICE_CLASS_UNITS, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, RestoreSensor, + SensorDeviceClass, SensorEntity, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_SOURCE, @@ -83,6 +86,17 @@ UnitOfTime.DAYS: 24 * 60 * 60, } +DERIVED_CLASS = { + SensorDeviceClass.ENERGY: SensorDeviceClass.POWER, + SensorDeviceClass.ENERGY_STORAGE: SensorDeviceClass.POWER, + SensorDeviceClass.DATA_SIZE: SensorDeviceClass.DATA_RATE, + SensorDeviceClass.DISTANCE: SensorDeviceClass.SPEED, + SensorDeviceClass.WATER: SensorDeviceClass.VOLUME_FLOW_RATE, + SensorDeviceClass.GAS: SensorDeviceClass.VOLUME_FLOW_RATE, + SensorDeviceClass.VOLUME: SensorDeviceClass.VOLUME_FLOW_RATE, + SensorDeviceClass.VOLUME_STORAGE: SensorDeviceClass.VOLUME_FLOW_RATE, +} + DEFAULT_ROUND = 3 DEFAULT_TIME_WINDOW = 0 @@ -203,10 +217,11 @@ def __init__( self._attr_name = name if name is not None else f"{source_entity} derivative" self._attr_extra_state_attributes = {ATTR_SOURCE_ID: source_entity} - self._unit_template: str | None = None + self._string_unit_prefix: str | None = None + self._string_unit_time: str | None = None if unit_of_measurement is None: - final_unit_prefix = "" if unit_prefix is None else unit_prefix - self._unit_template = f"{final_unit_prefix}{{}}/{unit_time}" + self._string_unit_prefix = "" if unit_prefix is None else unit_prefix + self._string_unit_time = unit_time # we postpone the definition of unit_of_measurement to later self._attr_native_unit_of_measurement = None else: @@ -225,12 +240,40 @@ def __init__( ) def _derive_and_set_attributes_from_state(self, source_state: State | None) -> None: - if self._unit_template and source_state: + if not source_state: + return + + source_class_raw = source_state.attributes.get(ATTR_DEVICE_CLASS) + source_class: SensorDeviceClass | None = None + if isinstance(source_class_raw, str): + try: + source_class = SensorDeviceClass(source_class_raw) + except ValueError: + source_class = None + if self._string_unit_prefix is not None and self._string_unit_time is not None: original_unit = self._attr_native_unit_of_measurement source_unit = source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - self._attr_native_unit_of_measurement = self._unit_template.format( - "" if source_unit is None else source_unit - ) + if ( + ( + source_class + in (SensorDeviceClass.ENERGY, SensorDeviceClass.ENERGY_STORAGE) + ) + and self._string_unit_time == UnitOfTime.HOURS + and source_unit + and source_unit.endswith("Wh") + ): + self._attr_native_unit_of_measurement = ( + f"{self._string_unit_prefix}{source_unit[:-1]}" + ) + + else: + unit_template = ( + f"{self._string_unit_prefix}{{}}/{self._string_unit_time}" + ) + self._attr_native_unit_of_measurement = unit_template.format( + "" if source_unit is None else source_unit + ) + if original_unit != self._attr_native_unit_of_measurement: _LOGGER.debug( "%s: Derivative sensor switched UoM from %s to %s, resetting state to 0", @@ -241,6 +284,16 @@ def _derive_and_set_attributes_from_state(self, source_state: State | None) -> N self._state_list = [] self._attr_native_value = round(Decimal(0), self._round_digits) + self._attr_device_class = None + if source_class: + derived_class = DERIVED_CLASS.get(source_class) + if ( + derived_class + and self._attr_native_unit_of_measurement + in DEVICE_CLASS_UNITS[derived_class] + ): + self._attr_device_class = derived_class + def _calc_derivative_from_state_list(self, current_time: datetime) -> Decimal: def calculate_weight(start: datetime, end: datetime, now: datetime) -> float: window_start = now - timedelta(seconds=self._time_window) @@ -309,6 +362,10 @@ async def _handle_restore(self) -> None: except InvalidOperation, TypeError: self._attr_native_value = None + last_state = await self.async_get_last_state() + if last_state: + self._attr_device_class = last_state.attributes.get(ATTR_DEVICE_CLASS) + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index 4b282582789baf..29337d5d369870 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -11,13 +11,22 @@ from homeassistant import config as hass_config, core as ha from homeassistant.components.derivative.const import DOMAIN -from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.const import ( + ATTR_DEVICE_CLASS, SERVICE_RELOAD, STATE_UNAVAILABLE, STATE_UNKNOWN, + UnitOfDataRate, + UnitOfEnergy, UnitOfPower, + UnitOfSpeed, UnitOfTime, + UnitOfVolumeFlowRate, ) from homeassistant.core import ( Event, @@ -642,6 +651,137 @@ def calc_expected(elapsed_seconds: int, calculation_delay: int = 0): assert expect_min <= derivative <= expect_max, f"Failed at time {time}" +@pytest.mark.parametrize( + ("extra_config", "source_unit", "source_class", "derived_unit", "derived_class"), + [ + ( + {}, + UnitOfEnergy.KILO_WATT_HOUR, + SensorDeviceClass.ENERGY, + UnitOfPower.KILO_WATT, + SensorDeviceClass.POWER, + ), + ( + {}, + UnitOfEnergy.TERA_WATT_HOUR, + SensorDeviceClass.ENERGY, + UnitOfPower.TERA_WATT, + SensorDeviceClass.POWER, + ), + ( + {"unit_prefix": "m"}, + UnitOfEnergy.WATT_HOUR, + SensorDeviceClass.ENERGY_STORAGE, + UnitOfPower.MILLIWATT, + SensorDeviceClass.POWER, + ), + ( + {"unit_prefix": "k"}, + UnitOfEnergy.WATT_HOUR, + SensorDeviceClass.ENERGY, + UnitOfPower.KILO_WATT, + SensorDeviceClass.POWER, + ), + ( + {"unit_prefix": "n"}, + UnitOfEnergy.WATT_HOUR, + SensorDeviceClass.ENERGY, + "nW", + None, + ), + ( + {}, + "GB", + SensorDeviceClass.DATA_SIZE, + "GB/h", + None, + ), + ( + {"unit_time": "s"}, + "GB", + SensorDeviceClass.DATA_SIZE, + UnitOfDataRate.GIGABYTES_PER_SECOND, + SensorDeviceClass.DATA_RATE, + ), + ( + {}, + "km", + SensorDeviceClass.DISTANCE, + UnitOfSpeed.KILOMETERS_PER_HOUR, + SensorDeviceClass.SPEED, + ), + ( + {}, + "m³", + SensorDeviceClass.GAS, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + SensorDeviceClass.VOLUME_FLOW_RATE, + ), + ( + {"unit_time": "min"}, + "gal", + SensorDeviceClass.WATER, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + SensorDeviceClass.VOLUME_FLOW_RATE, + ), + ( + {}, + UnitOfEnergy.KILO_WATT_HOUR, + "not_a_real_device_class", + "kWh/h", + None, + ), + ], +) +async def test_device_classes( + extra_config: dict[str, Any], + source_unit: str, + source_class: str, + derived_unit: str, + derived_class: str, + hass: HomeAssistant, +) -> None: + """Test derivative sensor handles unit conversions and device classes.""" + config = { + "sensor": { + "platform": "derivative", + "name": "derivative", + "source": "sensor.source", + "round": 2, + "unit_time": "h", + **extra_config, + } + } + + assert await async_setup_component(hass, "sensor", config) + entity_id = config["sensor"]["source"] + base = dt_util.utcnow() + with freeze_time(base) as freezer: + hass.states.async_set( + entity_id, + 1000, + { + "unit_of_measurement": source_unit, + "device_class": source_class, + }, + ) + await hass.async_block_till_done() + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) + hass.states.async_set( + entity_id, + 2000, + { + "unit_of_measurement": source_unit, + "device_class": source_class, + }, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.derivative") + assert state is not None + assert state.attributes.get("unit_of_measurement") == derived_unit + assert state.attributes.get("device_class") == derived_class + + async def test_prefix(hass: HomeAssistant) -> None: """Test derivative sensor state using a power source.""" config = { @@ -885,13 +1025,11 @@ async def test_unavailable_boot( State( "sensor.power", restore_state, - { - "unit_of_measurement": "kWh/s", - }, + {"unit_of_measurement": "kW", "device_class": "power"}, ), { "native_value": restore_state, - "native_unit_of_measurement": "kWh/s", + "native_unit_of_measurement": "kW", }, ), ], @@ -902,12 +1040,16 @@ async def test_unavailable_boot( "name": "power", "source": "sensor.energy", "round": 2, - "unit_time": "s", + "unit_time": "h", } config = {"sensor": config} entity_id = config["sensor"]["source"] - hass.states.async_set(entity_id, STATE_UNAVAILABLE, {"unit_of_measurement": "kWh"}) + hass.states.async_set( + entity_id, + STATE_UNAVAILABLE, + {"unit_of_measurement": "kWh", "device_class": "energy"}, + ) await hass.async_block_till_done() assert await async_setup_component(hass, "sensor", config) @@ -917,11 +1059,14 @@ async def test_unavailable_boot( assert state is not None # Sensor is unavailable as source is unavailable assert state.state == STATE_UNAVAILABLE + assert state.attributes.get(ATTR_DEVICE_CLASS) == "power" base = dt_util.utcnow() with freeze_time(base) as freezer: - freezer.move_to(base + timedelta(seconds=1)) - hass.states.async_set(entity_id, 10, {"unit_of_measurement": "kWh"}) + freezer.move_to(base + timedelta(hours=1)) + hass.states.async_set( + entity_id, 10, {"unit_of_measurement": "kWh", "device_class": "energy"} + ) await hass.async_block_till_done() state = hass.states.get("sensor.power") @@ -930,15 +1075,17 @@ async def test_unavailable_boot( # so just hold until the next tick assert state.state == restore_state - freezer.move_to(base + timedelta(seconds=2)) - hass.states.async_set(entity_id, 15, {"unit_of_measurement": "kWh"}) + freezer.move_to(base + timedelta(hours=2)) + hass.states.async_set( + entity_id, 15, {"unit_of_measurement": "kWh", "device_class": "energy"} + ) await hass.async_block_till_done() state = hass.states.get("sensor.power") assert state is not None # Now that the source sensor has two valid datapoints, we can calculate derivative assert state.state == "5.00" - assert state.attributes.get("unit_of_measurement") == "kWh/s" + assert state.attributes.get("unit_of_measurement") == "kW" async def test_source_unit_change( From 2fc9ded6b7f53c5133954d9849d6ab3273a3e5b2 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 17 Feb 2026 17:15:49 +0100 Subject: [PATCH 12/20] Add sensors to onedrive_for_business (#163135) --- .../onedrive_for_business/__init__.py | 32 +-- .../onedrive_for_business/coordinator.py | 102 ++++++++ .../onedrive_for_business/icons.json | 24 ++ .../onedrive_for_business/quality_scale.yaml | 65 +---- .../onedrive_for_business/sensor.py | 123 +++++++++ .../onedrive_for_business/strings.json | 35 +++ .../snapshots/test_sensor.ambr | 235 ++++++++++++++++++ .../onedrive_for_business/test_sensor.py | 64 +++++ 8 files changed, 612 insertions(+), 68 deletions(-) create mode 100644 homeassistant/components/onedrive_for_business/coordinator.py create mode 100644 homeassistant/components/onedrive_for_business/icons.json create mode 100644 homeassistant/components/onedrive_for_business/sensor.py create mode 100644 tests/components/onedrive_for_business/snapshots/test_sensor.ambr create mode 100644 tests/components/onedrive_for_business/test_sensor.py diff --git a/homeassistant/components/onedrive_for_business/__init__.py b/homeassistant/components/onedrive_for_business/__init__.py index c9aea1b60ca7f9..32210622d35bf4 100644 --- a/homeassistant/components/onedrive_for_business/__init__.py +++ b/homeassistant/components/onedrive_for_business/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Awaitable, Callable -from dataclasses import dataclass import logging from typing import cast @@ -14,8 +13,7 @@ OneDriveException, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -32,19 +30,15 @@ DATA_BACKUP_AGENT_LISTENERS, DOMAIN, ) +from .coordinator import ( + OneDriveConfigEntry, + OneDriveForBusinessUpdateCoordinator, + OneDriveRuntimeData, +) -_LOGGER = logging.getLogger(__name__) - - -@dataclass -class OneDriveRuntimeData: - """Runtime data for the OneDrive integration.""" - - client: OneDriveClient - token_function: Callable[[], Awaitable[str]] - +PLATFORMS = [Platform.SENSOR] -type OneDriveConfigEntry = ConfigEntry[OneDriveRuntimeData] +_LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: @@ -68,11 +62,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> entry, data={**entry.data, CONF_FOLDER_ID: backup_folder.id} ) + coordinator = OneDriveForBusinessUpdateCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = OneDriveRuntimeData( client=client, token_function=get_access_token, + coordinator=coordinator, ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + def async_notify_backup_listeners() -> None: for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): listener() @@ -82,9 +82,9 @@ def async_notify_backup_listeners() -> None: return True -async def async_unload_entry(hass: HomeAssistant, _: OneDriveConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: """Unload a OneDrive config entry.""" - return True + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def _get_onedrive_client( diff --git a/homeassistant/components/onedrive_for_business/coordinator.py b/homeassistant/components/onedrive_for_business/coordinator.py new file mode 100644 index 00000000000000..ee5abb965282d0 --- /dev/null +++ b/homeassistant/components/onedrive_for_business/coordinator.py @@ -0,0 +1,102 @@ +"""Coordinator for OneDrive for Business.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import timedelta +import logging +from time import time + +from onedrive_personal_sdk import OneDriveClient +from onedrive_personal_sdk.const import DriveState +from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException +from onedrive_personal_sdk.models.items import Drive + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +SCAN_INTERVAL = timedelta(minutes=5) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class OneDriveRuntimeData: + """Runtime data for the OneDrive integration.""" + + client: OneDriveClient + token_function: Callable[[], Awaitable[str]] + coordinator: OneDriveForBusinessUpdateCoordinator + + +type OneDriveConfigEntry = ConfigEntry[OneDriveRuntimeData] + + +class OneDriveForBusinessUpdateCoordinator(DataUpdateCoordinator[Drive]): + """Class to handle fetching data from the Graph API centrally.""" + + config_entry: OneDriveConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: OneDriveConfigEntry, client: OneDriveClient + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self._client = client + + async def _async_update_data(self) -> Drive: + """Fetch data from API endpoint.""" + expires_at = self.config_entry.data["token"]["expires_at"] + _LOGGER.debug( + "Token expiry: %s (in %s seconds)", + expires_at, + expires_at - time(), + ) + + try: + drive = await self._client.get_drive() + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + except OneDriveException as err: + _LOGGER.debug("Failed to fetch drive data: %s", err, exc_info=True) + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="update_failed" + ) from err + + # create an issue if the drive is almost full + if drive.quota and (state := drive.quota.state) in ( + DriveState.CRITICAL, + DriveState.EXCEEDED, + ): + key = "drive_full" if state is DriveState.EXCEEDED else "drive_almost_full" + ir.async_create_issue( + self.hass, + DOMAIN, + key, + is_fixable=False, + severity=( + ir.IssueSeverity.ERROR + if state is DriveState.EXCEEDED + else ir.IssueSeverity.WARNING + ), + translation_key=key, + translation_placeholders={ + "total": f"{drive.quota.total / (1024**3):.2f}", + "used": f"{drive.quota.used / (1024**3):.2f}", + }, + ) + return drive diff --git a/homeassistant/components/onedrive_for_business/icons.json b/homeassistant/components/onedrive_for_business/icons.json new file mode 100644 index 00000000000000..62396439fd12af --- /dev/null +++ b/homeassistant/components/onedrive_for_business/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "drive_state": { + "default": "mdi:harddisk", + "state": { + "critical": "mdi:alert", + "exceeded": "mdi:alert-octagon", + "nearing": "mdi:alert-circle-outline", + "normal": "mdi:harddisk" + } + }, + "remaining_size": { + "default": "mdi:database" + }, + "total_size": { + "default": "mdi:database" + }, + "used_size": { + "default": "mdi:database" + } + } + } +} diff --git a/homeassistant/components/onedrive_for_business/quality_scale.yaml b/homeassistant/components/onedrive_for_business/quality_scale.yaml index 91917eb4af0009..05e6ffcc17a490 100644 --- a/homeassistant/components/onedrive_for_business/quality_scale.yaml +++ b/homeassistant/components/onedrive_for_business/quality_scale.yaml @@ -21,14 +21,8 @@ rules: status: exempt comment: | Entities of this integration does not explicitly subscribe to events. - entity-unique-id: - status: exempt - comment: | - This integration does not create entities. - has-entity-name: - status: exempt - comment: | - This integration does not create entities. + entity-unique-id: done + has-entity-name: done runtime-data: done test-before-configure: done test-before-setup: done @@ -42,27 +36,15 @@ rules: comment: | This integration does not have configuration parameters. docs-installation-parameters: done - entity-unavailable: - status: exempt - comment: | - This integration does not create entities. + entity-unavailable: done integration-owner: done - log-when-unavailable: - status: exempt - comment: | - This integration does not create entities. - parallel-updates: - status: exempt - comment: | - This integration does not create entities. + log-when-unavailable: done + parallel-updates: done reauthentication-flow: done test-coverage: done # Gold - devices: - status: exempt - comment: | - This integration does not create entities. + devices: done diagnostics: todo discovery-update-info: status: exempt @@ -72,10 +54,7 @@ rules: status: exempt comment: | This integration is a cloud service and does not support discovery. - docs-data-update: - status: exempt - comment: | - This integration does not create entities. + docs-data-update: done docs-examples: status: exempt comment: | @@ -95,32 +74,14 @@ rules: status: exempt comment: | This integration connects to a single service. - entity-category: - status: exempt - comment: | - This integration does not create entities. - entity-device-class: - status: exempt - comment: | - This integration does not create entities. - entity-disabled-by-default: - status: exempt - comment: | - This integration does not create entities. - entity-translations: - status: exempt - comment: | - This integration does not create entities. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done exception-translations: done - icon-translations: - status: exempt - comment: | - This integration does not create entities. + icon-translations: done reconfiguration-flow: done - repair-issues: - status: exempt - comment: | - No repairs yet. + repair-issues: done stale-devices: status: exempt comment: | diff --git a/homeassistant/components/onedrive_for_business/sensor.py b/homeassistant/components/onedrive_for_business/sensor.py new file mode 100644 index 00000000000000..a717cd490c0adc --- /dev/null +++ b/homeassistant/components/onedrive_for_business/sensor.py @@ -0,0 +1,123 @@ +"""Sensors for OneDrive for Business.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from onedrive_personal_sdk.const import DriveState +from onedrive_personal_sdk.models.items import DriveQuota + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import EntityCategory, UnitOfInformation +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OneDriveConfigEntry, OneDriveForBusinessUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class OneDriveForBusinessSensorEntityDescription(SensorEntityDescription): + """Describes OneDrive sensor entity.""" + + value_fn: Callable[[DriveQuota], StateType] + + +DRIVE_STATE_ENTITIES: tuple[OneDriveForBusinessSensorEntityDescription, ...] = ( + OneDriveForBusinessSensorEntityDescription( + key="total_size", + value_fn=lambda quota: quota.total, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=0, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + OneDriveForBusinessSensorEntityDescription( + key="used_size", + value_fn=lambda quota: quota.used, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=2, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + OneDriveForBusinessSensorEntityDescription( + key="remaining_size", + value_fn=lambda quota: quota.remaining, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=2, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + OneDriveForBusinessSensorEntityDescription( + key="drive_state", + value_fn=lambda quota: quota.state.value, + options=[state.value for state in DriveState], + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OneDriveConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up OneDrive for Business sensors based on a config entry.""" + coordinator = entry.runtime_data.coordinator + async_add_entities( + OneDriveDriveStateSensor(coordinator, description) + for description in DRIVE_STATE_ENTITIES + ) + + +class OneDriveDriveStateSensor( + CoordinatorEntity[OneDriveForBusinessUpdateCoordinator], SensorEntity +): + """Define a OneDrive for Business sensor.""" + + entity_description: OneDriveForBusinessSensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: OneDriveForBusinessUpdateCoordinator, + description: OneDriveForBusinessSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_translation_key = description.key + self._attr_unique_id = f"{coordinator.data.id}_{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + name=coordinator.data.name or coordinator.config_entry.title, + identifiers={(DOMAIN, coordinator.data.id)}, + manufacturer="Microsoft", + model=f"OneDrive {coordinator.data.drive_type.value.capitalize()}", + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + if TYPE_CHECKING: + assert self.coordinator.data.quota + return self.entity_description.value_fn(self.coordinator.data.quota) + + @property + def available(self) -> bool: + """Availability of the sensor.""" + return super().available and self.coordinator.data.quota is not None diff --git a/homeassistant/components/onedrive_for_business/strings.json b/homeassistant/components/onedrive_for_business/strings.json index fa151ea1b85c18..a453ca9643db28 100644 --- a/homeassistant/components/onedrive_for_business/strings.json +++ b/homeassistant/components/onedrive_for_business/strings.json @@ -69,12 +69,47 @@ } } }, + "entity": { + "sensor": { + "drive_state": { + "name": "[%key:component::onedrive::entity::sensor::drive_state::name%]", + "state": { + "critical": "[%key:component::onedrive::entity::sensor::drive_state::state::critical%]", + "exceeded": "[%key:component::onedrive::entity::sensor::drive_state::state::exceeded%]", + "nearing": "[%key:component::onedrive::entity::sensor::drive_state::state::nearing%]", + "normal": "[%key:common::state::normal%]" + } + }, + "remaining_size": { + "name": "[%key:component::onedrive::entity::sensor::remaining_size::name%]" + }, + "total_size": { + "name": "[%key:component::onedrive::entity::sensor::total_size::name%]" + }, + "used_size": { + "name": "[%key:component::onedrive::entity::sensor::used_size::name%]" + } + } + }, "exceptions": { "authentication_failed": { "message": "[%key:component::onedrive::exceptions::authentication_failed::message%]" }, "failed_to_get_folder": { "message": "[%key:component::onedrive::exceptions::failed_to_get_folder::message%]" + }, + "update_failed": { + "message": "[%key:component::onedrive::exceptions::update_failed::message%]" + } + }, + "issues": { + "drive_almost_full": { + "description": "[%key:component::onedrive::issues::drive_almost_full::description%]", + "title": "[%key:component::onedrive::issues::drive_almost_full::title%]" + }, + "drive_full": { + "description": "[%key:component::onedrive::issues::drive_full::description%]", + "title": "[%key:component::onedrive::issues::drive_full::title%]" } } } diff --git a/tests/components/onedrive_for_business/snapshots/test_sensor.ambr b/tests/components/onedrive_for_business/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..3b431de50400fa --- /dev/null +++ b/tests/components/onedrive_for_business/snapshots/test_sensor.ambr @@ -0,0 +1,235 @@ +# serializer version: 1 +# name: test_sensors[sensor.my_drive_drive_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'normal', + 'nearing', + 'critical', + 'exceeded', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_drive_drive_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Drive state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Drive state', + 'platform': 'onedrive_for_business', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state', + 'unique_id': 'mock_drive_id_drive_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.my_drive_drive_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'My Drive Drive state', + 'options': list([ + 'normal', + 'nearing', + 'critical', + 'exceeded', + ]), + }), + 'context': , + 'entity_id': 'sensor.my_drive_drive_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'nearing', + }) +# --- +# name: test_sensors[sensor.my_drive_remaining_storage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_drive_remaining_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Remaining storage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining storage', + 'platform': 'onedrive_for_business', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_size', + 'unique_id': 'mock_drive_id_remaining_size', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.my_drive_remaining_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'My Drive Remaining storage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_drive_remaining_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.75', + }) +# --- +# name: test_sensors[sensor.my_drive_total_available_storage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_drive_total_available_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total available storage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total available storage', + 'platform': 'onedrive_for_business', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_size', + 'unique_id': 'mock_drive_id_total_size', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.my_drive_total_available_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'My Drive Total available storage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_drive_total_available_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_sensors[sensor.my_drive_used_storage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_drive_used_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Used storage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Used storage', + 'platform': 'onedrive_for_business', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'used_size', + 'unique_id': 'mock_drive_id_used_size', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.my_drive_used_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'My Drive Used storage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_drive_used_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.95812094211578', + }) +# --- diff --git a/tests/components/onedrive_for_business/test_sensor.py b/tests/components/onedrive_for_business/test_sensor.py new file mode 100644 index 00000000000000..bf8a56ac4bad46 --- /dev/null +++ b/tests/components/onedrive_for_business/test_sensor.py @@ -0,0 +1,64 @@ +"""Tests for OneDrive for Business sensors.""" + +from datetime import timedelta +from typing import Any +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from onedrive_personal_sdk.const import DriveType +from onedrive_personal_sdk.exceptions import HttpRequestException +from onedrive_personal_sdk.models.items import Drive +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the OneDrive for Business sensors.""" + + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("attr", "side_effect"), + [ + ("side_effect", HttpRequestException(503, "Service Unavailable")), + ("return_value", Drive(id="id", name="name", drive_type=DriveType.PERSONAL)), + ], +) +async def test_update_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + freezer: FrozenDateTimeFactory, + attr: str, + side_effect: Any, +) -> None: + """Ensure sensors are going unavailable on update failure.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.my_drive_remaining_storage") + assert state.state == "0.75" + + setattr(mock_onedrive_client.get_drive, attr, side_effect) + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.my_drive_remaining_storage") + assert state.state == STATE_UNAVAILABLE From 4af60ef3b953c53bf5b7f0a863848f757b05c616 Mon Sep 17 00:00:00 2001 From: hbludworth <63749412+hbludworth@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:24:06 -0700 Subject: [PATCH 13/20] Show progress indicator during backup stage of Core/App update (#162683) --- homeassistant/components/hassio/update.py | 4 + tests/components/hassio/test_update.py | 167 ++++++++++++++++++++++ 2 files changed, 171 insertions(+) diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index b9db22d558de99..8bf2ee988e754d 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -152,6 +152,8 @@ async def async_install( **kwargs: Any, ) -> None: """Install an update.""" + self._attr_in_progress = True + self.async_write_ha_state() await update_addon( self.hass, self._addon_slug, backup, self.title, self.installed_version ) @@ -308,6 +310,8 @@ async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" + self._attr_in_progress = True + self.async_write_ha_state() await update_core(self.hass, version, backup) @callback diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index b6aea208b99444..bdd89be124ffb6 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -1044,6 +1044,173 @@ async def test_update_core_with_backup( ) +async def test_update_core_sets_progress_immediately( + hass: HomeAssistant, supervisor_client: AsyncMock +) -> None: + """Test core update sets in_progress immediately when install starts.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + state = hass.states.get("update.home_assistant_core_update") + assert state.attributes.get("in_progress") is False + + # Mock update_core to verify in_progress is set before it's called + async def check_progress( + hass: HomeAssistant, version: str | None, backup: bool + ) -> None: + assert ( + hass.states.get("update.home_assistant_core_update").attributes.get( + "in_progress" + ) + is True + ) + + with patch( + "homeassistant.components.hassio.update.update_core", + side_effect=check_progress, + ) as mock_update: + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.home_assistant_core_update", "backup": True}, + blocking=True, + ) + + mock_update.assert_called_once() + + +async def test_update_core_resets_progress_on_error( + hass: HomeAssistant, supervisor_client: AsyncMock +) -> None: + """Test core update resets in_progress to False when update fails.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + state = hass.states.get("update.home_assistant_core_update") + assert state.attributes.get("in_progress") is False + + with ( + patch( + "homeassistant.components.hassio.update.update_core", + side_effect=HomeAssistantError, + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.home_assistant_core_update", "backup": True}, + blocking=True, + ) + + state = hass.states.get("update.home_assistant_core_update") + assert state.attributes.get("in_progress") is False, ( + "in_progress should be reset to False after error" + ) + + +async def test_update_addon_sets_progress_immediately( + hass: HomeAssistant, supervisor_client: AsyncMock +) -> None: + """Test addon update sets in_progress immediately when install starts.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.attributes.get("in_progress") is False + + # Mock update_addon to verify in_progress is set before it's called + async def check_progress( + hass: HomeAssistant, + addon: str, + backup: bool, + addon_name: str | None, + installed_version: str | None, + ) -> None: + assert ( + hass.states.get("update.test_update").attributes.get("in_progress") is True + ) + + with patch( + "homeassistant.components.hassio.update.update_addon", + side_effect=check_progress, + ) as mock_update: + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.test_update", "backup": True}, + blocking=True, + ) + + mock_update.assert_called_once() + + +async def test_update_addon_resets_progress_on_error( + hass: HomeAssistant, supervisor_client: AsyncMock +) -> None: + """Test addon update resets in_progress to False when update fails.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.attributes.get("in_progress") is False + + with ( + patch( + "homeassistant.components.hassio.update.update_addon", + side_effect=HomeAssistantError, + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.test_update", "backup": True}, + blocking=True, + ) + + state = hass.states.get("update.test_update") + assert state.attributes.get("in_progress") is False, ( + "in_progress should be reset to False after error" + ) + + async def test_update_supervisor( hass: HomeAssistant, supervisor_client: AsyncMock ) -> None: From 654e13244097323664786a770f7b62419099ac09 Mon Sep 17 00:00:00 2001 From: christian9712 <68643927+christian9712@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:26:19 +0100 Subject: [PATCH 14/20] ADS Light Color Temperature Support (#153913) Co-authored-by: Cursor --- homeassistant/components/ads/light.py | 79 +++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/ads/light.py b/homeassistant/components/ads/light.py index 3de223e5fc44a4..63d699a00554c5 100644 --- a/homeassistant/components/ads/light.py +++ b/homeassistant/components/ads/light.py @@ -9,9 +9,13 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA, ColorMode, LightEntity, + filter_supported_color_modes, ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant @@ -24,13 +28,20 @@ from .hub import AdsHub CONF_ADS_VAR_BRIGHTNESS = "adsvar_brightness" +CONF_ADS_VAR_COLOR_TEMP_KELVIN = "adsvar_color_temp_kelvin" +CONF_MIN_COLOR_TEMP_KELVIN = "min_color_temp_kelvin" +CONF_MAX_COLOR_TEMP_KELVIN = "max_color_temp_kelvin" STATE_KEY_BRIGHTNESS = "brightness" +STATE_KEY_COLOR_TEMP_KELVIN = "color_temp_kelvin" DEFAULT_NAME = "ADS Light" PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_ADS_VAR): cv.string, vol.Optional(CONF_ADS_VAR_BRIGHTNESS): cv.string, + vol.Optional(CONF_ADS_VAR_COLOR_TEMP_KELVIN): cv.string, + vol.Optional(CONF_MIN_COLOR_TEMP_KELVIN): cv.positive_int, + vol.Optional(CONF_MAX_COLOR_TEMP_KELVIN): cv.positive_int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, } ) @@ -47,9 +58,24 @@ def setup_platform( ads_var_enable: str = config[CONF_ADS_VAR] ads_var_brightness: str | None = config.get(CONF_ADS_VAR_BRIGHTNESS) + ads_var_color_temp_kelvin: str | None = config.get(CONF_ADS_VAR_COLOR_TEMP_KELVIN) + min_color_temp_kelvin: int | None = config.get(CONF_MIN_COLOR_TEMP_KELVIN) + max_color_temp_kelvin: int | None = config.get(CONF_MAX_COLOR_TEMP_KELVIN) name: str = config[CONF_NAME] - add_entities([AdsLight(ads_hub, ads_var_enable, ads_var_brightness, name)]) + add_entities( + [ + AdsLight( + ads_hub, + ads_var_enable, + ads_var_brightness, + ads_var_color_temp_kelvin, + min_color_temp_kelvin, + max_color_temp_kelvin, + name, + ) + ] + ) class AdsLight(AdsEntity, LightEntity): @@ -60,18 +86,40 @@ def __init__( ads_hub: AdsHub, ads_var_enable: str, ads_var_brightness: str | None, + ads_var_color_temp_kelvin: str | None, + min_color_temp_kelvin: int | None, + max_color_temp_kelvin: int | None, name: str, ) -> None: """Initialize AdsLight entity.""" super().__init__(ads_hub, name, ads_var_enable) self._state_dict[STATE_KEY_BRIGHTNESS] = None + self._state_dict[STATE_KEY_COLOR_TEMP_KELVIN] = None self._ads_var_brightness = ads_var_brightness + self._ads_var_color_temp_kelvin = ads_var_color_temp_kelvin + + # Determine supported color modes + color_modes = {ColorMode.ONOFF} if ads_var_brightness is not None: - self._attr_color_mode = ColorMode.BRIGHTNESS - self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} - else: - self._attr_color_mode = ColorMode.ONOFF - self._attr_supported_color_modes = {ColorMode.ONOFF} + color_modes.add(ColorMode.BRIGHTNESS) + if ads_var_color_temp_kelvin is not None: + color_modes.add(ColorMode.COLOR_TEMP) + + self._attr_supported_color_modes = filter_supported_color_modes(color_modes) + self._attr_color_mode = next(iter(self._attr_supported_color_modes)) + + # Set color temperature range (static config values take precedence over defaults) + if ads_var_color_temp_kelvin is not None: + self._attr_min_color_temp_kelvin = ( + min_color_temp_kelvin + if min_color_temp_kelvin is not None + else DEFAULT_MIN_KELVIN + ) + self._attr_max_color_temp_kelvin = ( + max_color_temp_kelvin + if max_color_temp_kelvin is not None + else DEFAULT_MAX_KELVIN + ) async def async_added_to_hass(self) -> None: """Register device notification.""" @@ -84,11 +132,23 @@ async def async_added_to_hass(self) -> None: STATE_KEY_BRIGHTNESS, ) + if self._ads_var_color_temp_kelvin is not None: + await self.async_initialize_device( + self._ads_var_color_temp_kelvin, + pyads.PLCTYPE_UINT, + STATE_KEY_COLOR_TEMP_KELVIN, + ) + @property def brightness(self) -> int | None: """Return the brightness of the light (0..255).""" return self._state_dict[STATE_KEY_BRIGHTNESS] + @property + def color_temp_kelvin(self) -> int | None: + """Return the color temperature in Kelvin.""" + return self._state_dict[STATE_KEY_COLOR_TEMP_KELVIN] + @property def is_on(self) -> bool: """Return True if the entity is on.""" @@ -97,6 +157,8 @@ def is_on(self) -> bool: def turn_on(self, **kwargs: Any) -> None: """Turn the light on or set a specific dimmer value.""" brightness = kwargs.get(ATTR_BRIGHTNESS) + color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN) + self._ads_hub.write_by_name(self._ads_var, True, pyads.PLCTYPE_BOOL) if self._ads_var_brightness is not None and brightness is not None: @@ -104,6 +166,11 @@ def turn_on(self, **kwargs: Any) -> None: self._ads_var_brightness, brightness, pyads.PLCTYPE_UINT ) + if self._ads_var_color_temp_kelvin is not None and color_temp is not None: + self._ads_hub.write_by_name( + self._ads_var_color_temp_kelvin, color_temp, pyads.PLCTYPE_UINT + ) + def turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" self._ads_hub.write_by_name(self._ads_var, False, pyads.PLCTYPE_BOOL) From 58ac3d2f454bcbf0658e0e184c8679922560d972 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 17 Feb 2026 18:32:35 +0100 Subject: [PATCH 15/20] Type fixture in Fritz tests (#163271) --- tests/components/fritz/conftest.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/components/fritz/conftest.py b/tests/components/fritz/conftest.py index 211da1cf605270..2e0b366ff0c2d0 100644 --- a/tests/components/fritz/conftest.py +++ b/tests/components/fritz/conftest.py @@ -1,5 +1,6 @@ """Common stuff for Fritz!Tools tests.""" +from collections.abc import Generator import logging from unittest.mock import MagicMock, patch @@ -86,13 +87,13 @@ def _call_action(self, service: str, action: str, **kwargs): @pytest.fixture(name="fc_data") -def fc_data_mock(): +def fc_data_mock() -> dict[str, dict]: """Fixture for default fc_data.""" return MOCK_FB_SERVICES @pytest.fixture -def fc_class_mock(fc_data): +def fc_class_mock(fc_data: dict[str, dict]) -> Generator[FritzConnectionMock]: """Fixture that sets up a mocked FritzConnection class.""" with patch( "homeassistant.components.fritz.coordinator.FritzConnectionCached", @@ -103,7 +104,7 @@ def fc_class_mock(fc_data): @pytest.fixture -def fh_class_mock(): +def fh_class_mock() -> Generator[type[FritzHosts]]: """Fixture that sets up a mocked FritzHosts class.""" with ( patch( @@ -125,7 +126,7 @@ def fh_class_mock(): @pytest.fixture -def fs_class_mock(): +def fs_class_mock() -> Generator[type[FritzStatus]]: """Fixture that sets up a mocked FritzStatus class.""" with ( patch( From 65cf61571a282b3797791d436f7a8a39e1c5bfab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Tue, 17 Feb 2026 20:36:58 +0100 Subject: [PATCH 16/20] Add Miele dishwasher program code (#163308) --- homeassistant/components/miele/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 98ff8430a0a8a9..22c874b408c02a 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -489,7 +489,7 @@ class DishWasherProgramId(MieleEnum, missing_to_none=True): no_program = 0, -1 intensive = 1, 26, 205 maintenance = 2, 27, 214 - eco = 3, 28, 200 + eco = 3, 22, 28, 200 automatic = 6, 7, 31, 32, 202 solar_save = 9, 34 gentle = 10, 35, 210 From 551a71104e5f5387d13b59c083fc4289614afa12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 17 Feb 2026 19:41:27 +0000 Subject: [PATCH 17/20] Bump Idasen Desk dependency (#163309) --- homeassistant/components/idasen_desk/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json index 9e83347f098fc2..9ed011498442ae 100644 --- a/homeassistant/components/idasen_desk/manifest.json +++ b/homeassistant/components/idasen_desk/manifest.json @@ -13,5 +13,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["idasen-ha==2.6.3"] + "requirements": ["idasen-ha==2.6.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0a75b3319fd3cd..e1c2920ce75cb5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1280,7 +1280,7 @@ icalendar==6.3.1 icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.6.3 +idasen-ha==2.6.4 # homeassistant.components.idrive_e2 idrive-e2-client==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 886cb1e7413a86..e3510e00842617 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1135,7 +1135,7 @@ icalendar==6.3.1 icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.6.3 +idasen-ha==2.6.4 # homeassistant.components.idrive_e2 idrive-e2-client==0.1.1 From d50d914928f6deb21946b2d9afea86643baeb757 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:02:23 +0100 Subject: [PATCH 18/20] =?UTF-8?q?Update=20quality=20scale=20of=20Namecheap?= =?UTF-8?q?=20DynamicDNS=20integration=20to=20platinum=20=F0=9F=8F=86?= =?UTF-8?q?=EF=B8=8F=20(#161682)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .strict-typing | 1 + .../components/namecheapdns/manifest.json | 1 + .../namecheapdns/quality_scale.yaml | 110 ++++++++++++++++++ mypy.ini | 10 ++ script/hassfest/quality_scale.py | 2 - 5 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/namecheapdns/quality_scale.yaml diff --git a/.strict-typing b/.strict-typing index 2ea93fa6fbc12c..90c915e03272c9 100644 --- a/.strict-typing +++ b/.strict-typing @@ -367,6 +367,7 @@ homeassistant.components.my.* homeassistant.components.mysensors.* homeassistant.components.myuplink.* homeassistant.components.nam.* +homeassistant.components.namecheapdns.* homeassistant.components.nasweb.* homeassistant.components.neato.* homeassistant.components.nest.* diff --git a/homeassistant/components/namecheapdns/manifest.json b/homeassistant/components/namecheapdns/manifest.json index cb8b708a2029aa..f02fef41b960be 100644 --- a/homeassistant/components/namecheapdns/manifest.json +++ b/homeassistant/components/namecheapdns/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/namecheapdns", "integration_type": "service", "iot_class": "cloud_push", + "quality_scale": "platinum", "requirements": [] } diff --git a/homeassistant/components/namecheapdns/quality_scale.yaml b/homeassistant/components/namecheapdns/quality_scale.yaml new file mode 100644 index 00000000000000..a3c9bb2f0dad4e --- /dev/null +++ b/homeassistant/components/namecheapdns/quality_scale.yaml @@ -0,0 +1,110 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: the integration has no actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: + status: exempt + comment: no external dependencies + docs-actions: + status: exempt + comment: the integration has no actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: integration has no entities + entity-unique-id: + status: exempt + comment: integration has no entities + has-entity-name: + status: exempt + comment: integration has no entities + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: the integration has no actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: the integration has no options + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: integration has no entities + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: integration has no entity platforms + reauthentication-flow: done + test-coverage: done + + # Gold + devices: + status: exempt + comment: integration has no devices + diagnostics: + status: exempt + comment: the integration has no runtime data and entry data only contains sensitive information + discovery-update-info: + status: exempt + comment: the service cannot be discovered + discovery: + status: exempt + comment: the service cannot be discovered + docs-data-update: done + docs-examples: + status: exempt + comment: the integration has no entities or actions + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: the integration is a service + docs-supported-functions: + status: exempt + comment: integration has no entities or actions + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: integration has no devices + entity-category: + status: exempt + comment: integration has no entities + entity-device-class: + status: exempt + comment: integration has no entities + entity-disabled-by-default: + status: exempt + comment: integration has no entities + entity-translations: + status: exempt + comment: integration has no entities + exception-translations: done + icon-translations: + status: exempt + comment: integration has no entities or actions + reconfiguration-flow: done + repair-issues: done + stale-devices: + status: exempt + comment: integration has no devices + + # Platinum + async-dependency: + status: exempt + comment: integration has no external dependencies + inject-websession: done + strict-typing: done diff --git a/mypy.ini b/mypy.ini index 6ace8e21ce45ee..c1fc17cf90843a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3426,6 +3426,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.namecheapdns.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.nasweb.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 013f80a165f5aa..4924065b325f15 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -651,7 +651,6 @@ class Rule: "mythicbeastsdns", "nad", "nam", - "namecheapdns", "nanoleaf", "nasweb", "neato", @@ -1649,7 +1648,6 @@ class Rule: "mythicbeastsdns", "nad", "nam", - "namecheapdns", "nanoleaf", "nasweb", "neato", From 479cb7f1e1dc6e0f7dadd9cd01ab7ff1edebbc0e Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 17 Feb 2026 12:50:38 -0800 Subject: [PATCH 19/20] Allow Gemini CLI and Anti-gravity SKILL discovery (#163194) --- .agent/skills | 1 + .gemini/skills | 1 + 2 files changed, 2 insertions(+) create mode 120000 .agent/skills create mode 120000 .gemini/skills diff --git a/.agent/skills b/.agent/skills new file mode 120000 index 00000000000000..2cd5a6932fa9ca --- /dev/null +++ b/.agent/skills @@ -0,0 +1 @@ +../.claude/skills/ \ No newline at end of file diff --git a/.gemini/skills b/.gemini/skills new file mode 120000 index 00000000000000..454b8427cd757f --- /dev/null +++ b/.gemini/skills @@ -0,0 +1 @@ +../.claude/skills \ No newline at end of file From 19f6340546e063972142d07e67bbf546b4af7712 Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Tue, 17 Feb 2026 12:57:56 -0800 Subject: [PATCH 20/20] Bump victron-ble-ha-parser to 0.4.10 (#163310) --- homeassistant/components/victron_ble/manifest.json | 2 +- homeassistant/components/victron_ble/sensor.py | 2 ++ homeassistant/components/victron_ble/strings.json | 2 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/victron_ble/snapshots/test_sensor.ambr | 6 +++++- 6 files changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/victron_ble/manifest.json b/homeassistant/components/victron_ble/manifest.json index 968fd27dec0ff1..d89b0dd5363582 100644 --- a/homeassistant/components/victron_ble/manifest.json +++ b/homeassistant/components/victron_ble/manifest.json @@ -15,5 +15,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["victron-ble-ha-parser==0.4.9"] + "requirements": ["victron-ble-ha-parser==0.4.10"] } diff --git a/homeassistant/components/victron_ble/sensor.py b/homeassistant/components/victron_ble/sensor.py index 0b5916b23ca6a4..b66043a5a55597 100644 --- a/homeassistant/components/victron_ble/sensor.py +++ b/homeassistant/components/victron_ble/sensor.py @@ -44,6 +44,7 @@ ] ALARM_OPTIONS = [ + "no_alarm", "low_voltage", "high_voltage", "low_soc", @@ -336,6 +337,7 @@ class VictronBLESensorEntityDescription(SensorEntityDescription): "switched_off_register", "remote_input", "protection_active", + "load_output_disabled", "pay_as_you_go_out_of_credit", "bms", "engine_shutdown", diff --git a/homeassistant/components/victron_ble/strings.json b/homeassistant/components/victron_ble/strings.json index 1553d373213cbb..a44eb4c5ee90f8 100644 --- a/homeassistant/components/victron_ble/strings.json +++ b/homeassistant/components/victron_ble/strings.json @@ -63,6 +63,7 @@ "low_v_ac_out": "AC-out undervoltage", "low_voltage": "Undervoltage", "mid_voltage": "[%key:component::victron_ble::common::midpoint_voltage%]", + "no_alarm": "No alarm", "overload": "Overload", "short_circuit": "Short circuit" } @@ -224,6 +225,7 @@ "analysing_input_voltage": "Analyzing input voltage", "bms": "Battery management system", "engine_shutdown": "Engine shutdown", + "load_output_disabled": "Load output disabled", "no_input_power": "No input power", "no_reason": "No reason", "pay_as_you_go_out_of_credit": "Pay-as-you-go out of credit", diff --git a/requirements_all.txt b/requirements_all.txt index e1c2920ce75cb5..4876cb3055f978 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3182,7 +3182,7 @@ venstarcolortouch==0.21 viaggiatreno_ha==0.2.4 # homeassistant.components.victron_ble -victron-ble-ha-parser==0.4.9 +victron-ble-ha-parser==0.4.10 # homeassistant.components.victron_remote_monitoring victron-vrm==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e3510e00842617..1001db6ec9e31e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2673,7 +2673,7 @@ velbus-aio==2026.1.4 venstarcolortouch==0.21 # homeassistant.components.victron_ble -victron-ble-ha-parser==0.4.9 +victron-ble-ha-parser==0.4.10 # homeassistant.components.victron_remote_monitoring victron-vrm==0.1.8 diff --git a/tests/components/victron_ble/snapshots/test_sensor.ambr b/tests/components/victron_ble/snapshots/test_sensor.ambr index c7f9edcca82024..bb4a92a9eb2536 100644 --- a/tests/components/victron_ble/snapshots/test_sensor.ambr +++ b/tests/components/victron_ble/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ + 'no_alarm', 'low_voltage', 'high_voltage', 'low_soc', @@ -58,6 +59,7 @@ 'device_class': 'enum', 'friendly_name': 'Battery Monitor Alarm', 'options': list([ + 'no_alarm', 'low_voltage', 'high_voltage', 'low_soc', @@ -79,7 +81,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'no_alarm', }) # --- # name: test_sensors[battery_monitor][sensor.battery_monitor_battery-entry] @@ -592,6 +594,7 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ + 'no_alarm', 'low_voltage', 'high_voltage', 'low_soc', @@ -644,6 +647,7 @@ 'device_class': 'enum', 'friendly_name': 'DC Energy Meter Alarm', 'options': list([ + 'no_alarm', 'low_voltage', 'high_voltage', 'low_soc',