diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b6bdcad1c7194c..0c23b5fd727c08 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,7 +5,7 @@ # Copilot code review instructions - Start review comments with a short, one-sentence summary of the suggested fix. -- Do not add comments about code style, formatting or linting issues. +- Do not comment on code style, formatting or linting issues. # GitHub Copilot & Claude Code Instructions @@ -21,7 +21,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom ## Python Syntax Notes -- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses. +- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses. Never flag this as an issue since Home Assistant officially supports Python 3.14. ## Testing @@ -34,8 +34,3 @@ Integrations with Platinum or Gold level in the Integration Quality Scale reflec When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form. When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked. - - -# Skills - -- ha-integration-knowledge: .claude/skills/ha-integration-knowledge/SKILL.md diff --git a/.github/instructions/integrations.instructions.md b/.github/instructions/integrations.instructions.md new file mode 100644 index 00000000000000..34bb4947b36f47 --- /dev/null +++ b/.github/instructions/integrations.instructions.md @@ -0,0 +1,47 @@ +--- +applyTo: "homeassistant/components/**, tests/components/**" +excludeAgent: "cloud-agent" +--- + + + + +## File Locations +- **Integration code**: `./homeassistant/components//` +- **Integration tests**: `./tests/components//` + +## General guidelines + +- When looking for examples, prefer integrations with the platinum or gold quality scale level first. +- Polling intervals are NOT user-configurable. Never add scan_interval, update_interval, or polling frequency options to config flows or config entries. +- Do NOT allow users to set config entry names in config flows. Names are automatically generated or can be customized later in UI. Exception: helper integrations may allow custom names. +- For entity actions and entity services, avoid requesting redundant defensive checks for fields already enforced by Home Assistant validation schemas and entity filters; only request extra guards when values bypass validation or are transformed unsafely. +- When validation guarantees a key is present, prefer direct dictionary indexing (`data["key"]`) over `.get("key")` so invalid assumptions fail fast. +- Integrations should be thin wrappers. Protocol parsing, device state machines, or other domain logic belong in a separate PyPI library, not in the integration itself. If unsure, ask before inlining. +- "potato" is a forbidden word for an integration and should never be used. + +The following platforms have extra guidelines: +- **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection +- **Repairs**: [`platform-repairs.md`](platform-repairs.md) for user-actionable repair issues + + +## Integration Quality Scale + +- When validating the quality scale rules, check them at https://developers.home-assistant.io/docs/core/integration-quality-scale/rules +- When implementing or reviewing an integration, always consider the quality scale rules, since they promote best practices. + +Template scale file: `./script/scaffold/templates/integration/integration/quality_scale.yaml` + +### How Rules Apply +1. **Check `manifest.json`**: Look for `"quality_scale"` key to determine integration level +2. **Bronze Rules**: Always required for any integration with quality scale +3. **Higher Tier Rules**: Only apply if integration targets that tier or higher +4. **Rule Status**: Check `quality_scale.yaml` in integration folder for: + - `done`: Rule implemented + - `exempt`: Rule doesn't apply (with reason in comment) + - `todo`: Rule needs implementation + + +## Testing Requirements + +- Tests should avoid interacting or mocking internal integration details. For more info, see https://developers.home-assistant.io/docs/development_testing/#writing-tests-for-integrations diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e5b86931469d58..6d8c73a00e97a3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -366,7 +366,7 @@ jobs: echo "key=uv-${UV_CACHE_VERSION}-${uv_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv key: >- @@ -374,7 +374,8 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore uv wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + id: cache-uv + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ${{ env.UV_CACHE_DIR }} key: >- @@ -398,6 +399,7 @@ jobs: if: | steps.cache-venv.outputs.cache-hit != 'true' || steps.cache-apt-check.outputs.cache-hit != 'true' + id: install-os-deps timeout-minutes: 10 env: APT_CACHE_HIT: ${{ steps.cache-apt-check.outputs.cache-hit }} @@ -431,7 +433,10 @@ jobs: sudo chmod -R 755 ${APT_CACHE_BASE} fi - name: Save apt cache - if: steps.cache-apt-check.outputs.cache-hit != 'true' + if: | + always() + && steps.cache-apt-check.outputs.cache-hit != 'true' + && steps.install-os-deps.outcome == 'success' uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | @@ -441,6 +446,7 @@ jobs: ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }} - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' + id: create-venv run: | python -m venv venv . venv/bin/activate @@ -471,6 +477,26 @@ jobs: - name: Check dirty run: | ./script/check_dirty + - name: Save uv wheel cache + if: | + (success() && steps.cache-venv.outputs.cache-hit != 'true') + || (always() + && steps.create-venv.outcome == 'success' + && steps.cache-uv.outputs.cache-matched-key == '') + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ${{ env.UV_CACHE_DIR }} + key: >- + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ + steps.generate-uv-key.outputs.key }} + - name: Save base Python virtual environment + if: always() && steps.create-venv.outcome == 'success' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: venv + key: >- + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ + needs.info.outputs.python_cache_key }} hassfest: name: Check hassfest diff --git a/AGENTS.md b/AGENTS.md index d9340c42027605..406a618c2b1db6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom ## Python Syntax Notes -- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses. +- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses. Never flag this as an issue since Home Assistant officially supports Python 3.14. ## Testing diff --git a/CODEOWNERS b/CODEOWNERS index 6146338ddc1335..63e8f32ef88de9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -758,6 +758,8 @@ CLAUDE.md @home-assistant/core /tests/components/homewizard/ @DCSBL /homeassistant/components/honeywell/ @rdfurman @mkmer /tests/components/honeywell/ @rdfurman @mkmer +/homeassistant/components/honeywell_string_lights/ @balloob +/tests/components/honeywell_string_lights/ @balloob /homeassistant/components/hr_energy_qube/ @MattieGit /tests/components/hr_energy_qube/ @MattieGit /homeassistant/components/html5/ @alexyao2015 @tr4nt0r @@ -1201,6 +1203,8 @@ CLAUDE.md @home-assistant/core /tests/components/notify_events/ @matrozov @papajojo /homeassistant/components/notion/ @bachya /tests/components/notion/ @bachya +/homeassistant/components/novy_cooker_hood/ @piitaya +/tests/components/novy_cooker_hood/ @piitaya /homeassistant/components/nrgkick/ @andijakl /tests/components/nrgkick/ @andijakl /homeassistant/components/nsw_fuel_station/ @nickw444 @@ -1985,8 +1989,8 @@ CLAUDE.md @home-assistant/core /tests/components/wled/ @frenck @mik-laj /homeassistant/components/wmspro/ @mback2k /tests/components/wmspro/ @mback2k -/homeassistant/components/wolflink/ @adamkrol93 @mtielen -/tests/components/wolflink/ @adamkrol93 @mtielen +/homeassistant/components/wolflink/ @adamkrol93 @EnjoyingM +/tests/components/wolflink/ @adamkrol93 @EnjoyingM /homeassistant/components/workday/ @fabaff @gjohansson-ST /tests/components/workday/ @fabaff @gjohansson-ST /homeassistant/components/worldclock/ @fabaff diff --git a/homeassistant/brands/honeywell.json b/homeassistant/brands/honeywell.json index 37cd6d8ce732e0..001db20de07afe 100644 --- a/homeassistant/brands/honeywell.json +++ b/homeassistant/brands/honeywell.json @@ -1,5 +1,5 @@ { "domain": "honeywell", "name": "Honeywell", - "integrations": ["lyric", "evohome", "honeywell"] + "integrations": ["lyric", "evohome", "honeywell", "honeywell_string_lights"] } diff --git a/homeassistant/components/accuweather/config_flow.py b/homeassistant/components/accuweather/config_flow.py index a56391e9c4f05e..a15dc9609ed302 100644 --- a/homeassistant/components/accuweather/config_flow.py +++ b/homeassistant/components/accuweather/config_flow.py @@ -4,7 +4,7 @@ from asyncio import timeout from collections.abc import Mapping -from typing import Any +from typing import TYPE_CHECKING, Any from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError from aiohttp import ClientError @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -55,8 +55,11 @@ async def async_step_user( ) self._abort_if_unique_id_configured() + if TYPE_CHECKING: + assert accuweather.location_name is not None + return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input + title=accuweather.location_name, data=user_input ) return self.async_show_form( @@ -70,9 +73,6 @@ async def async_step_user( vol.Optional( CONF_LONGITUDE, default=self.hass.config.longitude ): cv.longitude, - vol.Optional( - CONF_NAME, default=self.hass.config.location_name - ): str, } ), errors=errors, diff --git a/homeassistant/components/accuweather/coordinator.py b/homeassistant/components/accuweather/coordinator.py index 3c4991d2c59fbf..c8e37f45cf9d8b 100644 --- a/homeassistant/components/accuweather/coordinator.py +++ b/homeassistant/components/accuweather/coordinator.py @@ -64,7 +64,7 @@ def __init__( """Initialize.""" self.accuweather = accuweather self.location_key = accuweather.location_key - name = config_entry.data[CONF_NAME] + name = config_entry.data.get(CONF_NAME) or config_entry.title if TYPE_CHECKING: assert self.location_key is not None @@ -122,7 +122,7 @@ def __init__( self.accuweather = accuweather self.location_key = accuweather.location_key self._fetch_method = fetch_method - name = config_entry.data[CONF_NAME] + name = config_entry.data.get(CONF_NAME) or config_entry.title if TYPE_CHECKING: assert self.location_key is not None diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index 221452a63c9d19..ac6d15bd4774c6 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -25,8 +25,7 @@ "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "latitude": "[%key:common::config_flow::data::latitude%]", - "longitude": "[%key:common::config_flow::data::longitude%]", - "name": "[%key:common::config_flow::data::name%]" + "longitude": "[%key:common::config_flow::data::longitude%]" }, "data_description": { "api_key": "API key generated in the AccuWeather APIs portal." diff --git a/homeassistant/components/actron_air/climate.py b/homeassistant/components/actron_air/climate.py index efa75212773c27..8d2e681159431a 100644 --- a/homeassistant/components/actron_air/climate.py +++ b/homeassistant/components/actron_air/climate.py @@ -38,6 +38,7 @@ "HEAT": HVACMode.HEAT, "FAN": HVACMode.FAN_ONLY, "AUTO": HVACMode.AUTO, + "DRY": HVACMode.DRY, "OFF": HVACMode.OFF, } HVAC_MODE_MAPPING_HA_TO_ACTRONAIR = { @@ -79,7 +80,6 @@ class ActronAirClimateEntity(ClimateEntity): ) _attr_name = None _attr_fan_modes = list(FAN_MODE_MAPPING_ACTRONAIR_TO_HA.values()) - _attr_hvac_modes = list(HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.values()) class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity): @@ -93,6 +93,17 @@ def __init__( super().__init__(coordinator) self._attr_unique_id = self._serial_number + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the list of supported HVAC modes.""" + modes = [ + HVAC_MODE_MAPPING_ACTRONAIR_TO_HA[mode] + for mode in self._status.user_aircon_settings.supported_modes + if mode in HVAC_MODE_MAPPING_ACTRONAIR_TO_HA + ] + modes.append(HVACMode.OFF) + return modes + @property def min_temp(self) -> float: """Return the minimum temperature that can be set.""" @@ -179,6 +190,18 @@ def __init__( super().__init__(coordinator, zone) self._attr_unique_id: str = self._zone_identifier + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the list of supported HVAC modes.""" + status = self.coordinator.data + modes = [ + HVAC_MODE_MAPPING_ACTRONAIR_TO_HA[mode] + for mode in status.user_aircon_settings.supported_modes + if mode in HVAC_MODE_MAPPING_ACTRONAIR_TO_HA + ] + modes.append(HVACMode.OFF) + return modes + @property def min_temp(self) -> float: """Return the minimum temperature that can be set.""" diff --git a/homeassistant/components/actron_air/manifest.json b/homeassistant/components/actron_air/manifest.json index 8bcf92bb038343..06978d83b460c4 100644 --- a/homeassistant/components/actron_air/manifest.json +++ b/homeassistant/components/actron_air/manifest.json @@ -13,5 +13,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["actron-neo-api==0.5.3"] + "requirements": ["actron-neo-api==0.5.6"] } diff --git a/homeassistant/components/airly/config_flow.py b/homeassistant/components/airly/config_flow.py index d3f2240a37c7d3..5f0354848a7625 100644 --- a/homeassistant/components/airly/config_flow.py +++ b/homeassistant/components/airly/config_flow.py @@ -12,11 +12,11 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_USE_NEAREST, DOMAIN, NO_AIRLY_SENSORS +from .const import CONF_USE_NEAREST, DEFAULT_NAME, DOMAIN, NO_AIRLY_SENSORS DESCRIPTION_PLACEHOLDERS = { "developer_registration_url": "https://developer.airly.eu/register", @@ -45,16 +45,16 @@ async def async_step_user( try: location_point_valid = await check_location( websession, - user_input["api_key"], - user_input["latitude"], - user_input["longitude"], + user_input[CONF_API_KEY], + user_input[CONF_LATITUDE], + user_input[CONF_LONGITUDE], ) if not location_point_valid: location_nearest_valid = await check_location( websession, - user_input["api_key"], - user_input["latitude"], - user_input["longitude"], + user_input[CONF_API_KEY], + user_input[CONF_LATITUDE], + user_input[CONF_LONGITUDE], use_nearest=True, ) except AirlyError as err: @@ -68,7 +68,7 @@ async def async_step_user( return self.async_abort(reason="wrong_location") use_nearest = True return self.async_create_entry( - title=user_input[CONF_NAME], + title=DEFAULT_NAME, data={**user_input, CONF_USE_NEAREST: use_nearest}, ) @@ -83,9 +83,6 @@ async def async_step_user( vol.Optional( CONF_LONGITUDE, default=self.hass.config.longitude ): cv.longitude, - vol.Optional( - CONF_NAME, default=self.hass.config.location_name - ): str, } ), errors=errors, diff --git a/homeassistant/components/airly/const.py b/homeassistant/components/airly/const.py index 5939bfa62de244..6dc00ddcc8618a 100644 --- a/homeassistant/components/airly/const.py +++ b/homeassistant/components/airly/const.py @@ -37,3 +37,5 @@ MIN_UPDATE_INTERVAL: Final = 5 NO_AIRLY_SENSORS: Final = "There are no Airly sensors in this area yet." URL = "https://airly.org/map/#{latitude},{longitude}" + +DEFAULT_NAME: Final = "Airly" diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 2aa99d9c792a00..64a68e499713ae 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -127,7 +127,7 @@ class AirlySensorEntityDescription(SensorEntityDescription): ), AirlySensorEntityDescription( key=ATTR_API_CO, - translation_key="co", + device_class=SensorDeviceClass.CO, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -178,7 +178,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Airly sensor entities based on a config entry.""" - name = entry.data[CONF_NAME] + name = entry.data.get(CONF_NAME) or entry.title coordinator = entry.runtime_data diff --git a/homeassistant/components/airly/strings.json b/homeassistant/components/airly/strings.json index 6f53c7ed23cb79..4c3a50b194b06d 100644 --- a/homeassistant/components/airly/strings.json +++ b/homeassistant/components/airly/strings.json @@ -13,8 +13,7 @@ "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "latitude": "[%key:common::config_flow::data::latitude%]", - "longitude": "[%key:common::config_flow::data::longitude%]", - "name": "[%key:common::config_flow::data::name%]" + "longitude": "[%key:common::config_flow::data::longitude%]" }, "description": "To generate API key go to {developer_registration_url}" } @@ -24,9 +23,6 @@ "sensor": { "caqi": { "name": "Common air quality index" - }, - "co": { - "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" } } }, diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index af0a3d7818cc34..4e51047696937d 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -11,6 +11,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.NOTIFY, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/alexa_devices/button.py b/homeassistant/components/alexa_devices/button.py new file mode 100644 index 00000000000000..9a735f550fc1e1 --- /dev/null +++ b/homeassistant/components/alexa_devices/button.py @@ -0,0 +1,55 @@ +"""Support for buttons.""" + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import slugify + +from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator +from .entity import AmazonServiceEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AmazonConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up button entities for Alexa Devices.""" + coordinator = entry.runtime_data + + known_routines: set[str] = set() + + def _check_routines() -> None: + current_routines = set(coordinator.api.routines) + new_routines = current_routines - known_routines + if new_routines: + known_routines.update(new_routines) + async_add_entities( + AmazonRoutineButton(coordinator, routine) for routine in new_routines + ) + + _check_routines() + entry.async_on_unload(coordinator.async_add_listener(_check_routines)) + + +class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity): + """Button entity for Alexa routine.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: AmazonDevicesCoordinator, routine: str) -> None: + """Initialize the routine button entity.""" + self._coordinator = coordinator + self._routine = routine + super().__init__( + coordinator, + EntityDescription(key=slugify(routine), name=routine), + ) + + async def async_press(self) -> None: + """Handle button press action.""" + await self._coordinator.api.call_routine(self._routine) diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index 8988d3e13cf785..a5414722baa806 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -12,12 +12,13 @@ from aiohttp import ClientSession from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import slugify from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN @@ -64,6 +65,13 @@ def __init__( for identifier_domain, identifier in device.identifiers if identifier_domain == DOMAIN } + self.previous_routines: set[str] = { + routine.unique_id + for routine in er.async_entries_for_config_entry( + er.async_get(hass), entry.entry_id + ) + if routine.domain == Platform.BUTTON + } async def _async_update_data(self) -> dict[str, AmazonDevice]: """Update device data.""" @@ -92,8 +100,13 @@ async def _async_update_data(self) -> dict[str, AmazonDevice]: current_devices = set(data.keys()) if stale_devices := self.previous_devices - current_devices: await self._async_remove_device_stale(stale_devices) - self.previous_devices = current_devices + + current_routines = {slugify(routine) for routine in self.api.routines} + if stale_routines := self.previous_routines - current_routines: + await self._async_remove_routine_stale(stale_routines) + self.previous_routines = current_routines + return data async def _async_remove_device_stale( @@ -116,3 +129,23 @@ async def _async_remove_device_stale( device_id=device.id, remove_config_entry_id=self.config_entry.entry_id, ) + + async def _async_remove_routine_stale( + self, + stale_routines: set[str], + ) -> None: + """Remove stale routine.""" + entity_registry = er.async_get(self.hass) + + for routine in stale_routines: + _LOGGER.debug( + "Detected change in routines: routine %s removed", + routine, + ) + entity_id = entity_registry.async_get_entity_id( + Platform.BUTTON, + DOMAIN, + f"{slugify(self.config_entry.unique_id)}-{slugify(routine)}", + ) + if entity_id: + entity_registry.async_remove(entity_id) diff --git a/homeassistant/components/alexa_devices/entity.py b/homeassistant/components/alexa_devices/entity.py index 21b01e26f6ccb8..57a67d9d31f876 100644 --- a/homeassistant/components/alexa_devices/entity.py +++ b/homeassistant/components/alexa_devices/entity.py @@ -2,9 +2,10 @@ from aioamazondevices.structures import AmazonDevice -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import slugify from .const import DOMAIN from .coordinator import AmazonDevicesCoordinator @@ -50,3 +51,32 @@ def available(self) -> bool: and self._serial_num in self.coordinator.data and self.device.online ) + + +class AmazonServiceEntity(CoordinatorEntity[AmazonDevicesCoordinator]): + """Defines Alexa Devices entity for service device.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AmazonDevicesCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the service entity.""" + + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, service_device_id(coordinator))}, + manufacturer="Amazon", + entry_type=DeviceEntryType.SERVICE, + ) + self.entity_description = description + self._attr_unique_id = ( + f"{slugify(coordinator.config_entry.unique_id)}-{description.key}" + ) + + +def service_device_id(coordinator: AmazonDevicesCoordinator) -> str: + """Return service device id.""" + return slugify(f"{coordinator.config_entry.unique_id}_service_device") diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index 792c6e8d31d447..bd2782c3eb195f 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -1,7 +1,8 @@ """Base entity for Anthropic.""" import base64 -from collections.abc import AsyncGenerator, Callable, Iterable +from collections import deque +from collections.abc import AsyncIterator, Callable, Iterable from dataclasses import dataclass, field from datetime import UTC, datetime import json @@ -20,18 +21,22 @@ CitationWebSearchResultLocationParam, CodeExecutionTool20250825Param, CodeExecutionToolResultBlock, + CodeExecutionToolResultBlockContent, CodeExecutionToolResultBlockParamContentParam, Container, + ContentBlock, ContentBlockParam, DocumentBlockParam, ImageBlockParam, InputJSONDelta, JSONOutputFormatParam, + Message, MessageDeltaUsage, MessageParam, MessageStreamEvent, ModelInfo, OutputConfigParam, + RawContentBlockDelta, RawContentBlockDeltaEvent, RawContentBlockStartEvent, RawContentBlockStopEvent, @@ -68,18 +73,30 @@ WebSearchTool20250305Param, WebSearchTool20260209Param, WebSearchToolResultBlock, + WebSearchToolResultBlockContent, WebSearchToolResultBlockParamContentParam, ) +from anthropic.types.bash_code_execution_tool_result_block import ( + Content as BashCodeExecutionToolResultBlockContent, +) from anthropic.types.bash_code_execution_tool_result_block_param import ( Content as BashCodeExecutionToolResultBlockParamContentParam, ) from anthropic.types.message_create_params import MessageCreateParamsStreaming +from anthropic.types.raw_message_delta_event import Delta +from anthropic.types.text_editor_code_execution_tool_result_block import ( + Content as TextEditorCodeExecutionToolResultBlockContent, +) from anthropic.types.text_editor_code_execution_tool_result_block_param import ( Content as TextEditorCodeExecutionToolResultBlockParamContentParam, ) +from anthropic.types.tool_search_tool_result_block import ( + Content as ToolSearchToolResultBlockContent, +) from anthropic.types.tool_search_tool_result_block_param import ( Content as ToolSearchToolResultBlockParamContentParam, ) +from anthropic.types.tool_use_block import Caller import voluptuous as vol from voluptuous_openapi import convert @@ -91,7 +108,7 @@ from homeassistant.helpers.json import json_dumps from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify -from homeassistant.util.json import JsonObjectType +from homeassistant.util.json import JsonArrayType, JsonObjectType from .const import ( CONF_CHAT_MODEL, @@ -445,13 +462,7 @@ def _convert_content( # noqa: C901 return messages, container_id -async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place - chat_log: conversation.ChatLog, - stream: AsyncStream[MessageStreamEvent], - output_tool: str | None = None, -) -> AsyncGenerator[ - conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict -]: +class AnthropicDeltaStream: """Transform the response stream into HA format. A typical stream of responses might look something like the following: @@ -481,201 +492,376 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have Each message could contain multiple blocks of the same type. """ - if stream is None or not hasattr(stream, "__aiter__"): - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="unexpected_stream_object" + + def __init__( + self, + chat_log: conversation.ChatLog, + stream: AsyncStream[MessageStreamEvent], + output_tool: str | None = None, + ) -> None: + """Initialize the delta stream.""" + self._chat_log: conversation.ChatLog = chat_log + self._stream: AsyncStream[MessageStreamEvent] = stream + self._output_tool: str | None = output_tool + + self._buffer: deque[ + conversation.AssistantContentDeltaDict + | conversation.ToolResultContentDeltaDict + ] = deque() + self._stream_iterator: AsyncIterator[MessageStreamEvent] | None = None + + self._current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = ( + None ) + self._current_tool_args: str = "" + self._content_details = ContentDetails() + self._content_details.add_citation_detail() + self._input_usage: Usage | None = None + self._first_block: bool = True - current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = None - current_tool_args: str - content_details = ContentDetails() - content_details.add_citation_detail() - input_usage: Usage | None = None - first_block: bool = True - - async for response in stream: - LOGGER.debug("Received response: %s", response) - - if isinstance(response, RawMessageStartEvent): - input_usage = response.message.usage - first_block = True - elif isinstance(response, RawContentBlockStartEvent): - if isinstance(response.content_block, ToolUseBlock): - current_tool_block = ToolUseBlockParam( - type="tool_use", - id=response.content_block.id, - name=response.content_block.name, - input=response.content_block.input or {}, - ) - current_tool_args = "" - if response.content_block.name == output_tool: - if first_block or content_details.has_content(): - if content_details: - content_details.delete_empty() - yield {"native": content_details} - content_details = ContentDetails() - content_details.add_citation_detail() - yield {"role": "assistant"} - first_block = False - elif isinstance(response.content_block, TextBlock): - if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead. - first_block - or ( - not content_details.has_citations() - and response.content_block.citations is None - and content_details.has_content() - ) - ): - if content_details: - content_details.delete_empty() - yield {"native": content_details} - content_details = ContentDetails() - yield {"role": "assistant"} - first_block = False - content_details.add_citation_detail() - if response.content_block.text: - content_details.citation_details[-1].length += len( - response.content_block.text - ) - yield {"content": response.content_block.text} - elif isinstance(response.content_block, ThinkingBlock): - if first_block or content_details.thinking_signature: - if content_details: - content_details.delete_empty() - yield {"native": content_details} - content_details = ContentDetails() - content_details.add_citation_detail() - yield {"role": "assistant"} - first_block = False - elif isinstance(response.content_block, RedactedThinkingBlock): - LOGGER.debug( - "Some of Claude’s internal reasoning has been automatically " - "encrypted for safety reasons. This doesn’t affect the quality of " - "responses" - ) - if first_block or content_details.redacted_thinking: - if content_details: - content_details.delete_empty() - yield {"native": content_details} - content_details = ContentDetails() - content_details.add_citation_detail() - yield {"role": "assistant"} - first_block = False - content_details.redacted_thinking = response.content_block.data - elif isinstance(response.content_block, ServerToolUseBlock): - current_tool_block = ServerToolUseBlockParam( - type="server_tool_use", - id=response.content_block.id, - name=response.content_block.name, - input=response.content_block.input or {}, - ) - current_tool_args = "" - elif isinstance( - response.content_block, - ( - WebSearchToolResultBlock, - CodeExecutionToolResultBlock, - BashCodeExecutionToolResultBlock, - TextEditorCodeExecutionToolResultBlock, - ToolSearchToolResultBlock, - ), - ): - if content_details: - content_details.delete_empty() - yield {"native": content_details} - content_details = ContentDetails() - content_details.add_citation_detail() - yield { - "role": "tool_result", - "tool_call_id": response.content_block.tool_use_id, - "tool_name": response.content_block.type.removesuffix( - "_tool_result" - ), - "tool_result": { - "content": cast( - JsonObjectType, response.content_block.to_dict()["content"] - ) - } - if isinstance(response.content_block.content, list) - else cast(JsonObjectType, response.content_block.content.to_dict()), + def __aiter__( + self, + ) -> AsyncIterator[ + conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict + ]: + """Initialize the stream and return the async iterator.""" + if self._stream is None or not hasattr(self._stream, "__aiter__"): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="unexpected_stream_object" + ) + if self._stream_iterator is None: + self._stream_iterator = self._stream.__aiter__() + return self + + async def __anext__( + self, + ) -> ( + conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict + ): + """Get the next item from the stream.""" + while True: + if self._buffer: + return self._buffer.popleft() + + response = await self._stream_iterator.__anext__() # type: ignore[union-attr] + + LOGGER.debug("Received response: %s", response) + self.on_message_stream_event(response) + + def on_message_stream_event(self, event: MessageStreamEvent) -> None: + """Handle MessageStreamEvent.""" + if isinstance(event, RawMessageStartEvent): + self.on_message_start_event(event.message) + return + if isinstance(event, RawContentBlockStartEvent): + self.on_content_block_start_event(event.content_block, event.index) + return + if isinstance(event, RawContentBlockDeltaEvent): + self.on_content_block_delta_event(event.delta) + return + if isinstance(event, RawContentBlockStopEvent): + self.on_content_block_stop_event(event.index) + return + if isinstance(event, RawMessageDeltaEvent): + self.on_message_delta_event(event.delta, event.usage) + return + if isinstance(event, RawMessageStopEvent): + self.on_message_stop_event() + return + LOGGER.debug("Unhandled event type: %s", event.type) # type: ignore[unreachable] # pragma: no cover - All types are handled but we want to verify that + + def on_message_start_event(self, message: Message) -> None: + """Handle RawMessageStartEvent.""" + self._input_usage = message.usage + self._first_block = True + + def on_content_block_start_event( + self, content_block: ContentBlock, index: int + ) -> None: + """Handle RawContentBlockStartEvent.""" + if isinstance(content_block, ToolUseBlock): + self.on_tool_use_block( + content_block.id, + content_block.input, + content_block.name, + content_block.caller, + ) + return + if isinstance(content_block, TextBlock): + self.on_text_block(content_block.text, content_block.citations) + return + if isinstance(content_block, ThinkingBlock): + self.on_thinking_block(content_block.thinking, content_block.signature) + return + if isinstance(content_block, RedactedThinkingBlock): + self.on_redacted_thinking_block(content_block.data) + return + if isinstance(content_block, ServerToolUseBlock): + self.on_server_tool_use_block( + content_block.id, + content_block.name, + content_block.input, + content_block.caller, + ) + return + if isinstance( + content_block, + ( + WebSearchToolResultBlock, + CodeExecutionToolResultBlock, + BashCodeExecutionToolResultBlock, + TextEditorCodeExecutionToolResultBlock, + ToolSearchToolResultBlock, + ), + ): + self.on_server_tool_result_block( + content_block.tool_use_id, + content_block.type, + content_block.content, + content_block.caller if hasattr(content_block, "caller") else None, + ) + return + LOGGER.debug("Unhandled content block type: %s", content_block.type) + + def on_tool_use_block( + self, id: str, input: dict[str, Any], name: str, caller: Caller | None + ) -> None: + """Handle ToolUseBlock.""" + self._current_tool_block = ToolUseBlockParam( + type="tool_use", + id=id, + name=name, + input=input, + ) + self._current_tool_args = "" + if name == self._output_tool: + if self._first_block or self._content_details.has_content(): + if self._content_details: + self._content_details.delete_empty() + self._buffer.append({"native": self._content_details}) + self._content_details = ContentDetails() + self._content_details.add_citation_detail() + self._buffer.append({"role": "assistant"}) + self._first_block = False + + def on_text_block(self, text: str, citations: list[TextCitation] | None) -> None: + """Handle TextBlock.""" + if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead. + self._first_block + or ( + not self._content_details.has_citations() + and citations is None + and self._content_details.has_content() + ) + ): + if self._content_details: + self._content_details.delete_empty() + self._buffer.append({"native": self._content_details}) + self._content_details = ContentDetails() + self._buffer.append({"role": "assistant"}) + self._first_block = False + self._content_details.add_citation_detail() + if text: + self._content_details.citation_details[-1].length += len(text) + self._buffer.append({"content": text}) + + def on_thinking_block(self, thinking: str, signature: str) -> None: + """Handle ThinkingBlock.""" + if self._first_block or self._content_details.thinking_signature: + if self._content_details: + self._content_details.delete_empty() + self._buffer.append({"native": self._content_details}) + self._content_details = ContentDetails() + self._content_details.add_citation_detail() + self._buffer.append({"role": "assistant"}) + self._first_block = False + + def on_redacted_thinking_block(self, data: str) -> None: + """Handle RedactedThinkingBlock.""" + LOGGER.debug( + "Some of Claude’s internal reasoning has been automatically " + "encrypted for safety reasons. This doesn’t affect the quality of " + "responses" + ) + if self._first_block or self._content_details.redacted_thinking: + if self._content_details: + self._content_details.delete_empty() + self._buffer.append({"native": self._content_details}) + self._content_details = ContentDetails() + self._content_details.add_citation_detail() + self._buffer.append({"role": "assistant"}) + self._first_block = False + self._content_details.redacted_thinking = data + + def on_server_tool_use_block( + self, + id: str, + name: Literal[ + "web_search", + "web_fetch", + "code_execution", + "bash_code_execution", + "text_editor_code_execution", + "tool_search_tool_regex", + "tool_search_tool_bm25", + ], + input: dict[str, Any], + caller: Caller | None, + ) -> None: + """Handle ServerToolUseBlock.""" + self._current_tool_block = ServerToolUseBlockParam( + type="server_tool_use", + id=id, + name=name, + input=input, + ) + self._current_tool_args = "" + + def on_server_tool_result_block( + self, + tool_use_id: str, + tool_name: Literal[ + "web_search_tool_result", + "code_execution_tool_result", + "bash_code_execution_tool_result", + "text_editor_code_execution_tool_result", + "tool_search_tool_result", + ], + content: WebSearchToolResultBlockContent + | CodeExecutionToolResultBlockContent + | BashCodeExecutionToolResultBlockContent + | TextEditorCodeExecutionToolResultBlockContent + | ToolSearchToolResultBlockContent, + caller: Caller | None, + ) -> None: + """Handle various server tool result blocks.""" + if self._content_details: + self._content_details.delete_empty() + self._buffer.append({"native": self._content_details}) + self._content_details = ContentDetails() + self._content_details.add_citation_detail() + self._buffer.append( + { + "role": "tool_result", + "tool_call_id": tool_use_id, + "tool_name": tool_name.removesuffix("_tool_result"), + "tool_result": { + "content": cast(JsonArrayType, [x.to_dict() for x in content]) } - first_block = True - elif isinstance(response, RawContentBlockDeltaEvent): - if isinstance(response.delta, InputJSONDelta): - if ( - current_tool_block is not None - and current_tool_block["name"] == output_tool - ): - content_details.citation_details[-1].length += len( - response.delta.partial_json - ) - yield {"content": response.delta.partial_json} - else: - current_tool_args += response.delta.partial_json - elif isinstance(response.delta, TextDelta): - if response.delta.text: - content_details.citation_details[-1].length += len( - response.delta.text - ) - yield {"content": response.delta.text} - elif isinstance(response.delta, ThinkingDelta): - if response.delta.thinking: - yield {"thinking_content": response.delta.thinking} - elif isinstance(response.delta, SignatureDelta): - content_details.thinking_signature = response.delta.signature - elif isinstance(response.delta, CitationsDelta): - content_details.add_citation(response.delta.citation) - elif isinstance(response, RawContentBlockStopEvent): - if current_tool_block is not None: - if current_tool_block["name"] == output_tool: - current_tool_block = None - continue - tool_args = json.loads(current_tool_args) if current_tool_args else {} - current_tool_block["input"] |= tool_args - yield { + if isinstance(content, list) + else cast(JsonObjectType, content.to_dict()), + } + ) + self._first_block = True + + def on_content_block_delta_event(self, delta: RawContentBlockDelta) -> None: + """Handle RawContentBlockDeltaEvent.""" + if isinstance(delta, InputJSONDelta): + self.on_input_json_delta(delta.partial_json) + return + if isinstance(delta, TextDelta): + self.on_text_delta(delta.text) + return + if isinstance(delta, ThinkingDelta): + self.on_thinking_delta(delta.thinking) + return + if isinstance(delta, SignatureDelta): + self.on_signature_delta(delta.signature) + return + if isinstance(delta, CitationsDelta): + self.on_citations_delta(delta.citation) + return + LOGGER.debug("Unhandled content delta type: %s", delta.type) # type: ignore[unreachable] # pragma: no cover - All types are handled but we want to verify that + + def on_input_json_delta(self, partial_json: str) -> None: + """Handle InputJSONDelta.""" + if ( + self._current_tool_block is not None + and self._current_tool_block["name"] == self._output_tool + ): + self._content_details.citation_details[-1].length += len(partial_json) + self._buffer.append({"content": partial_json}) + else: + self._current_tool_args += partial_json + + def on_text_delta(self, text: str) -> None: + """Handle TextDelta.""" + if text: + self._content_details.citation_details[-1].length += len(text) + self._buffer.append({"content": text}) + + def on_thinking_delta(self, thinking: str) -> None: + """Handle ThinkingDelta.""" + if thinking: + self._buffer.append({"thinking_content": thinking}) + + def on_signature_delta(self, signature: str) -> None: + """Handle SignatureDelta.""" + self._content_details.thinking_signature = signature + + def on_citations_delta(self, citation: TextCitation) -> None: + """Handle CitationsDelta.""" + self._content_details.add_citation(citation) + + def on_content_block_stop_event(self, index: int) -> None: + """Handle RawContentBlockStopEvent.""" + if self._current_tool_block is not None: + if self._current_tool_block["name"] == self._output_tool: + self._current_tool_block = None + return + tool_args = ( + json.loads(self._current_tool_args) if self._current_tool_args else {} + ) + self._current_tool_block["input"] |= tool_args + self._buffer.append( + { "tool_calls": [ llm.ToolInput( - id=current_tool_block["id"], - tool_name=current_tool_block["name"], - tool_args=current_tool_block["input"], - external=current_tool_block["type"] == "server_tool_use", + id=self._current_tool_block["id"], + tool_name=self._current_tool_block["name"], + tool_args=self._current_tool_block["input"], + external=self._current_tool_block["type"] + == "server_tool_use", ) ] } - current_tool_block = None - elif isinstance(response, RawMessageDeltaEvent): - if (usage := response.usage) is not None: - chat_log.async_trace(_create_token_stats(input_usage, usage)) - content_details.container = response.delta.container - if response.delta.stop_reason == "refusal": - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="api_refusal" - ) - elif isinstance(response, RawMessageStopEvent): - if content_details: - content_details.delete_empty() - yield {"native": content_details} - content_details = ContentDetails() - content_details.add_citation_detail() - - -def _create_token_stats( - input_usage: Usage | None, response_usage: MessageDeltaUsage -) -> dict[str, Any]: - """Create token stats for conversation agent tracing.""" - input_tokens = 0 - cached_input_tokens = 0 - if input_usage: - input_tokens = input_usage.input_tokens - cached_input_tokens = input_usage.cache_creation_input_tokens or 0 - output_tokens = response_usage.output_tokens - return { - "stats": { - "input_tokens": input_tokens, - "cached_input_tokens": cached_input_tokens, - "output_tokens": output_tokens, + ) + self._current_tool_block = None + + def on_message_delta_event(self, delta: Delta, usage: MessageDeltaUsage) -> None: + """Handle RawMessageDeltaEvent.""" + self._chat_log.async_trace(self._create_token_stats(self._input_usage, usage)) + self._content_details.container = delta.container + if delta.stop_reason == "refusal": + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_refusal" + ) + + def on_message_stop_event(self) -> None: + """Handle RawMessageStopEvent.""" + if self._content_details: + self._content_details.delete_empty() + self._buffer.append({"native": self._content_details}) + self._content_details = ContentDetails() + self._content_details.add_citation_detail() + + def _create_token_stats( + self, input_usage: Usage | None, response_usage: MessageDeltaUsage + ) -> dict[str, Any]: + """Create token stats for conversation agent tracing.""" + input_tokens = 0 + cached_input_tokens = 0 + if input_usage: + input_tokens = input_usage.input_tokens + cached_input_tokens = input_usage.cache_creation_input_tokens or 0 + output_tokens = response_usage.output_tokens + return { + "stats": { + "input_tokens": input_tokens, + "cached_input_tokens": cached_input_tokens, + "output_tokens": output_tokens, + } } - } class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]): @@ -963,7 +1149,7 @@ async def _async_handle_chat_log( content async for content in chat_log.async_add_delta_content_stream( self.entity_id, - _transform_stream( + AnthropicDeltaStream( chat_log, stream, output_tool=structure_name or None, diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index ac0e92b37146b2..869148904fdb5c 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -155,7 +155,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DATA_COMPONENT] = storage_collection collection.DictStorageCollectionWebsocket( - storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS + storage_collection, + DOMAIN, + DOMAIN, + CREATE_FIELDS, + UPDATE_FIELDS, + admin_only=True, ).async_setup(hass) websocket_api.async_register_command(hass, handle_integration_list) @@ -341,6 +346,7 @@ async def handle_integration_list( vol.Required("config_entry_id"): str, } ) +@websocket_api.require_admin @websocket_api.async_response async def handle_config_entry( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] diff --git a/homeassistant/components/aquacell/entity.py b/homeassistant/components/aquacell/entity.py index 6c746ded24cb69..8d4fea5d39d09e 100644 --- a/homeassistant/components/aquacell/entity.py +++ b/homeassistant/components/aquacell/entity.py @@ -28,7 +28,7 @@ def __init__( self._attr_unique_id = f"{softener_key}-{entity_key}" self._attr_device_info = DeviceInfo( name=self.softener.name, - hw_version=self.softener.fwVersion, + hw_version=self.softener.diagnostics.fw_version, identifiers={(DOMAIN, str(softener_key))}, manufacturer=self.softener.brand, model=self.softener.ssn, diff --git a/homeassistant/components/aquacell/manifest.json b/homeassistant/components/aquacell/manifest.json index 2d8b80f4488c73..41dff9b9f6826d 100644 --- a/homeassistant/components/aquacell/manifest.json +++ b/homeassistant/components/aquacell/manifest.json @@ -8,5 +8,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["aioaquacell"], - "requirements": ["aioaquacell==0.2.0"] + "requirements": ["aioaquacell==1.0.0"] } diff --git a/homeassistant/components/aquacell/sensor.py b/homeassistant/components/aquacell/sensor.py index 58d3548284e3b6..0571736fdbe8bf 100644 --- a/homeassistant/components/aquacell/sensor.py +++ b/homeassistant/components/aquacell/sensor.py @@ -38,39 +38,39 @@ class SoftenerSensorEntityDescription(SensorEntityDescription): translation_key="salt_left_side_percentage", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda softener: softener.salt.leftPercent, + value_fn=lambda softener: softener.salt.left_percent, ), SoftenerSensorEntityDescription( key="salt_right_side_percentage", translation_key="salt_right_side_percentage", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda softener: softener.salt.rightPercent, + value_fn=lambda softener: softener.salt.right_percent, ), SoftenerSensorEntityDescription( key="salt_left_side_time_remaining", translation_key="salt_left_side_time_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.DAYS, - value_fn=lambda softener: softener.salt.leftDays, + value_fn=lambda softener: softener.salt.left_days, ), SoftenerSensorEntityDescription( key="salt_right_side_time_remaining", translation_key="salt_right_side_time_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.DAYS, - value_fn=lambda softener: softener.salt.rightDays, + value_fn=lambda softener: softener.salt.right_days, ), SoftenerSensorEntityDescription( key="battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda softener: softener.battery, + value_fn=lambda softener: softener.diagnostics.battery, ), SoftenerSensorEntityDescription( key="wi_fi_strength", translation_key="wi_fi_strength", - value_fn=lambda softener: softener.wifiLevel, + value_fn=lambda softener: softener.diagnostics.wifi_level, device_class=SensorDeviceClass.ENUM, options=[ "high", @@ -82,7 +82,7 @@ class SoftenerSensorEntityDescription(SensorEntityDescription): key="last_update", translation_key="last_update", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda softener: softener.lastUpdate, + value_fn=lambda softener: softener.diagnostics.last_update, ), ) diff --git a/homeassistant/components/arcam_fmj/sensor.py b/homeassistant/components/arcam_fmj/sensor.py index a415f92864a098..03dacd54045384 100644 --- a/homeassistant/components/arcam_fmj/sensor.py +++ b/homeassistant/components/arcam_fmj/sensor.py @@ -4,8 +4,9 @@ from collections.abc import Callable from dataclasses import dataclass +import logging -from arcam.fmj import IncomingVideoAspectRatio, IncomingVideoColorspace +from arcam.fmj import IncomingVideoAspectRatio, IncomingVideoColorspace, IntOrTypeEnum from arcam.fmj.state import IncomingAudioConfig, IncomingAudioFormat, State from homeassistant.components.sensor import ( @@ -21,6 +22,25 @@ from .coordinator import ArcamFmjConfigEntry from .entity import ArcamFmjEntity +_LOGGER = logging.getLogger(__name__) + + +def _enum_options(value: type[IntOrTypeEnum]) -> list[str]: + return [ + member.name.lower() for member in value if not member.name.startswith("CODE_") + ] + + +def _enum_value(value: IntOrTypeEnum | None) -> str | None: + if value is None: + return None + + if value.name.startswith("CODE_"): + _LOGGER.debug("Undefined enum value %s ignored", value) + return None + + return value.name.lower() + @dataclass(frozen=True, kw_only=True) class ArcamFmjSensorEntityDescription(SensorEntityDescription): @@ -75,9 +95,9 @@ class ArcamFmjSensorEntityDescription(SensorEntityDescription): translation_key="incoming_video_aspect_ratio", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, - options=[member.name.lower() for member in IncomingVideoAspectRatio], + options=_enum_options(IncomingVideoAspectRatio), value_fn=lambda state: ( - vp.aspect_ratio.name.lower() + _enum_value(vp.aspect_ratio) if (vp := state.get_incoming_video_parameters()) is not None else None ), @@ -87,11 +107,10 @@ class ArcamFmjSensorEntityDescription(SensorEntityDescription): translation_key="incoming_video_colorspace", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, - options=[member.name.lower() for member in IncomingVideoColorspace], + options=_enum_options(IncomingVideoColorspace), value_fn=lambda state: ( - vp.colorspace.name.lower() + _enum_value(vp.colorspace) if (vp := state.get_incoming_video_parameters()) is not None - and vp.colorspace is not None else None ), ), @@ -100,24 +119,16 @@ class ArcamFmjSensorEntityDescription(SensorEntityDescription): translation_key="incoming_audio_format", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, - options=[member.name.lower() for member in IncomingAudioFormat], - value_fn=lambda state: ( - result.name.lower() - if (result := state.get_incoming_audio_format()[0]) is not None - else None - ), + options=_enum_options(IncomingAudioFormat), + value_fn=lambda state: _enum_value(state.get_incoming_audio_format()[0]), ), ArcamFmjSensorEntityDescription( key="incoming_audio_config", translation_key="incoming_audio_config", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, - options=[member.name.lower() for member in IncomingAudioConfig], - value_fn=lambda state: ( - result.name.lower() - if (result := state.get_incoming_audio_format()[1]) is not None - else None - ), + options=_enum_options(IncomingAudioConfig), + value_fn=lambda state: _enum_value(state.get_incoming_audio_format()[1]), ), ArcamFmjSensorEntityDescription( key="incoming_audio_sample_rate", diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 62dcb8c1d80024..dfc40551da04b0 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -13,11 +13,12 @@ ) import voluptuous as vol +from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_CONTROL from homeassistant.components.http import StaticPathConfig from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, Unauthorized, UnknownUser from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -103,6 +104,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def handle_ask_question(call: ServiceCall) -> dict[str, Any]: """Handle a Show View service call.""" satellite_entity_id: str = call.data[ATTR_ENTITY_ID] + if call.context.user_id: + user = await hass.auth.async_get_user(call.context.user_id) + if user is None: + raise UnknownUser( + context=call.context, + permission=POLICY_CONTROL, + user_id=call.context.user_id, + ) + if not user.permissions.check_entity(satellite_entity_id, POLICY_CONTROL): + raise Unauthorized( + context=call.context, + permission=POLICY_CONTROL, + user_id=call.context.user_id, + perm_category=CAT_ENTITIES, + ) + satellite_entity: AssistSatelliteEntity | None = component.get_entity( satellite_entity_id ) diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py index 6f8b3d723ad7ba..18f512d2f21906 100644 --- a/homeassistant/components/assist_satellite/websocket_api.py +++ b/homeassistant/components/assist_satellite/websocket_api.py @@ -165,6 +165,7 @@ async def websocket_set_wake_words( vol.Required("entity_id"): cv.entity_domain(DOMAIN), } ) +@websocket_api.require_admin @websocket_api.async_response async def websocket_test_connection( hass: HomeAssistant, diff --git a/homeassistant/components/auth/mfa_setup_flow.py b/homeassistant/components/auth/mfa_setup_flow.py index 5b4a539b86f8b2..eb427dfc06798c 100644 --- a/homeassistant/components/auth/mfa_setup_flow.py +++ b/homeassistant/components/auth/mfa_setup_flow.py @@ -15,24 +15,6 @@ from homeassistant.helpers import config_validation as cv from homeassistant.util.hass_dict import HassKey -WS_TYPE_SETUP_MFA = "auth/setup_mfa" -SCHEMA_WS_SETUP_MFA = vol.All( - websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - { - vol.Required("type"): WS_TYPE_SETUP_MFA, - vol.Exclusive("mfa_module_id", "module_or_flow_id"): str, - vol.Exclusive("flow_id", "module_or_flow_id"): str, - vol.Optional("user_input"): object, - } - ), - cv.has_at_least_one_key("mfa_module_id", "flow_id"), -) - -WS_TYPE_DEPOSE_MFA = "auth/depose_mfa" -SCHEMA_WS_DEPOSE_MFA = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_DEPOSE_MFA, vol.Required("mfa_module_id"): str} -) - DATA_SETUP_FLOW_MGR: HassKey[MfaFlowManager] = HassKey("auth_mfa_setup_flow_manager") _LOGGER = logging.getLogger(__name__) @@ -73,16 +55,24 @@ def async_setup(hass: HomeAssistant) -> None: """Init mfa setup flow manager.""" hass.data[DATA_SETUP_FLOW_MGR] = MfaFlowManager(hass) - websocket_api.async_register_command( - hass, WS_TYPE_SETUP_MFA, websocket_setup_mfa, SCHEMA_WS_SETUP_MFA - ) - - websocket_api.async_register_command( - hass, WS_TYPE_DEPOSE_MFA, websocket_depose_mfa, SCHEMA_WS_DEPOSE_MFA - ) + websocket_api.async_register_command(hass, websocket_setup_mfa) + websocket_api.async_register_command(hass, websocket_depose_mfa) @callback +@websocket_api.websocket_command( + vol.All( + vol.Schema( + { + vol.Required("type"): "auth/setup_mfa", + vol.Exclusive("mfa_module_id", "module_or_flow_id"): str, + vol.Exclusive("flow_id", "module_or_flow_id"): str, + vol.Optional("user_input"): object, + } + ), + cv.has_at_least_one_key("mfa_module_id", "flow_id"), + ) +) @websocket_api.ws_require_user(allow_system_user=False) def websocket_setup_mfa( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] @@ -121,6 +111,9 @@ async def async_setup_flow(msg: dict[str, Any]) -> None: @callback +@websocket_api.websocket_command( + {vol.Required("type"): "auth/depose_mfa", vol.Required("mfa_module_id"): str} +) @websocket_api.ws_require_user(allow_system_user=False) def websocket_depose_mfa( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 086d106295daf1..9d2ec9f73816d0 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -4,10 +4,10 @@ from abc import ABC, abstractmethod import asyncio -from collections.abc import Callable, Mapping +from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any, Protocol, cast +from typing import Any, cast from propcache.api import cached_property import voluptuous as vol @@ -229,14 +229,11 @@ def is_disabled_experimental_trigger(hass: HomeAssistant, platform: str) -> bool ) -class IfAction(Protocol): +class IfAction(condition_helper.ConditionsChecker): """Define the format of if_action.""" config: list[ConfigType] - def __call__(self, variables: Mapping[str, Any] | None = None) -> bool: - """AND all conditions.""" - def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Return true if specified automation entity_id is on. @@ -835,7 +832,7 @@ async def async_trigger( if ( not skip_condition and self._condition is not None - and not self._condition(variables) + and not self._condition.async_check(variables=variables) ): self._logger.debug( "Conditions not met, aborting automation. Condition summary: %s", @@ -904,6 +901,9 @@ async def async_will_remove_from_hass(self) -> None: """Remove listeners when removing automation from Home Assistant.""" await super().async_will_remove_from_hass() await self._async_disable() + self.action_script.async_unload() + if self._condition is not None: + self._condition.async_unload() async def _async_enable_automation(self, event: Event) -> None: """Start automation on startup.""" @@ -1276,6 +1276,7 @@ async def _async_process_if( @websocket_api.websocket_command({"type": "automation/config", "entity_id": str}) +@websocket_api.require_admin def websocket_config( hass: HomeAssistant, connection: websocket_api.ActiveConnection, diff --git a/homeassistant/components/axis/hub/api.py b/homeassistant/components/axis/hub/api.py index 2bfce19bae5bba..0229a0157696da 100644 --- a/homeassistant/components/axis/hub/api.py +++ b/homeassistant/components/axis/hub/api.py @@ -36,6 +36,7 @@ async def get_axis_api( username=config[CONF_USERNAME], password=config[CONF_PASSWORD], web_proto=config.get(CONF_PROTOCOL, "http"), + websocket_enabled=True, ) ) diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index ed446f6c72ada1..ca90c2d0e03ab7 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -29,7 +29,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["axis"], - "requirements": ["axis==68"], + "requirements": ["axis==69"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/homeassistant/components/backup/services.py b/homeassistant/components/backup/services.py index 17448f7bb065c1..192a2f3c171464 100644 --- a/homeassistant/components/backup/services.py +++ b/homeassistant/components/backup/services.py @@ -2,6 +2,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers.hassio import is_hassio +from homeassistant.helpers.service import async_register_admin_service from .const import DATA_MANAGER, DOMAIN @@ -30,7 +31,9 @@ async def _async_handle_create_automatic_service(call: ServiceCall) -> None: def async_setup_services(hass: HomeAssistant) -> None: """Register services.""" if not is_hassio(hass): - hass.services.async_register(DOMAIN, "create", _async_handle_create_service) - hass.services.async_register( - DOMAIN, "create_automatic", _async_handle_create_automatic_service + async_register_admin_service( + hass, DOMAIN, "create", _async_handle_create_service + ) + async_register_admin_service( + hass, DOMAIN, "create_automatic", _async_handle_create_automatic_service ) diff --git a/homeassistant/components/bayesian/config_flow.py b/homeassistant/components/bayesian/config_flow.py index ce13cf43d8cade..c43305ef99882c 100644 --- a/homeassistant/components/bayesian/config_flow.py +++ b/homeassistant/components/bayesian/config_flow.py @@ -33,11 +33,13 @@ from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN from homeassistant.config_entries import ( + SOURCE_USER, ConfigEntry, ConfigFlowResult, ConfigSubentry, - ConfigSubentryData, ConfigSubentryFlow, + FlowType, + SubentryFlowContext, SubentryFlowResult, ) from homeassistant.const import ( @@ -62,7 +64,6 @@ from .binary_sensor import above_greater_than_below, no_overlapping from .const import ( - CONF_OBSERVATIONS, CONF_P_GIVEN_F, CONF_P_GIVEN_T, CONF_PRIOR, @@ -373,26 +374,6 @@ def _validate_observation_subentry( return user_input -async def _validate_subentry_from_config_entry( - handler: SchemaCommonFlowHandler, user_input: dict[str, Any] -) -> dict[str, Any]: - # Standard behavior is to merge the result with the options. - # In this case, we want to add a subentry so we update the options directly. - observations: list[dict[str, Any]] = handler.options.setdefault( - CONF_OBSERVATIONS, [] - ) - - if handler.parent_handler.cur_step is not None: - user_input[CONF_PLATFORM] = handler.parent_handler.cur_step["step_id"] - user_input = _validate_observation_subentry( - user_input[CONF_PLATFORM], - user_input, - other_subentries=handler.options[CONF_OBSERVATIONS], - ) - observations.append(user_input) - return {} - - async def _get_description_placeholders( handler: SchemaCommonFlowHandler, ) -> dict[str, str]: @@ -420,48 +401,12 @@ async def _get_description_placeholders( } -async def _get_observation_menu_options(handler: SchemaCommonFlowHandler) -> list[str]: - """Return the menu options for the observation selector.""" - options = [typ.value for typ in ObservationTypes] - if handler.options.get(CONF_OBSERVATIONS): - options.append("finish") - return options - - CONFIG_FLOW: dict[str, SchemaFlowMenuStep | SchemaFlowFormStep] = { str(USER): SchemaFlowFormStep( CONFIG_SCHEMA, validate_user_input=_validate_user, - next_step=str(OBSERVATION_SELECTOR), - description_placeholders=_get_description_placeholders, - ), - str(OBSERVATION_SELECTOR): SchemaFlowMenuStep( - _get_observation_menu_options, - ), - str(ObservationTypes.STATE): SchemaFlowFormStep( - STATE_SUBSCHEMA, - next_step=str(OBSERVATION_SELECTOR), - validate_user_input=_validate_subentry_from_config_entry, - # Prevent the name of the bayesian sensor from being used as the suggested - # name of the observations - suggested_values=None, - description_placeholders=_get_description_placeholders, - ), - str(ObservationTypes.NUMERIC_STATE): SchemaFlowFormStep( - NUMERIC_STATE_SUBSCHEMA, - next_step=str(OBSERVATION_SELECTOR), - validate_user_input=_validate_subentry_from_config_entry, - suggested_values=None, description_placeholders=_get_description_placeholders, - ), - str(ObservationTypes.TEMPLATE): SchemaFlowFormStep( - TEMPLATE_SUBSCHEMA, - next_step=str(OBSERVATION_SELECTOR), - validate_user_input=_validate_subentry_from_config_entry, - suggested_values=None, - description_placeholders=_get_description_placeholders, - ), - "finish": SchemaFlowFormStep(), + ) } @@ -497,27 +442,17 @@ def async_config_entry_title(self, options: Mapping[str, str]) -> str: name: str = options[CONF_NAME] return name - @callback - def async_create_entry( - self, - data: Mapping[str, Any], - **kwargs: Any, - ) -> ConfigFlowResult: - """Finish config flow and create a config entry.""" - data = dict(data) - observations = data.pop(CONF_OBSERVATIONS) - subentries: list[ConfigSubentryData] = [ - ConfigSubentryData( - data=observation, - title=observation[CONF_NAME], - subentry_type="observation", - unique_id=None, - ) - for observation in observations - ] - - self.async_config_flow_finished(data) - return super().async_create_entry(data=data, subentries=subentries, **kwargs) + async def async_on_create_entry(self, result: ConfigFlowResult) -> ConfigFlowResult: + """Start subentry flow when config entry has been created.""" + subentry_result = await self.hass.config_entries.subentries.async_init( + (result["result"].entry_id, "observation"), + context=SubentryFlowContext(source=SOURCE_USER), + ) + result["next_flow"] = ( + FlowType.CONFIG_SUBENTRIES_FLOW, + subentry_result["flow_id"], + ) + return result class ObservationSubentryFlowHandler(ConfigSubentryFlow): diff --git a/homeassistant/components/blink/icons.json b/homeassistant/components/blink/icons.json index 1f4edb07f42ffc..44f9c4561c17a3 100644 --- a/homeassistant/components/blink/icons.json +++ b/homeassistant/components/blink/icons.json @@ -21,9 +21,6 @@ "save_video": { "service": "mdi:file-video" }, - "send_pin": { - "service": "mdi:two-factor-authentication" - }, "trigger_camera": { "service": "mdi:image-refresh" } diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index 3882aa67312b4e..5839dc8914cbf1 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -5,15 +5,9 @@ import voluptuous as vol from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN -from homeassistant.const import ( - ATTR_CONFIG_ENTRY_ID, - CONF_FILE_PATH, - CONF_FILENAME, - CONF_PIN, -) -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, issue_registry as ir, service +from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, service from .const import DOMAIN @@ -23,50 +17,10 @@ SERVICE_SAVE_RECENT_CLIPS = "save_recent_clips" -# Deprecated -SERVICE_SEND_PIN = "send_pin" -SERVICE_SEND_PIN_SCHEMA = vol.Schema( - { - vol.Required(ATTR_CONFIG_ENTRY_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_PIN): cv.string, - } -) - - -async def _send_pin(call: ServiceCall) -> None: - """Call blink to send new pin.""" - # Create repair issue to inform user about service removal - ir.async_create_issue( - call.hass, - DOMAIN, - "service_send_pin_deprecation", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.ERROR, - breaks_in_ha_version="2026.5.0", - translation_key="service_send_pin_deprecation", - translation_placeholders={"service_name": f"{DOMAIN}.{SERVICE_SEND_PIN}"}, - ) - - # Service has been removed - raise exception - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_removed", - translation_placeholders={"service_name": f"{DOMAIN}.{SERVICE_SEND_PIN}"}, - ) - - @callback def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for the Blink integration.""" - hass.services.async_register( - DOMAIN, - SERVICE_SEND_PIN, - _send_pin, - schema=SERVICE_SEND_PIN_SCHEMA, - ) - service.async_register_platform_entity_service( hass, DOMAIN, diff --git a/homeassistant/components/blink/services.yaml b/homeassistant/components/blink/services.yaml index 244763d5535af2..82aeafb8ede30e 100644 --- a/homeassistant/components/blink/services.yaml +++ b/homeassistant/components/blink/services.yaml @@ -35,15 +35,3 @@ save_recent_clips: example: "/tmp" selector: text: - -send_pin: - fields: - config_entry_id: - required: true - selector: - config_entry: - integration: blink - pin: - example: "abc123" - selector: - text: diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index cdd30483b5084f..7bf075f18c9d31 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -82,9 +82,6 @@ }, "not_loaded": { "message": "{target} is not loaded." - }, - "service_removed": { - "message": "The service {service_name} has been removed and is no longer needed. Home Assistant will automatically prompt for reauthentication when required." } }, "issues": { @@ -98,10 +95,6 @@ } }, "title": "Blink update service is being removed" - }, - "service_send_pin_deprecation": { - "description": "The service {service_name} has been removed and is no longer needed. When a new two-factor authentication code is required, Home Assistant will automatically prompt you to reauthenticate through the integration configuration. Please remove any automations or scripts that call this service.", - "title": "Blink send PIN service has been removed" } }, "options": { @@ -140,20 +133,6 @@ }, "name": "Save video" }, - "send_pin": { - "description": "Sends a new PIN to Blink for 2FA.", - "fields": { - "config_entry_id": { - "description": "The Blink integration ID.", - "name": "Integration ID" - }, - "pin": { - "description": "PIN received from Blink. Leave empty if you only received a verification email.", - "name": "PIN" - } - }, - "name": "Send PIN" - }, "trigger_camera": { "description": "Requests camera to take new image.", "name": "Trigger camera" diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 941a7822439e01..84f02b7859ff70 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -58,6 +58,7 @@ async_address_present, async_ble_device_from_address, async_clear_address_from_match_history, + async_clear_advertisement_history, async_current_scanners, async_discovered_service_info, async_get_advertisement_callback, @@ -116,6 +117,7 @@ "async_address_present", "async_ble_device_from_address", "async_clear_address_from_match_history", + "async_clear_advertisement_history", "async_current_scanners", "async_discovered_service_info", "async_get_advertisement_callback", diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index c0ec6acf0a5233..7c48bdedb3e71e 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -207,6 +207,19 @@ def async_clear_address_from_match_history(hass: HomeAssistant, address: str) -> _get_manager(hass).async_clear_address_from_match_history(address) +@hass_callback +def async_clear_advertisement_history(hass: HomeAssistant, address: str) -> None: + """Clear cached advertisement history for a device. + + Causes the next advertisement from this address to be treated as new + data, bypassing the change-detection guard in the Bluetooth manager. + Intended for devices that emit static advertisements as a wake-up + signal, for example, devices that require an active GATT connection + to read sensor data and whose advertisement payload never changes. + """ + _get_manager(hass).async_clear_advertisement_history(address) + + @hass_callback def async_register_scanner( hass: HomeAssistant, diff --git a/homeassistant/components/broadlink/const.py b/homeassistant/components/broadlink/const.py index 602a3693b7b355..e5865168fdf30b 100644 --- a/homeassistant/components/broadlink/const.py +++ b/homeassistant/components/broadlink/const.py @@ -7,6 +7,7 @@ DOMAINS_AND_TYPES = { Platform.CLIMATE: {"HYS"}, Platform.LIGHT: {"LB1", "LB2"}, + Platform.RADIO_FREQUENCY: {"RM4PRO", "RMPRO"}, Platform.REMOTE: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"}, Platform.SELECT: {"HYS"}, Platform.SENSOR: { diff --git a/homeassistant/components/broadlink/radio_frequency.py b/homeassistant/components/broadlink/radio_frequency.py new file mode 100644 index 00000000000000..31b83d5dcfb28b --- /dev/null +++ b/homeassistant/components/broadlink/radio_frequency.py @@ -0,0 +1,132 @@ +"""Radio Frequency platform for Broadlink.""" + +from __future__ import annotations + +import logging + +from broadlink.exceptions import BroadlinkException +from rf_protocols import RadioFrequencyCommand + +from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .device import BroadlinkDevice +from .entity import BroadlinkEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + +_TICK_US = 32.84 + +_RF_433_TYPE_BYTE = 0xB2 +_RF_315_TYPE_BYTE = 0xB4 + +_RF_433_RANGE = (433_050_000, 434_790_000) +_RF_315_RANGE = (314_950_000, 315_250_000) + +SUPPORTED_FREQUENCY_RANGES: list[tuple[int, int]] = [_RF_433_RANGE, _RF_315_RANGE] + + +def _type_byte_for_frequency(frequency: int) -> int: + """Return the Broadlink RF type byte for a given carrier frequency.""" + if _RF_433_RANGE[0] <= frequency <= _RF_433_RANGE[1]: + return _RF_433_TYPE_BYTE + if _RF_315_RANGE[0] <= frequency <= _RF_315_RANGE[1]: + return _RF_315_TYPE_BYTE + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="frequency_not_supported", + translation_placeholders={"frequency": f"{frequency / 1_000_000:g}"}, + ) + + +def encode_rf_packet( + *, + type_byte: int, + repeat_count: int, + timings_us: list[int], +) -> bytes: + """Encode raw OOK timings as a Broadlink RF pulse-length packet. + + The layout is:: + + byte 0 type byte (0xB2 for 433 MHz, 0xB4 for 315 MHz) + byte 1 repeat count (additional transmissions after the first) + bytes 2..3 payload length (little-endian), counted from byte 4 + bytes 4..N-1 pulses: 1 byte when ticks < 256, otherwise + 0x00 followed by a 2-byte big-endian tick count + + Each pulse is expressed as multiples of 32.84 µs ticks, which is the + timing resolution of the Broadlink RF front-end. + """ + buf = bytearray([type_byte, repeat_count, 0, 0]) + for duration in timings_us: + ticks = round(abs(duration) / _TICK_US) + div, mod = divmod(ticks, 256) + if div: + buf.append(0x00) + buf.append(div) + buf.append(mod) + payload_len = len(buf) - 4 + buf[2] = payload_len & 0xFF + buf[3] = (payload_len >> 8) & 0xFF + return bytes(buf) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up a Broadlink radio frequency transmitter.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data + device: BroadlinkDevice = hass.data[DOMAIN].devices[config_entry.entry_id] + async_add_entities([BroadlinkRadioFrequency(device)]) + + +class BroadlinkRadioFrequency(BroadlinkEntity, RadioFrequencyTransmitterEntity): + """Representation of a Broadlink RF transmitter.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, device: BroadlinkDevice) -> None: + """Initialize the entity.""" + super().__init__(device) + self._attr_unique_id = device.unique_id + + @property + def supported_frequency_ranges(self) -> list[tuple[int, int]]: + """Return the Broadlink-supported narrow RF bands.""" + return SUPPORTED_FREQUENCY_RANGES + + async def async_send_command(self, command: RadioFrequencyCommand) -> None: + """Encode an OOK command and transmit it via the Broadlink device.""" + type_byte = _type_byte_for_frequency(command.frequency) + packet = encode_rf_packet( + type_byte=type_byte, + repeat_count=command.repeat_count, + timings_us=command.get_raw_timings(), + ) + _LOGGER.debug( + "Transmitting RF packet: %d bytes on %d Hz (repeat=%d)", + len(packet), + command.frequency, + command.repeat_count, + ) + + device = self._device + try: + await device.async_request(device.api.send_data, packet) + except (BroadlinkException, OSError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="transmit_failed", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/broadlink/strings.json b/homeassistant/components/broadlink/strings.json index a019f350ec066d..d5d91be6b0e788 100644 --- a/homeassistant/components/broadlink/strings.json +++ b/homeassistant/components/broadlink/strings.json @@ -77,5 +77,13 @@ "name": "Total consumption" } } + }, + "exceptions": { + "frequency_not_supported": { + "message": "Broadlink devices cannot transmit on {frequency} MHz" + }, + "transmit_failed": { + "message": "Failed to transmit RF command: {error}" + } } } diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 75ba2838599ad0..9af145cfef8f4b 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -15,7 +15,10 @@ from dateutil.rrule import rrulestr import voluptuous as vol +from homeassistant.auth.models import User +from homeassistant.auth.permissions.const import POLICY_CONTROL, POLICY_READ from homeassistant.components import frontend, http, websocket_api +from homeassistant.components.http import KEY_HASS_USER from homeassistant.components.websocket_api import ( ERR_INVALID_FORMAT, ERR_NOT_FOUND, @@ -32,7 +35,7 @@ SupportsResponse, callback, ) -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.entity import Entity, EntityDescription @@ -786,6 +789,10 @@ def __init__(self, component: EntityComponent[CalendarEntity]) -> None: async def get(self, request: web.Request, entity_id: str) -> web.Response: """Return calendar events.""" + user: User = request[KEY_HASS_USER] + if not user.permissions.check_entity(entity_id, POLICY_READ): + raise Unauthorized(entity_id=entity_id) + if not (entity := self.component.get_entity(entity_id)) or not isinstance( entity, CalendarEntity ): @@ -837,10 +844,14 @@ def __init__(self, component: EntityComponent[CalendarEntity]) -> None: async def get(self, request: web.Request) -> web.Response: """Retrieve calendar list.""" + user: User = request[KEY_HASS_USER] hass = request.app[http.KEY_HASS] + entity_perm = user.permissions.check_entity calendar_list: list[dict[str, str]] = [] for entity in self.component.entities: + if not entity_perm(entity.entity_id, POLICY_READ): + continue state = hass.states.get(entity.entity_id) assert state calendar_list.append({"name": state.name, "entity_id": entity.entity_id}) @@ -860,6 +871,9 @@ async def handle_calendar_event_create( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle creation of a calendar event.""" + if not connection.user.permissions.check_entity(msg["entity_id"], POLICY_CONTROL): + raise Unauthorized(entity_id=msg["entity_id"]) + if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])): connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") return @@ -899,6 +913,8 @@ async def handle_calendar_event_delete( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle delete of a calendar event.""" + if not connection.user.permissions.check_entity(msg["entity_id"], POLICY_CONTROL): + raise Unauthorized(entity_id=msg["entity_id"]) if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])): connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") @@ -944,7 +960,10 @@ async def handle_calendar_event_delete( async def handle_calendar_event_update( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: - """Handle creation of a calendar event.""" + """Handle update of a calendar event.""" + if not connection.user.permissions.check_entity(msg["entity_id"], POLICY_CONTROL): + raise Unauthorized(entity_id=msg["entity_id"]) + if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])): connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") return @@ -989,6 +1008,9 @@ async def handle_calendar_event_subscribe( """Subscribe to calendar event updates.""" entity_id: str = msg["entity_id"] + if not connection.user.permissions.check_entity(entity_id, POLICY_READ): + raise Unauthorized(entity_id=entity_id) + if not (entity := hass.data[DATA_COMPONENT].get_entity(entity_id)): connection.send_error( msg["id"], diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 5b7cc97427141d..be9ff1a9e11ede 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -926,6 +926,7 @@ async def websocket_get_prefs( vol.Optional(PREF_ORIENTATION): vol.Coerce(Orientation), } ) +@websocket_api.require_admin @websocket_api.async_response async def websocket_update_prefs( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index b1c3bebcaaefa1..51bc0bd4e39f06 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -374,6 +374,7 @@ async def async_webhook_message(self, payload: dict[Any, Any]) -> dict[Any, Any] method=payload["method"], query_string=payload["query"], mock_source=DOMAIN, + remote=None, # Remote will be used for the local_only check, but since this is from the cloud we want it to be None to mark it as non-local and bypass the ip parsing and remote checks ) response = await webhook.async_handle_webhook( diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 53ed41d5b6d816..ccabc63092ac7e 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -615,6 +615,7 @@ def get_domain_table_markdown(domain_info: dict[str, Any]) -> str: return markdown + @require_admin async def get(self, request: web.Request) -> web.Response: """Download support package file.""" @@ -709,6 +710,7 @@ def with_cloud_auth( return with_cloud_auth +@websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command({vol.Required("type"): "cloud/subscription"}) @websocket_api.async_response @@ -750,6 +752,7 @@ def validate_language_voice(value: tuple[str, str]) -> tuple[str, str]: return value +@websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command( { @@ -809,6 +812,7 @@ async def websocket_update_prefs( connection.send_message(websocket_api.result_message(msg["id"])) +@websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command( { @@ -829,6 +833,7 @@ async def websocket_hook_create( connection.send_message(websocket_api.result_message(msg["id"], hook)) +@websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command( { diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index f776cf6b3ee76a..ee4ac563a48b7d 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aiocomelit"], "quality_scale": "platinum", - "requirements": ["aiocomelit==2.0.2"] + "requirements": ["aiocomelit==2.0.3"] } diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py index 1b3fa71d7ea848..d1bc96397363cb 100644 --- a/homeassistant/components/config/auth.py +++ b/homeassistant/components/config/auth.py @@ -10,32 +10,19 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback -WS_TYPE_LIST = "config/auth/list" -SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_LIST} -) - -WS_TYPE_DELETE = "config/auth/delete" -SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_DELETE, vol.Required("user_id"): str} -) - @callback def async_setup(hass: HomeAssistant) -> bool: """Enable the Home Assistant views.""" - websocket_api.async_register_command( - hass, WS_TYPE_LIST, websocket_list, SCHEMA_WS_LIST - ) - websocket_api.async_register_command( - hass, WS_TYPE_DELETE, websocket_delete, SCHEMA_WS_DELETE - ) + websocket_api.async_register_command(hass, websocket_list) + websocket_api.async_register_command(hass, websocket_delete) websocket_api.async_register_command(hass, websocket_create) websocket_api.async_register_command(hass, websocket_update) return True @websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "config/auth/list"}) @websocket_api.async_response async def websocket_list( hass: HomeAssistant, @@ -49,6 +36,9 @@ async def websocket_list( @websocket_api.require_admin +@websocket_api.websocket_command( + {vol.Required("type"): "config/auth/delete", vol.Required("user_id"): str} +) @websocket_api.async_response async def websocket_delete( hass: HomeAssistant, diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py index ce383eec1c1862..063fce654fafab 100644 --- a/homeassistant/components/configurator/__init__.py +++ b/homeassistant/components/configurator/__init__.py @@ -14,6 +14,8 @@ import functools as ft from typing import Any +import voluptuous as vol + from homeassistant.const import ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME from homeassistant.core import ( HassJob, @@ -24,6 +26,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import run_callback_threadsafe @@ -149,8 +152,12 @@ def __init__(self, hass: HomeAssistant) -> None: self._requests: dict[ str, tuple[str, list[dict[str, str]], ConfiguratorCallback | None] ] = {} - hass.services.async_register( - DOMAIN, SERVICE_CONFIGURE, self.async_handle_service_call + async_register_admin_service( + hass, + DOMAIN, + SERVICE_CONFIGURE, + self.async_handle_service_call, + schema=vol.Schema({}, extra=vol.ALLOW_EXTRA), ) @async_callback diff --git a/homeassistant/components/cover/condition.py b/homeassistant/components/cover/condition.py index f44ad6582cbbfd..051e6a7c5d017b 100644 --- a/homeassistant/components/cover/condition.py +++ b/homeassistant/components/cover/condition.py @@ -4,7 +4,11 @@ from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.condition import Condition, EntityConditionBase +from homeassistant.helpers.condition import ( + ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL_FOR, + Condition, + EntityConditionBase, +) from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass from .models import CoverDomainSpec @@ -14,6 +18,7 @@ class CoverConditionBase(EntityConditionBase): """Base condition for cover state checks.""" _domain_specs: Mapping[str, CoverDomainSpec] + _schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL_FOR def is_valid_state(self, entity_state: State) -> bool: """Check if the state matches the expected cover state.""" diff --git a/homeassistant/components/cover/conditions.yaml b/homeassistant/components/cover/conditions.yaml index 075f3a926bc547..ac21f4b137093c 100644 --- a/homeassistant/components/cover/conditions.yaml +++ b/homeassistant/components/cover/conditions.yaml @@ -8,6 +8,11 @@ options: - all - any + for: + required: true + default: 00:00:00 + selector: + duration: awning_is_closed: fields: *condition_common_fields diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index 184a94fa67b797..cf6f664f3e9d14 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -1,6 +1,7 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "trigger_behavior_name": "Trigger when", "trigger_for_name": "For at least" }, @@ -10,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Awning is closed" @@ -19,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Awning is open" @@ -28,6 +35,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Blind is closed" @@ -37,6 +47,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Blind is open" @@ -46,6 +59,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Curtain is closed" @@ -55,6 +71,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Curtain is open" @@ -64,6 +83,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Shade is closed" @@ -73,6 +95,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Shade is open" @@ -82,6 +107,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Shutter is closed" @@ -91,6 +119,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Shutter is open" diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index b3c900c07c4b13..95f81f9a8a75fa 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -11,6 +11,7 @@ device_registry as dr, entity_registry as er, ) +from homeassistant.helpers.service import async_register_admin_service from homeassistant.util.read_only_dict import ReadOnlyDict from .const import CONF_BRIDGE_ID, DOMAIN, LOGGER @@ -98,7 +99,8 @@ async def async_call_deconz_service(service_call: ServiceCall) -> None: await async_remove_orphaned_entries_service(hub) for service in SUPPORTED_SERVICES: - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, service, async_call_deconz_service, diff --git a/homeassistant/components/decora_wifi/__init__.py b/homeassistant/components/decora_wifi/__init__.py index e6f9a1e2b0d506..e30efc67c885ad 100644 --- a/homeassistant/components/decora_wifi/__init__.py +++ b/homeassistant/components/decora_wifi/__init__.py @@ -19,7 +19,7 @@ Platform, ) from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady PLATFORMS = [Platform.LIGHT] @@ -40,7 +40,7 @@ def _login_and_get_switches(email: str, password: str) -> DecoraWifiData: success = session.login(email, password) if success is None: - raise ConfigEntryAuthFailed("Invalid credentials for myLeviton account") + raise ConfigEntryError("Invalid credentials for myLeviton account") perms = session.user.get_residential_permissions() all_switches: list[IotSwitch] = [] diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 8515b54295a1dc..974dcf6c9b4771 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -187,6 +187,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): _attr_translation_key = "derivative" _attr_should_poll = False + _attr_state_class = SensorStateClass.MEASUREMENT def __init__( self, diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index a19f3c888e5dac..26a6441e4ba868 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -245,6 +245,7 @@ class DownloadDiagnosticsView(http.HomeAssistantView): extra_urls = ["/api/diagnostics/{d_type}/{d_id}/{sub_type}/{sub_id}"] name = "api:diagnostics" + @http.require_admin async def get( self, request: web.Request, diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py index 52d27e02c269c4..a2de27131c3782 100644 --- a/homeassistant/components/dnsip/__init__.py +++ b/homeassistant/components/dnsip/__init__.py @@ -30,12 +30,10 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return False if config_entry.version < 2 and config_entry.minor_version < 2: - version = config_entry.version - minor_version = config_entry.minor_version _LOGGER.debug( "Migrating configuration from version %s.%s", - version, - minor_version, + config_entry.version, + config_entry.minor_version, ) new_options = {**config_entry.options} @@ -46,10 +44,19 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry, options=new_options, minor_version=2 ) + _LOGGER.debug("Migration to configuration version %s.%s successful", 1, 2) + + if config_entry.version < 2 and config_entry.minor_version < 3: _LOGGER.debug( - "Migration to configuration version %s.%s successful", - 1, - 2, + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + hass.config_entries.async_update_entry( + config_entry, unique_id=None, minor_version=3 ) + _LOGGER.debug("Migration to configuration version %s.%s successful", 1, 3) + return True diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py index 24c42408fd2581..1e83c7743f24d5 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -93,7 +93,7 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for dnsip integration.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 @staticmethod @callback @@ -133,10 +133,7 @@ async def async_step_user( ): errors["base"] = "invalid_hostname" else: - # Uses hostname as unique ID, which is no longer allowed - # pylint: disable-next=hass-unique-id-ip-based - await self.async_set_unique_id(hostname) - self._abort_if_unique_id_configured() + self._async_abort_entries_match({CONF_HOSTNAME: hostname}) return self.async_create_entry( title=name, diff --git a/homeassistant/components/door/conditions.yaml b/homeassistant/components/door/conditions.yaml index ed1c3d79ec5da4..242b8397f9afaf 100644 --- a/homeassistant/components/door/conditions.yaml +++ b/homeassistant/components/door/conditions.yaml @@ -8,6 +8,11 @@ options: - all - any + for: + required: true + default: 00:00:00 + selector: + duration: is_closed: fields: *condition_common_fields diff --git a/homeassistant/components/door/strings.json b/homeassistant/components/door/strings.json index 47b23ee4f20d67..e4e68a2c672a07 100644 --- a/homeassistant/components/door/strings.json +++ b/homeassistant/components/door/strings.json @@ -1,6 +1,7 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "trigger_behavior_name": "Trigger when", "trigger_for_name": "For at least" }, @@ -10,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::door::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::door::common::condition_for_name%]" } }, "name": "Door is closed" @@ -19,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::door::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::door::common::condition_for_name%]" } }, "name": "Door is open" diff --git a/homeassistant/components/duco/__init__.py b/homeassistant/components/duco/__init__.py index 39975c0163ece2..dbec4d061bb3fa 100644 --- a/homeassistant/components/duco/__init__.py +++ b/homeassistant/components/duco/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from duco import DucoClient +from duco import DucoClient, build_ssl_context from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant @@ -14,9 +14,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool: """Set up Duco from a config entry.""" + ssl_context = await hass.async_add_executor_job(build_ssl_context) client = DucoClient( session=async_get_clientsession(hass), host=entry.data[CONF_HOST], + ssl_context=ssl_context, ) coordinator = DucoCoordinator(hass, entry, client) diff --git a/homeassistant/components/duco/config_flow.py b/homeassistant/components/duco/config_flow.py index 036ba4ca98e386..d16371981c5f16 100644 --- a/homeassistant/components/duco/config_flow.py +++ b/homeassistant/components/duco/config_flow.py @@ -5,7 +5,7 @@ import logging from typing import Any -from duco import DucoClient +from duco import DucoClient, build_ssl_context from duco.exceptions import DucoConnectionError, DucoError import voluptuous as vol @@ -160,9 +160,11 @@ async def _validate_input(self, host: str) -> tuple[str, str]: Returns a tuple of (box_name, mac_address). """ + ssl_context = await self.hass.async_add_executor_job(build_ssl_context) client = DucoClient( session=async_get_clientsession(self.hass), host=host, + ssl_context=ssl_context, ) board_info = await client.async_get_board_info() lan_info = await client.async_get_lan_info() diff --git a/homeassistant/components/duco/diagnostics.py b/homeassistant/components/duco/diagnostics.py index 78a23db7a9cfb2..e21079984ed9c7 100644 --- a/homeassistant/components/duco/diagnostics.py +++ b/homeassistant/components/duco/diagnostics.py @@ -2,14 +2,17 @@ from __future__ import annotations -import asyncio from dataclasses import asdict from typing import Any +from duco.exceptions import DucoConnectionError + from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from .const import DOMAIN from .coordinator import DucoConfigEntry TO_REDACT = { @@ -32,11 +35,15 @@ async def async_get_config_entry_diagnostics( board = asdict(coordinator.board_info) board.pop("time") - lan_info, duco_diags, write_remaining = await asyncio.gather( - coordinator.client.async_get_lan_info(), - coordinator.client.async_get_diagnostics(), - coordinator.client.async_get_write_req_remaining(), - ) + try: + lan_info = await coordinator.client.async_get_lan_info() + duco_diags = await coordinator.client.async_get_diagnostics() + write_remaining = await coordinator.client.async_get_write_req_remaining() + except DucoConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err return async_redact_data( { diff --git a/homeassistant/components/duco/fan.py b/homeassistant/components/duco/fan.py index 77adbe432e2f68..8b590ac28f0b9b 100644 --- a/homeassistant/components/duco/fan.py +++ b/homeassistant/components/duco/fan.py @@ -35,7 +35,7 @@ # again always round-trips to the same Duco state. _SPEED_LEVEL_PERCENTAGES: list[int] = [ (i + 1) * 100 // len(ORDERED_NAMED_FAN_SPEEDS) - for i in range(len(ORDERED_NAMED_FAN_SPEEDS)) + for i, _ in enumerate(ORDERED_NAMED_FAN_SPEEDS) ] # Maps every active Duco state (including timed MAN variants) to its diff --git a/homeassistant/components/duco/manifest.json b/homeassistant/components/duco/manifest.json index 4936b2d3abf10d..10f14e43807859 100644 --- a/homeassistant/components/duco/manifest.json +++ b/homeassistant/components/duco/manifest.json @@ -13,7 +13,7 @@ "iot_class": "local_polling", "loggers": ["duco"], "quality_scale": "platinum", - "requirements": ["python-duco-client==0.3.6"], + "requirements": ["python-duco-client==0.3.9"], "zeroconf": [ { "name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*", diff --git a/homeassistant/components/duco/sensor.py b/homeassistant/components/duco/sensor.py index 35206cdf386369..a08ba23ddcd341 100644 --- a/homeassistant/components/duco/sensor.py +++ b/homeassistant/components/duco/sensor.py @@ -19,6 +19,7 @@ PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr @@ -59,6 +60,25 @@ class DucoBoxSensorEntityDescription(SensorEntityDescription): ), node_types=(NodeType.BOX,), ), + DucoSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda node: node.sensor.temp if node.sensor else None, + node_types=(NodeType.UCCO2, NodeType.BSRH, NodeType.UCRH), + ), + DucoSensorEntityDescription( + key="box_temperature", + translation_key="box_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda node: node.sensor.temp if node.sensor else None, + node_types=(NodeType.BOX,), + ), DucoSensorEntityDescription( key="co2", device_class=SensorDeviceClass.CO2, diff --git a/homeassistant/components/duco/strings.json b/homeassistant/components/duco/strings.json index de81a2568136cc..da70af200e10ef 100644 --- a/homeassistant/components/duco/strings.json +++ b/homeassistant/components/duco/strings.json @@ -47,6 +47,9 @@ } }, "sensor": { + "box_temperature": { + "name": "Box temperature" + }, "iaq_co2": { "name": "CO2 air quality index" }, @@ -84,6 +87,9 @@ "cannot_connect": { "message": "An error occurred while trying to connect to the Duco instance: {error}" }, + "connection_error": { + "message": "Could not connect to the Duco device." + }, "failed_to_set_state": { "message": "Failed to set ventilation state: {error}" }, diff --git a/homeassistant/components/easyenergy/diagnostics.py b/homeassistant/components/easyenergy/diagnostics.py index 64f30ba61fdacb..55a3614e495ca5 100644 --- a/homeassistant/components/easyenergy/diagnostics.py +++ b/homeassistant/components/easyenergy/diagnostics.py @@ -6,6 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from .coordinator import EasyEnergyConfigEntry, EasyEnergyData @@ -23,9 +24,7 @@ def get_gas_price(data: EasyEnergyData, hours: int) -> float | None: """ if not data.gas_today: return None - return data.gas_today.price_at_time( - data.gas_today.utcnow() + timedelta(hours=hours) - ) + return data.gas_today.price_at_time(dt_util.utcnow() + timedelta(hours=hours)) async def async_get_config_entry_diagnostics( @@ -40,21 +39,21 @@ async def async_get_config_entry_diagnostics( "title": entry.title, }, "energy_usage": { - "current_hour_price": energy_today.current_usage_price, + "current_hour_price": energy_today.current_price, "next_hour_price": energy_today.price_at_time( - energy_today.utcnow() + timedelta(hours=1) + dt_util.utcnow() + timedelta(hours=1) ), - "average_price": energy_today.average_usage_price, - "max_price": energy_today.extreme_usage_prices[1], - "min_price": energy_today.extreme_usage_prices[0], - "highest_price_time": energy_today.highest_usage_price_time, - "lowest_price_time": energy_today.lowest_usage_price_time, - "percentage_of_max": energy_today.pct_of_max_usage, + "average_price": energy_today.average_price, + "max_price": energy_today.extreme_prices[1], + "min_price": energy_today.extreme_prices[0], + "highest_price_time": energy_today.highest_price_time, + "lowest_price_time": energy_today.lowest_price_time, + "percentage_of_max": energy_today.pct_of_max, }, "energy_return": { "current_hour_price": energy_today.current_return_price, - "next_hour_price": energy_today.price_at_time( - energy_today.utcnow() + timedelta(hours=1), "return" + "next_hour_price": energy_today.return_price_at_time( + dt_util.utcnow() + timedelta(hours=1) ), "average_price": energy_today.average_return_price, "max_price": energy_today.extreme_return_prices[1], diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index c987e75e7180ff..7c0c00f7607528 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["easyenergy==2.2.0"], + "requirements": ["easyenergy==3.0.0"], "single_config_entry": true } diff --git a/homeassistant/components/easyenergy/sensor.py b/homeassistant/components/easyenergy/sensor.py index 35fab870af381f..c0638344cb656b 100644 --- a/homeassistant/components/easyenergy/sensor.py +++ b/homeassistant/components/easyenergy/sensor.py @@ -24,6 +24,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .const import DOMAIN, SERVICE_TYPE_DEVICE_NAMES from .coordinator import ( @@ -63,7 +64,7 @@ class EasyEnergySensorEntityDescription(SensorEntityDescription): service_type="today_energy_usage", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", - value_fn=lambda data: data.energy_today.current_usage_price, + value_fn=lambda data: data.energy_today.current_price, ), EasyEnergySensorEntityDescription( key="next_hour_price", @@ -71,7 +72,7 @@ class EasyEnergySensorEntityDescription(SensorEntityDescription): service_type="today_energy_usage", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.price_at_time( - data.energy_today.utcnow() + timedelta(hours=1) + dt_util.utcnow() + timedelta(hours=1) ), ), EasyEnergySensorEntityDescription( @@ -79,42 +80,42 @@ class EasyEnergySensorEntityDescription(SensorEntityDescription): translation_key="average_price", service_type="today_energy_usage", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", - value_fn=lambda data: data.energy_today.average_usage_price, + value_fn=lambda data: data.energy_today.average_price, ), EasyEnergySensorEntityDescription( key="max_price", translation_key="max_price", service_type="today_energy_usage", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", - value_fn=lambda data: data.energy_today.extreme_usage_prices[1], + value_fn=lambda data: data.energy_today.extreme_prices[1], ), EasyEnergySensorEntityDescription( key="min_price", translation_key="min_price", service_type="today_energy_usage", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", - value_fn=lambda data: data.energy_today.extreme_usage_prices[0], + value_fn=lambda data: data.energy_today.extreme_prices[0], ), EasyEnergySensorEntityDescription( key="highest_price_time", translation_key="highest_price_time", service_type="today_energy_usage", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda data: data.energy_today.highest_usage_price_time, + value_fn=lambda data: data.energy_today.highest_price_time, ), EasyEnergySensorEntityDescription( key="lowest_price_time", translation_key="lowest_price_time", service_type="today_energy_usage", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda data: data.energy_today.lowest_usage_price_time, + value_fn=lambda data: data.energy_today.lowest_price_time, ), EasyEnergySensorEntityDescription( key="percentage_of_max", translation_key="percentage_of_max", service_type="today_energy_usage", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: data.energy_today.pct_of_max_usage, + value_fn=lambda data: data.energy_today.pct_of_max, ), EasyEnergySensorEntityDescription( key="current_hour_price", @@ -129,8 +130,8 @@ class EasyEnergySensorEntityDescription(SensorEntityDescription): translation_key="next_hour_price", service_type="today_energy_return", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", - value_fn=lambda data: data.energy_today.price_at_time( - data.energy_today.utcnow() + timedelta(hours=1), "return" + value_fn=lambda data: data.energy_today.return_price_at_time( + dt_util.utcnow() + timedelta(hours=1) ), ), EasyEnergySensorEntityDescription( @@ -180,14 +181,14 @@ class EasyEnergySensorEntityDescription(SensorEntityDescription): translation_key="hours_priced_equal_or_lower", service_type="today_energy_usage", native_unit_of_measurement=UnitOfTime.HOURS, - value_fn=lambda data: data.energy_today.hours_priced_equal_or_lower_usage, + value_fn=lambda data: data.energy_today.periods_priced_equal_or_lower, ), EasyEnergySensorEntityDescription( key="hours_priced_equal_or_higher", translation_key="hours_priced_equal_or_higher", service_type="today_energy_return", native_unit_of_measurement=UnitOfTime.HOURS, - value_fn=lambda data: data.energy_today.hours_priced_equal_or_higher_return, + value_fn=lambda data: data.energy_today.return_periods_priced_equal_or_higher, ), ) @@ -205,9 +206,7 @@ def get_gas_price(data: EasyEnergyData, hours: int) -> float | None: """ if data.gas_today is None: return None - return data.gas_today.price_at_time( - data.gas_today.utcnow() + timedelta(hours=hours) - ) + return data.gas_today.price_at_time(dt_util.utcnow() + timedelta(hours=hours)) async def async_setup_entry( diff --git a/homeassistant/components/easyenergy/services.py b/homeassistant/components/easyenergy/services.py index 1ae7d5c5b5a728..f6886f6df4efd1 100644 --- a/homeassistant/components/easyenergy/services.py +++ b/homeassistant/components/easyenergy/services.py @@ -2,12 +2,13 @@ from __future__ import annotations -from datetime import date, datetime +from datetime import date, datetime, timedelta from enum import StrEnum from functools import partial from typing import Final -from easyenergy import Electricity, Gas, VatOption +from easyenergy import Electricity, Gas, PriceInterval, VatOption +from easyenergy.const import MARKET_TIMEZONE import voluptuous as vol from homeassistant.core import ( @@ -32,18 +33,22 @@ GAS_SERVICE_NAME: Final = "get_gas_prices" ENERGY_USAGE_SERVICE_NAME: Final = "get_energy_usage_prices" ENERGY_RETURN_SERVICE_NAME: Final = "get_energy_return_prices" +BASE_SERVICE_SCHEMA: Final = { + vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Optional(ATTR_START): str, + vol.Optional(ATTR_END): str, +} SERVICE_SCHEMA: Final = vol.Schema( { - vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( - { - "integration": DOMAIN, - } - ), + **BASE_SERVICE_SCHEMA, vol.Required(ATTR_INCL_VAT): bool, - vol.Optional(ATTR_START): str, - vol.Optional(ATTR_END): str, } ) +RETURN_SERVICE_SCHEMA: Final = vol.Schema(BASE_SERVICE_SCHEMA) class PriceType(StrEnum): @@ -54,22 +59,47 @@ class PriceType(StrEnum): GAS = "gas" -def __get_date(date_input: str | None) -> date | datetime: - """Get date.""" +def __get_date( + date_input: str | None, +) -> tuple[date, datetime | None]: + """Get date for the API and optional datetime for response filtering.""" if not date_input: - return dt_util.now().date() - - if value := dt_util.parse_datetime(date_input): - return value - - raise ServiceValidationError( - "Invalid datetime provided.", - translation_domain=DOMAIN, - translation_key="invalid_date", - translation_placeholders={ - "date": date_input, - }, - ) + return dt_util.now().date(), None + + if date_value := dt_util.parse_date(date_input): + return date_value, None + + if not (datetime_value := dt_util.parse_datetime(date_input)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_date", + translation_placeholders={ + "date": date_input, + }, + ) + + datetime_utc = dt_util.as_utc(datetime_value) + return datetime_utc.astimezone(MARKET_TIMEZONE).date(), datetime_utc + + +def __filter_prices( + prices: list[dict[str, float | datetime]], + intervals: tuple[PriceInterval, ...], + start: datetime, + end: datetime, +) -> list[dict[str, float | datetime]]: + """Filter prices to the requested datetime range.""" + included_timestamps = { + interval.starts_at + for interval in intervals + if interval.ends_at > start and interval.starts_at < end + } + + return [ + timestamp_price + for timestamp_price in prices + if timestamp_price["timestamp"] in included_timestamps + ] def __serialize_prices(prices: list[dict[str, float | datetime]]) -> ServiceResponse: @@ -101,8 +131,8 @@ async def __get_prices( """Get prices from easyEnergy.""" coordinator = __get_coordinator(call) - start = __get_date(call.data.get(ATTR_START)) - end = __get_date(call.data.get(ATTR_END)) + start_date, start_datetime = __get_date(call.data.get(ATTR_START)) + end_date, end_datetime = __get_date(call.data.get(ATTR_END)) vat = VatOption.INCLUDE if call.data.get(ATTR_INCL_VAT) is False: @@ -112,20 +142,38 @@ async def __get_prices( if price_type == PriceType.GAS: data = await coordinator.easyenergy.gas_prices( - start_date=start, - end_date=end, + start_date=start_date, + end_date=end_date, vat=vat, ) - return __serialize_prices(data.timestamp_prices) - data = await coordinator.easyenergy.energy_prices( - start_date=start, - end_date=end, - vat=vat, - ) + prices = data.timestamp_prices + else: + data = await coordinator.easyenergy.energy_prices( + start_date=start_date, + end_date=end_date, + vat=vat, + ) + + if price_type == PriceType.ENERGY_USAGE: + prices = data.timestamp_prices + else: + prices = data.timestamp_return_prices + + if start_datetime or end_datetime: + filter_start = start_datetime or dt_util.as_utc( + dt_util.start_of_local_day(start_date) + ) + filter_end = end_datetime or dt_util.as_utc( + dt_util.start_of_local_day(end_date + timedelta(days=1)) + ) + prices = __filter_prices( + prices, + data.intervals, + filter_start, + filter_end, + ) - if price_type == PriceType.ENERGY_USAGE: - return __serialize_prices(data.timestamp_usage_prices) - return __serialize_prices(data.timestamp_return_prices) + return __serialize_prices(prices) @callback @@ -150,6 +198,6 @@ def async_setup_services(hass: HomeAssistant) -> None: DOMAIN, ENERGY_RETURN_SERVICE_NAME, partial(__get_prices, price_type=PriceType.ENERGY_RETURN), - schema=SERVICE_SCHEMA, + schema=RETURN_SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 878e86888e16d5..4f5e0d7e84844a 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.11", "deebot-client==18.1.0"] + "requirements": ["py-sucks==0.9.11", "deebot-client==18.2.0"] } diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json index 5717c822a333cc..3c521810cdf46d 100644 --- a/homeassistant/components/elgato/manifest.json +++ b/homeassistant/components/elgato/manifest.json @@ -11,6 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/elgato", "integration_type": "device", "iot_class": "local_polling", + "quality_scale": "platinum", "requirements": ["elgato==5.1.2"], "zeroconf": ["_elg._tcp.local."] } diff --git a/homeassistant/components/elgato/quality_scale.yaml b/homeassistant/components/elgato/quality_scale.yaml index 4bfd143989fb41..6a8847026a3735 100644 --- a/homeassistant/components/elgato/quality_scale.yaml +++ b/homeassistant/components/elgato/quality_scale.yaml @@ -10,7 +10,7 @@ rules: docs-actions: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: status: exempt comment: | @@ -25,8 +25,8 @@ rules: # Silver action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done @@ -41,17 +41,13 @@ rules: diagnostics: done discovery-update-info: done discovery: done - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: - status: todo - comment: | - Device are documented, but some are missing. For example, the their pro - strip is supported as well. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: todo + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: | diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index e228e11d00d777..6c430fab3604df 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -715,6 +715,9 @@ def _update_state(self) -> None: self._attr_native_value = None return + self._attr_native_unit_of_measurement = source_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT + ) self._attr_native_value = value * -1 elif self._is_combined: @@ -763,13 +766,11 @@ async def async_added_to_hass(self) -> None: # Check first sensor if source_entry := entity_reg.async_get(self._source_sensors[0]): device_id = source_entry.device_id - # For combined mode, always use Watts because we may have different source units; for inverted mode, copy source unit + # Combined mode always emits Watts because we convert + # heterogeneous source units internally. For inverted mode the + # unit is copied from the source state in _update_state. if self._is_combined: self._attr_native_unit_of_measurement = UnitOfPower.WATT - else: - self._attr_native_unit_of_measurement = ( - source_entry.unit_of_measurement - ) # Get source name from registry source_name = source_entry.name or source_entry.original_name # Assign power sensor to same device as source sensor(s) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 4f0cf60da4d668..1b3e42e68c17e0 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -17,6 +17,7 @@ CONF_HOST, CONF_PASSWORD, CONF_PORT, + EVENT_HOMEASSISTANT_STOP, __version__ as ha_version, ) from homeassistant.core import HomeAssistant, callback @@ -80,7 +81,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if "usb" in hass.config.components: async_register_serial_port_scanner(hass, _async_scan_serial_ports) - serial_proxy.set_hass_loop(hass.loop) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, + serial_proxy.register_serialx_transport(hass.loop), + ) return True diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index 32ba27ded8e1cd..a2cb2177bd8137 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -1,11 +1,20 @@ """ESPHome constants.""" -from typing import Final +from __future__ import annotations + +from typing import TYPE_CHECKING, Final from awesomeversion import AwesomeVersion +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from .domain_data import DomainData + DOMAIN = "esphome" +ESPHOME_DATA: HassKey[DomainData] = HassKey(DOMAIN) + CONF_ALLOW_SERVICE_CALLS = "allow_service_calls" CONF_SUBSCRIBE_LOGS = "subscribe_logs" CONF_DEVICE_NAME = "device_name" diff --git a/homeassistant/components/esphome/domain_data.py b/homeassistant/components/esphome/domain_data.py index c7cec236cc75bc..5aa2d8f3e5a68e 100644 --- a/homeassistant/components/esphome/domain_data.py +++ b/homeassistant/components/esphome/domain_data.py @@ -4,12 +4,11 @@ from dataclasses import dataclass, field from functools import cache -from typing import Self from homeassistant.core import HomeAssistant from homeassistant.helpers.json import JSONEncoder -from .const import DOMAIN +from .const import ESPHOME_DATA from .entry_data import ESPHomeConfigEntry, ESPHomeStorage, RuntimeEntryData STORAGE_VERSION = 1 @@ -36,11 +35,9 @@ def get_or_create_store( ), ) - @classmethod + @staticmethod @cache - def get(cls, hass: HomeAssistant) -> Self: + def get(hass: HomeAssistant) -> DomainData: """Get the global DomainData instance stored in hass.data.""" - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - ret = hass.data[DOMAIN] = cls() + ret = hass.data[ESPHOME_DATA] = DomainData() return ret diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 46059407294f8d..cac4eadfe25483 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -35,6 +35,7 @@ MediaPlayerInfo, MediaPlayerSupportedFormat, NumberInfo, + RadioFrequencyInfo, SelectInfo, SensorInfo, SensorState, @@ -88,6 +89,7 @@ FanInfo: Platform.FAN, InfraredInfo: Platform.INFRARED, LightInfo: Platform.LIGHT, + RadioFrequencyInfo: Platform.RADIO_FREQUENCY, LockInfo: Platform.LOCK, MediaPlayerInfo: Platform.MEDIA_PLAYER, NumberInfo: Platform.NUMBER, diff --git a/homeassistant/components/esphome/radio_frequency.py b/homeassistant/components/esphome/radio_frequency.py new file mode 100644 index 00000000000000..7aaea22f53d80f --- /dev/null +++ b/homeassistant/components/esphome/radio_frequency.py @@ -0,0 +1,77 @@ +"""Radio Frequency platform for ESPHome.""" + +from __future__ import annotations + +from functools import partial +import logging + +from aioesphomeapi import ( + EntityState, + RadioFrequencyCapability, + RadioFrequencyInfo, + RadioFrequencyModulation, +) +from rf_protocols import ModulationType, RadioFrequencyCommand + +from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity +from homeassistant.core import callback + +from .entity import ( + EsphomeEntity, + convert_api_error_ha_error, + platform_async_setup_entry, +) + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + +MODULATION_TYPE_TO_ESPHOME: dict[ModulationType, RadioFrequencyModulation] = { + ModulationType.OOK: RadioFrequencyModulation.OOK, +} + + +class EsphomeRadioFrequencyEntity( + EsphomeEntity[RadioFrequencyInfo, EntityState], RadioFrequencyTransmitterEntity +): + """ESPHome radio frequency entity using native API.""" + + @property + def supported_frequency_ranges(self) -> list[tuple[int, int]]: + """Return supported frequency ranges from device info.""" + return [(self._static_info.frequency_min, self._static_info.frequency_max)] + + @callback + def _on_device_update(self) -> None: + """Call when device updates or entry data changes.""" + super()._on_device_update() + if self._entry_data.available: + self.async_write_ha_state() + + @convert_api_error_ha_error + async def async_send_command(self, command: RadioFrequencyCommand) -> None: + """Send an RF command.""" + timings = command.get_raw_timings() + _LOGGER.debug("Sending RF command: %s", timings) + + self._client.radio_frequency_transmit_raw_timings( + self._static_info.key, + frequency=command.frequency, + timings=timings, + modulation=MODULATION_TYPE_TO_ESPHOME[command.modulation], + # In ESPHome, repeat_count is total number of times to send the command, while in rf_protocols + # it's the number of additional times to send it, so we need to add 1 here. + repeat_count=command.repeat_count + 1, + device_id=self._static_info.device_id, + ) + + +async_setup_entry = partial( + platform_async_setup_entry, + info_type=RadioFrequencyInfo, + entity_type=EsphomeRadioFrequencyEntity, + state_type=EntityState, + info_filter=lambda info: bool( + info.capabilities & RadioFrequencyCapability.TRANSMITTER + ), +) diff --git a/homeassistant/components/esphome/serial_proxy.py b/homeassistant/components/esphome/serial_proxy.py index 860a0486ee54ea..a738462255188d 100644 --- a/homeassistant/components/esphome/serial_proxy.py +++ b/homeassistant/components/esphome/serial_proxy.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from typing import cast from aioesphomeapi import APIClient @@ -15,25 +16,17 @@ from yarl import URL from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant, async_get_hass +from homeassistant.core import Event, HomeAssistant, async_get_hass, callback from .const import DOMAIN from .entry_data import ESPHomeConfigEntry -SCHEME = "esphome-hass://" - # This is required so that serialx can safely query Core for an instance of an # aioesphomeapi client. We cannot make any assumptions here, some packages run separate # asyncio event loops in dedicated threads. _HASS_LOOP: asyncio.AbstractEventLoop | None = None -def set_hass_loop(loop: asyncio.AbstractEventLoop) -> None: - """Store a reference to the Core event loop.""" - global _HASS_LOOP # noqa: PLW0603 # pylint: disable=global-statement - _HASS_LOOP = loop - - def build_url(entry_id: str, port_name: str) -> URL: """Build a canonical `esphome-hass://` URL.""" return URL.build( @@ -105,9 +98,24 @@ class HassESPHomeSerialTransport(ESPHomeSerialTransport): _serial_cls = HassESPHomeSerial -register_uri_handler( - scheme=SCHEME, - unique_scheme=SCHEME, - sync_cls=HassESPHomeSerial, - async_transport_cls=HassESPHomeSerialTransport, -) +def register_serialx_transport( + loop: asyncio.AbstractEventLoop, +) -> Callable[[Event], None]: + """Register the ESPHome URI handler.""" + global _HASS_LOOP # noqa: PLW0603 # pylint: disable=global-statement + _HASS_LOOP = loop + + unregister = register_uri_handler( + scheme="esphome-hass://", + unique_scheme="esphome-hass-internal://", # The unique scheme must differ + sync_cls=HassESPHomeSerial, + async_transport_cls=HassESPHomeSerialTransport, + ) + + @callback + def _unregister(event: Event) -> None: + global _HASS_LOOP # noqa: PLW0603 # pylint: disable=global-statement + unregister() + _HASS_LOOP = None + + return _unregister diff --git a/homeassistant/components/eurotronic_cometblue/climate.py b/homeassistant/components/eurotronic_cometblue/climate.py index 5df02bec17aa39..f1bb29f7886254 100644 --- a/homeassistant/components/eurotronic_cometblue/climate.py +++ b/homeassistant/components/eurotronic_cometblue/climate.py @@ -56,7 +56,6 @@ class CometBlueClimateEntity(CometBlueBluetoothEntity, ClimateEntity): ] _attr_supported_features: ClimateEntityFeature = ( ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF @@ -81,13 +80,19 @@ def target_temperature(self) -> float | None: return self.coordinator.data.temperatures["manualTemp"] @property - def target_temperature_high(self) -> float | None: - """Return the upper bound target temperature.""" + def _device_comfort_setpoint(self) -> float | None: + """Return the comfort setpoint temperature. + + Internally used for preset selection. + """ return self.coordinator.data.temperatures["targetTempHigh"] @property - def target_temperature_low(self) -> float | None: - """Return the lower bound target temperature.""" + def _device_eco_setpoint(self) -> float | None: + """Return the eco setpoint temperature. + + Internally used for preset selection. + """ return self.coordinator.data.temperatures["targetTempLow"] @property @@ -113,9 +118,9 @@ def preset_mode(self) -> str | None: return PRESET_AWAY if self.target_temperature == MAX_TEMP: return PRESET_BOOST - if self.target_temperature == self.target_temperature_high: + if self.target_temperature == self._device_comfort_setpoint: return PRESET_COMFORT - if self.target_temperature == self.target_temperature_low: + if self.target_temperature == self._device_eco_setpoint: return PRESET_ECO return PRESET_NONE @@ -153,11 +158,11 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: ) if preset_mode == PRESET_ECO: return await self.async_set_temperature( - temperature=self.target_temperature_low + temperature=self._device_eco_setpoint ) if preset_mode == PRESET_COMFORT: return await self.async_set_temperature( - temperature=self.target_temperature_high + temperature=self._device_comfort_setpoint ) if preset_mode == PRESET_BOOST: return await self.async_set_temperature(temperature=MAX_TEMP) @@ -172,7 +177,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: return await self.async_set_temperature(temperature=MAX_TEMP) if hvac_mode == HVACMode.AUTO: return await self.async_set_temperature( - temperature=self.target_temperature_low + temperature=self._device_eco_setpoint ) raise ServiceValidationError(f"Unknown HVAC mode '{hvac_mode}'") diff --git a/homeassistant/components/file/services.py b/homeassistant/components/file/services.py index 0cd4aaf9324d11..9e4033148cf01d 100644 --- a/homeassistant/components/file/services.py +++ b/homeassistant/components/file/services.py @@ -9,6 +9,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service import async_register_admin_service from .const import ATTR_FILE_ENCODING, ATTR_FILE_NAME, DOMAIN, SERVICE_READ_FILE @@ -17,7 +18,8 @@ def async_setup_services(hass: HomeAssistant) -> None: """Register services for File integration.""" - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_READ_FILE, read_file, diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index 0f0213ec984d95..70ffee8973fc33 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -11,7 +11,7 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfVolume +from homeassistant.const import UnitOfVolume, UnitOfVolumeFlowRate from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -34,7 +34,8 @@ key="current_interval", translation_key="current_interval", suggested_display_precision=2, - native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/m", + native_unit_of_measurement=UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( @@ -65,14 +66,16 @@ key="last_60_min", translation_key="last_60_min", suggested_display_precision=2, - native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/h", + native_unit_of_measurement=UnitOfVolumeFlowRate.GALLONS_PER_HOUR, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="last_24_hrs", translation_key="last_24_hrs", suggested_display_precision=2, - native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/d", + native_unit_of_measurement=UnitOfVolumeFlowRate.GALLONS_PER_DAY, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( diff --git a/homeassistant/components/forecast_solar/config_flow.py b/homeassistant/components/forecast_solar/config_flow.py index 7fb1bde2d5a439..098c74c95473cf 100644 --- a/homeassistant/components/forecast_solar/config_flow.py +++ b/homeassistant/components/forecast_solar/config_flow.py @@ -15,7 +15,7 @@ OptionsFlow, SubentryFlowResult, ) -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, selector @@ -94,7 +94,7 @@ async def async_step_user( """Handle a flow initiated by the user.""" if user_input is not None: return self.async_create_entry( - title=user_input[CONF_NAME], + title="", data={ CONF_LATITUDE: user_input[CONF_LATITUDE], CONF_LONGITUDE: user_input[CONF_LONGITUDE], @@ -118,13 +118,11 @@ async def async_step_user( data_schema=self.add_suggested_values_to_schema( vol.Schema( { - vol.Required(CONF_NAME): str, vol.Required(CONF_LATITUDE): cv.latitude, vol.Required(CONF_LONGITUDE): cv.longitude, } ).extend(PLANE_SCHEMA.schema), { - CONF_NAME: self.hass.config.location_name, CONF_LATITUDE: self.hass.config.latitude, CONF_LONGITUDE: self.hass.config.longitude, CONF_DECLINATION: DEFAULT_DECLINATION, diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py index 13a4d5c2d232ec..55493103a7ce26 100644 --- a/homeassistant/components/forecast_solar/sensor.py +++ b/homeassistant/components/forecast_solar/sensor.py @@ -27,6 +27,8 @@ from .const import DOMAIN from .coordinator import ForecastSolarDataUpdateCoordinator +PARALLEL_UPDATES = 0 + @dataclass(frozen=True) class ForecastSolarSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/forecast_solar/strings.json b/homeassistant/components/forecast_solar/strings.json index 3ed3f146a110ab..6d0c3b45844925 100644 --- a/homeassistant/components/forecast_solar/strings.json +++ b/homeassistant/components/forecast_solar/strings.json @@ -7,8 +7,7 @@ "declination": "Declination (0 = Horizontal, 90 = Vertical)", "latitude": "[%key:common::config_flow::data::latitude%]", "longitude": "[%key:common::config_flow::data::longitude%]", - "modules_power": "Total Watt peak power of your solar modules", - "name": "[%key:common::config_flow::data::name%]" + "modules_power": "Total Watt peak power of your solar modules" }, "description": "Fill in the data of your solar panels. Please refer to the documentation if a field is unclear." } diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json index 74545c10a81b28..0558be9d4712af 100644 --- a/homeassistant/components/freebox/manifest.json +++ b/homeassistant/components/freebox/manifest.json @@ -8,6 +8,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["freebox_api"], - "requirements": ["freebox-api==1.3.0"], + "requirements": ["freebox-api==1.3.1"], "zeroconf": ["_fbx-api._tcp.local."] } diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index 25053abaca358c..25f9449696eaaa 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -45,6 +45,7 @@ class FritzButtonDescription(ButtonEntityDescription): device_class=ButtonDeviceClass.UPDATE, entity_category=EntityCategory.CONFIG, press_action=lambda avm_wrapper: avm_wrapper.async_trigger_firmware_update(), + entity_registry_enabled_default=False, ), FritzButtonDescription( key="reboot", @@ -96,6 +97,33 @@ def repair_issue_cleanup(hass: HomeAssistant, avm_wrapper: AvmWrapper) -> None: ) +def repair_issue_firmware_update(hass: HomeAssistant, avm_wrapper: AvmWrapper) -> None: + """Repair issue for firmware update button.""" + entity_registry = er.async_get(hass) + + if ( + ( + entity_button := entity_registry.async_get_entity_id( + "button", DOMAIN, f"{avm_wrapper.unique_id}-firmware_update" + ) + ) + and (entity_entry := entity_registry.async_get(entity_button)) + and not entity_entry.disabled + ): + # Deprecate the 'firmware update' button: create a Repairs issue for users + ir.async_create_issue( + hass, + domain=DOMAIN, + issue_id="deprecated_firmware_update_button", + is_fixable=False, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_firmware_update_button", + translation_placeholders={"removal_version": "2026.11.0"}, + breaks_in_ha_version="2026.11.0", + ) + + async def async_setup_entry( hass: HomeAssistant, entry: FritzConfigEntry, @@ -112,6 +140,7 @@ async def async_setup_entry( if avm_wrapper.mesh_role == MeshRoles.SLAVE: async_add_entities(entities_list) repair_issue_cleanup(hass, avm_wrapper) + repair_issue_firmware_update(hass, avm_wrapper) return data_fritz = hass.data[FRITZ_DATA_KEY] @@ -131,6 +160,7 @@ def async_update_avm_device() -> None: ) repair_issue_cleanup(hass, avm_wrapper) + repair_issue_firmware_update(hass, avm_wrapper) class FritzButton(ButtonEntity): @@ -164,6 +194,12 @@ async def async_press(self) -> None: "Please update your automations and dashboards to remove any usage of this button. " "The action is now performed automatically at each data refresh", ) + elif self.entity_description.key == "firmware_update": + _LOGGER.warning( + "The 'firmware update' button is deprecated and will be removed in Home Assistant Core " + "2026.11.0. It has been superseded by an update entity. Please update your automations " + "and dashboards to remove any usage of this button", + ) await self.entity_description.press_action(self.avm_wrapper) diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 032efb3f4ae9cd..5050907c1d8754 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -66,8 +66,6 @@ class MeshRoles(StrEnum): BUTTON_TYPE_WOL = "WakeOnLan" -UPTIME_DEVIATION = 5 - FRITZ_EXCEPTIONS = ( ConnectionError, FritzActionError, diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 41fa3fca056dca..4bd54a751d50f0 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -28,7 +28,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from .const import DSL_CONNECTION, UPTIME_DEVIATION +from .const import DSL_CONNECTION from .coordinator import FritzConfigEntry from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription from .models import ConnectionInfo @@ -39,31 +39,18 @@ PARALLEL_UPDATES = 0 -def _uptime_calculation(seconds_uptime: float, last_value: datetime | None) -> datetime: - """Calculate uptime with deviation.""" - delta_uptime = utcnow() - timedelta(seconds=seconds_uptime) - - if ( - not last_value - or abs((delta_uptime - last_value).total_seconds()) > UPTIME_DEVIATION - ): - return delta_uptime - - return last_value - - def _retrieve_device_uptime_state( - status: FritzStatus, last_value: datetime + status: FritzStatus, last_value: datetime | None ) -> datetime: """Return uptime from device.""" - return _uptime_calculation(status.device_uptime, last_value) + return utcnow() - timedelta(seconds=status.device_uptime) def _retrieve_connection_uptime_state( status: FritzStatus, last_value: datetime | None ) -> datetime: """Return uptime from connection.""" - return _uptime_calculation(status.connection_uptime, last_value) + return utcnow() - timedelta(seconds=status.connection_uptime) def _retrieve_external_ip_state(status: FritzStatus, last_value: str) -> str: @@ -200,7 +187,7 @@ class FritzDeviceSensorEntityDescription( FritzConnectionSensorEntityDescription( key="connection_uptime", translation_key="connection_uptime", - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_connection_uptime_state, ), @@ -308,7 +295,7 @@ class FritzDeviceSensorEntityDescription( FritzDeviceSensorEntityDescription( key="device_uptime", translation_key="device_uptime", - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_device_uptime_state, ), diff --git a/homeassistant/components/fritz/services.py b/homeassistant/components/fritz/services.py index 9d7d6b339b2aa8..193463233f9488 100644 --- a/homeassistant/components/fritz/services.py +++ b/homeassistant/components/fritz/services.py @@ -13,7 +13,10 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers.service import async_extract_config_entry_ids +from homeassistant.helpers.service import ( + async_extract_config_entry_ids, + async_register_admin_service, +) from .const import DOMAIN from .coordinator import FritzConfigEntry @@ -118,7 +121,8 @@ async def _async_dial(service_call: ServiceCall) -> None: def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Fritz integration.""" - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_SET_GUEST_WIFI_PW, _async_set_guest_wifi_password, diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 22cdd12bd20fe5..745a548aa7484d 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -211,6 +211,10 @@ "deprecated_cleanup_button": { "description": "The 'Cleanup' button is deprecated and will be removed in Home Assistant Core {removal_version}. Please update your automations and dashboards to remove any usage of this button. The action is now performed automatically at each data refresh.", "title": "'Cleanup' button is deprecated" + }, + "deprecated_firmware_update_button": { + "description": "The 'Firmware update' button is deprecated and will be removed in Home Assistant Core {removal_version}. It has been superseded by an update entity. Please update your automations and dashboards to remove any usage of this button.", + "title": "'Firmware update' button is deprecated" } }, "options": { diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index e9ec83fd8e412d..f5ba1caabf7239 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260325.7"] + "requirements": ["home-assistant-frontend==20260325.8"] } diff --git a/homeassistant/components/fumis/__init__.py b/homeassistant/components/fumis/__init__.py index 0ae417b6603215..e04b1b1527d21e 100644 --- a/homeassistant/components/fumis/__init__.py +++ b/homeassistant/components/fumis/__init__.py @@ -7,7 +7,13 @@ from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator -PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CLIMATE, + Platform.NUMBER, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: FumisConfigEntry) -> bool: diff --git a/homeassistant/components/fumis/binary_sensor.py b/homeassistant/components/fumis/binary_sensor.py new file mode 100644 index 00000000000000..de533a958355ec --- /dev/null +++ b/homeassistant/components/fumis/binary_sensor.py @@ -0,0 +1,76 @@ +"""Support for Fumis binary sensor entities.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from fumis import FumisInfo + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator +from .entity import FumisEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class FumisBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes a Fumis binary sensor entity.""" + + has_fn: Callable[[FumisInfo], bool] = lambda _: True + is_on_fn: Callable[[FumisInfo], bool | None] + + +BINARY_SENSORS: tuple[FumisBinarySensorEntityDescription, ...] = ( + FumisBinarySensorEntityDescription( + key="door", + device_class=BinarySensorDeviceClass.DOOR, + entity_category=EntityCategory.DIAGNOSTIC, + has_fn=lambda data: data.controller.door_open is not None, + is_on_fn=lambda data: data.controller.door_open, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FumisConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Fumis binary sensor entities based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + FumisBinarySensorEntity(coordinator=coordinator, description=description) + for description in BINARY_SENSORS + if description.has_fn(coordinator.data) + ) + + +class FumisBinarySensorEntity(FumisEntity, BinarySensorEntity): + """Defines a Fumis binary sensor entity.""" + + entity_description: FumisBinarySensorEntityDescription + + def __init__( + self, + coordinator: FumisDataUpdateCoordinator, + description: FumisBinarySensorEntityDescription, + ) -> None: + """Initialize the Fumis binary sensor entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + + @property + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return self.entity_description.is_on_fn(self.coordinator.data) diff --git a/homeassistant/components/fumis/button.py b/homeassistant/components/fumis/button.py new file mode 100644 index 00000000000000..c6fa30223a687a --- /dev/null +++ b/homeassistant/components/fumis/button.py @@ -0,0 +1,71 @@ +"""Support for Fumis button entities.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from fumis import Fumis + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator +from .entity import FumisEntity +from .helpers import fumis_exception_handler + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class FumisButtonEntityDescription(ButtonEntityDescription): + """Describes a Fumis button entity.""" + + press_fn: Callable[[Fumis], Awaitable[Any]] + + +BUTTONS: tuple[FumisButtonEntityDescription, ...] = ( + FumisButtonEntityDescription( + key="sync_clock", + translation_key="sync_clock", + entity_category=EntityCategory.DIAGNOSTIC, + press_fn=lambda client: client.set_clock(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FumisConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Fumis button entities based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + FumisButtonEntity(coordinator=coordinator, description=description) + for description in BUTTONS + ) + + +class FumisButtonEntity(FumisEntity, ButtonEntity): + """Defines a Fumis button entity.""" + + entity_description: FumisButtonEntityDescription + + def __init__( + self, + coordinator: FumisDataUpdateCoordinator, + description: FumisButtonEntityDescription, + ) -> None: + """Initialize the Fumis button entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + + @fumis_exception_handler + async def async_press(self) -> None: + """Handle the button press.""" + await self.entity_description.press_fn(self.coordinator.client) diff --git a/homeassistant/components/fumis/icons.json b/homeassistant/components/fumis/icons.json index 40c6bab93c22e1..f8298d2b1da653 100644 --- a/homeassistant/components/fumis/icons.json +++ b/homeassistant/components/fumis/icons.json @@ -1,12 +1,68 @@ { "entity": { + "button": { + "sync_clock": { + "default": "mdi:clock-sync" + } + }, + "number": { + "fan_speed": { + "default": "mdi:fan" + }, + "power_level": { + "default": "mdi:fire" + } + }, "sensor": { + "alert": { + "default": "mdi:alert", + "state": { + "airflow_malfunction": "mdi:fan-off", + "door_open": "mdi:door-open", + "flue_gas_warning": "mdi:thermometer-alert", + "low_battery": "mdi:battery-alert", + "low_fuel": "mdi:gauge-empty", + "none": "mdi:check-circle", + "service_due": "mdi:wrench-clock", + "speed_sensor_failure": "mdi:fan-alert" + } + }, "combustion_chamber_temperature": { "default": "mdi:thermometer-high" }, "detailed_stove_status": { "default": "mdi:fireplace" }, + "error": { + "default": "mdi:alert-circle", + "state": { + "chimney_alarm": "mdi:broom", + "chimney_dirty": "mdi:broom", + "door_alarm": "mdi:door-open", + "fire_error": "mdi:fire-alert", + "flue_gas_overtemp": "mdi:thermometer-high", + "fuel_ignition_timeout": "mdi:fire-off", + "gas_alarm": "mdi:alert-circle", + "general_error": "mdi:alert-circle", + "grate_error": "mdi:alert-circle", + "ignition_failed": "mdi:fire-alert", + "mfdoor_alarm": "mdi:door-open", + "no_pellet_alarm": "mdi:gauge-empty", + "none": "mdi:check-circle", + "ntc1_alarm": "mdi:thermometer-alert", + "ntc2_alarm": "mdi:thermometer-alert", + "ntc3_alarm": "mdi:thermometer-alert", + "pressure_alarm": "mdi:gauge-empty", + "pressure_sensor_off": "mdi:gauge-empty", + "safety_switch": "mdi:shield-alert", + "sensor_t01_t02": "mdi:thermometer-alert", + "sensor_t01_t03": "mdi:thermometer-alert", + "sensor_t02": "mdi:thermometer-alert", + "sensor_t03_t05": "mdi:thermometer-alert", + "sensor_t04": "mdi:thermometer-alert", + "tc1_alarm": "mdi:thermometer-alert" + } + }, "fan_1_speed": { "default": "mdi:fan" }, diff --git a/homeassistant/components/fumis/manifest.json b/homeassistant/components/fumis/manifest.json index 0ab8c7be5d063c..51182f92d3960b 100644 --- a/homeassistant/components/fumis/manifest.json +++ b/homeassistant/components/fumis/manifest.json @@ -13,5 +13,5 @@ "iot_class": "cloud_polling", "loggers": ["fumis"], "quality_scale": "bronze", - "requirements": ["fumis==0.3.0"] + "requirements": ["fumis==0.4.0"] } diff --git a/homeassistant/components/fumis/number.py b/homeassistant/components/fumis/number.py new file mode 100644 index 00000000000000..c966ba5d248c3b --- /dev/null +++ b/homeassistant/components/fumis/number.py @@ -0,0 +1,97 @@ +"""Support for Fumis number entities.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from fumis import Fumis, FumisInfo + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator +from .entity import FumisEntity +from .helpers import fumis_exception_handler + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class FumisNumberEntityDescription(NumberEntityDescription): + """Describes a Fumis number entity.""" + + has_fn: Callable[[FumisInfo], bool] = lambda _: True + value_fn: Callable[[FumisInfo], float | None] + set_fn: Callable[[Fumis, float], Awaitable[Any]] + + +NUMBERS: tuple[FumisNumberEntityDescription, ...] = ( + FumisNumberEntityDescription( + key="fan_speed", + translation_key="fan_speed", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_min_value=0, + native_max_value=5, + native_step=1, + has_fn=lambda data: len(data.controller.fans) > 0, + value_fn=lambda data: ( + data.controller.fans[0].speed if data.controller.fans else None + ), + set_fn=lambda client, value: client.set_fan_speed(int(value)), + ), + FumisNumberEntityDescription( + key="power_level", + translation_key="power_level", + native_min_value=1, + native_max_value=5, + native_step=1, + value_fn=lambda data: data.controller.power.set_power, + set_fn=lambda client, value: client.set_power(int(value)), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FumisConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Fumis number entities based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + FumisNumberEntity(coordinator=coordinator, description=description) + for description in NUMBERS + if description.has_fn(coordinator.data) + ) + + +class FumisNumberEntity(FumisEntity, NumberEntity): + """Defines a Fumis number entity.""" + + entity_description: FumisNumberEntityDescription + + def __init__( + self, + coordinator: FumisDataUpdateCoordinator, + description: FumisNumberEntityDescription, + ) -> None: + """Initialize the Fumis number entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + + @property + def native_value(self) -> float | None: + """Return the current value.""" + return self.entity_description.value_fn(self.coordinator.data) + + @fumis_exception_handler + async def async_set_native_value(self, value: float) -> None: + """Set a new value.""" + await self.entity_description.set_fn(self.coordinator.client, value) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/fumis/sensor.py b/homeassistant/components/fumis/sensor.py index 78c48b35f45525..024096048f8f91 100644 --- a/homeassistant/components/fumis/sensor.py +++ b/homeassistant/components/fumis/sensor.py @@ -5,8 +5,9 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta +from typing import Any -from fumis import FumisInfo, StoveState, StoveStatus +from fumis import FumisInfo, StoveAlert, StoveError, StoveState, StoveStatus from homeassistant.components.sensor import ( SensorDeviceClass, @@ -34,15 +35,52 @@ PARALLEL_UPDATES = 0 +def _code_to_state(code: StoveAlert | StoveError | None) -> str | None: + """Convert a stove alert or error code to a sensor state value. + + Returns "none" when there is no active alert/error, None when the code + is unknown, or the enum member name in lowercase for known codes. + """ + if code is None: + return "none" + if code.name == "UNKNOWN": + return None + return code.name.lower() + + +def _code_to_attr(code: StoveAlert | StoveError | None) -> dict[str, str | None]: + """Convert a stove alert or error code to extra state attributes.""" + if code is None or code.name == "UNKNOWN": + return {"code": None} + return {"code": code.value} + + @dataclass(frozen=True, kw_only=True) class FumisSensorEntityDescription(SensorEntityDescription): """Describes a Fumis sensor entity.""" + attr_fn: Callable[[FumisInfo], dict[str, Any]] | None = None has_fn: Callable[[FumisInfo], bool] = lambda _: True value_fn: Callable[[FumisInfo], datetime | float | int | str | None] SENSORS: tuple[FumisSensorEntityDescription, ...] = ( + FumisSensorEntityDescription( + key="alert", + translation_key="alert", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + "none", + *( + alert.name.lower() + for alert in StoveAlert + if alert != StoveAlert.UNKNOWN + ), + ], + value_fn=lambda data: _code_to_state(data.controller.stove_alert), + attr_fn=lambda data: _code_to_attr(data.controller.stove_alert), + ), FumisSensorEntityDescription( key="combustion_chamber_temperature", translation_key="combustion_chamber_temperature", @@ -69,6 +107,22 @@ class FumisSensorEntityDescription(SensorEntityDescription): else data.controller.stove_status.name.lower() ), ), + FumisSensorEntityDescription( + key="error", + translation_key="error", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + "none", + *( + error.name.lower() + for error in StoveError + if error != StoveError.UNKNOWN + ), + ], + value_fn=lambda data: _code_to_state(data.controller.stove_error), + attr_fn=lambda data: _code_to_attr(data.controller.stove_error), + ), FumisSensorEntityDescription( key="fan_1_speed", translation_key="fan_1_speed", @@ -267,6 +321,13 @@ def __init__( self.entity_description = description self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return additional state attributes.""" + if self.entity_description.attr_fn is None: + return None + return self.entity_description.attr_fn(self.coordinator.data) + @property def native_value(self) -> datetime | float | int | str | None: """Return the sensor value.""" diff --git a/homeassistant/components/fumis/strings.json b/homeassistant/components/fumis/strings.json index 91ea585d9d0cde..8332b1f5d3e7dd 100644 --- a/homeassistant/components/fumis/strings.json +++ b/homeassistant/components/fumis/strings.json @@ -53,7 +53,33 @@ } }, "entity": { + "button": { + "sync_clock": { + "name": "Sync clock" + } + }, + "number": { + "fan_speed": { + "name": "Fan speed" + }, + "power_level": { + "name": "Power level" + } + }, "sensor": { + "alert": { + "name": "Alert", + "state": { + "airflow_malfunction": "Airflow sensor malfunction", + "door_open": "Door open", + "flue_gas_warning": "Flue gas temperature warning", + "low_battery": "Low battery", + "low_fuel": "Low fuel level", + "none": "No alert", + "service_due": "Service due", + "speed_sensor_failure": "Speed sensor failure" + } + }, "combustion_chamber_temperature": { "name": "Combustion chamber" }, @@ -76,6 +102,36 @@ "wood_start": "Wood start" } }, + "error": { + "name": "Error", + "state": { + "chimney_alarm": "Chimney alarm", + "chimney_dirty": "Chimney or burning pot dirty", + "door_alarm": "Door alarm", + "fire_error": "Fire error", + "flue_gas_overtemp": "Flue gas overtemperature", + "fuel_ignition_timeout": "Fuel ignition timeout", + "gas_alarm": "Gas alarm", + "general_error": "General error", + "grate_error": "Grate error", + "ignition_failed": "Ignition failed", + "mfdoor_alarm": "MFDoor alarm", + "no_pellet_alarm": "No pellet alarm", + "none": "No error", + "ntc1_alarm": "NTC1 alarm", + "ntc2_alarm": "NTC2 alarm", + "ntc3_alarm": "NTC3 alarm", + "pressure_alarm": "Pressure alarm", + "pressure_sensor_off": "Pressure sensor off", + "safety_switch": "Safety switch tripped", + "sensor_t01_t02": "Sensor T01/T02 malfunction", + "sensor_t01_t03": "Sensor T01/T03 malfunction", + "sensor_t02": "Sensor T02 malfunction", + "sensor_t03_t05": "Sensor T03/T05 malfunction", + "sensor_t04": "Sensor T04 malfunction", + "tc1_alarm": "TC1 alarm" + } + }, "fan_1_speed": { "name": "Fan 1 speed" }, diff --git a/homeassistant/components/garage_door/conditions.yaml b/homeassistant/components/garage_door/conditions.yaml index 32215fdc5eb8ff..40acafeab1fc4f 100644 --- a/homeassistant/components/garage_door/conditions.yaml +++ b/homeassistant/components/garage_door/conditions.yaml @@ -8,6 +8,11 @@ options: - all - any + for: + required: true + default: 00:00:00 + selector: + duration: is_closed: fields: *condition_common_fields diff --git a/homeassistant/components/garage_door/strings.json b/homeassistant/components/garage_door/strings.json index b046d7f0a676ad..30deca4392998b 100644 --- a/homeassistant/components/garage_door/strings.json +++ b/homeassistant/components/garage_door/strings.json @@ -1,6 +1,7 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "trigger_behavior_name": "Trigger when", "trigger_for_name": "For at least" }, @@ -10,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::garage_door::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::garage_door::common::condition_for_name%]" } }, "name": "Garage door is closed" @@ -19,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::garage_door::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::garage_door::common::condition_for_name%]" } }, "name": "Garage door is open" diff --git a/homeassistant/components/gate/conditions.yaml b/homeassistant/components/gate/conditions.yaml index aea805c2069f35..8a6342ab91b934 100644 --- a/homeassistant/components/gate/conditions.yaml +++ b/homeassistant/components/gate/conditions.yaml @@ -8,6 +8,11 @@ options: - all - any + for: + required: true + default: 00:00:00 + selector: + duration: is_closed: fields: *condition_common_fields diff --git a/homeassistant/components/gate/strings.json b/homeassistant/components/gate/strings.json index 5eb93fcaebd148..02b21af3d7c74a 100644 --- a/homeassistant/components/gate/strings.json +++ b/homeassistant/components/gate/strings.json @@ -1,6 +1,7 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "trigger_behavior_name": "Trigger when", "trigger_for_name": "For at least" }, @@ -10,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::gate::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::gate::common::condition_for_name%]" } }, "name": "Gate is closed" @@ -19,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::gate::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::gate::common::condition_for_name%]" } }, "name": "Gate is open" diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index f27dad3bd70adb..db8cdb41191ec3 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -67,7 +67,7 @@ RECOMMENDED_VERSION, ) from .server import Server -from .util import get_go2rtc_unix_socket_path +from .util import get_camera_identifier, get_go2rtc_unix_socket_path _LOGGER = logging.getLogger(__name__) @@ -308,7 +308,7 @@ async def async_handle_async_webrtc_offer( return self._sessions[session_id] = ws_client = Go2RtcWsClient( - self._session, self._url, source=camera.entity_id + self._session, self._url, source=get_camera_identifier(camera) ) @callback @@ -354,7 +354,7 @@ async def async_get_image( """Get an image from the camera.""" await self._update_stream_source(camera) return await self._rest_client.get_jpeg_snapshot( - camera.entity_id, width, height + get_camera_identifier(camera), width, height ) async def _update_stream_source(self, camera: Camera) -> None: @@ -399,18 +399,19 @@ async def _update_stream_source(self, camera: Camera) -> None: stream_source += "#rotate=90" streams = await self._rest_client.streams.list() + identifier = get_camera_identifier(camera) - if (stream := streams.get(camera.entity_id)) is None or not any( + if (stream := streams.get(identifier)) is None or not any( stream_source == producer.url for producer in stream.producers ): await self._rest_client.streams.add( - camera.entity_id, + identifier, [ stream_source, # We are setting any ffmpeg rtsp related logs to debug # Connection problems to the camera will be logged by the first stream # Therefore setting it to debug will not hide any important logs - f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + f"ffmpeg:{identifier}#audio=opus#query=log_level=debug", ], ) diff --git a/homeassistant/components/go2rtc/util.py b/homeassistant/components/go2rtc/util.py index 6e47075dbf90be..a19f57f4383243 100644 --- a/homeassistant/components/go2rtc/util.py +++ b/homeassistant/components/go2rtc/util.py @@ -1,8 +1,15 @@ """Go2rtc utility functions.""" from pathlib import Path +import string +from urllib.parse import quote + +from homeassistant.components.camera import Camera _HA_MANAGED_UNIX_SOCKET_FILE = "go2rtc.sock" +# Go2rtc is not validating the camera identifier, but some characters (e.g. : or #) +# have special meaning in URLs and could cause issues. +_SAFE_CHARS = string.ascii_letters + string.digits + "._-" def get_go2rtc_unix_socket_path(path: str | Path) -> str: @@ -10,3 +17,11 @@ def get_go2rtc_unix_socket_path(path: str | Path) -> str: if not isinstance(path, Path): path = Path(path) return str(path / _HA_MANAGED_UNIX_SOCKET_FILE) + + +def get_camera_identifier(camera: Camera) -> str: + """Get the Go2rtc camera identifier.""" + attr = camera.entity_id + if camera.unique_id is not None: + attr = f"{camera.platform.platform_name}_{camera.unique_id}" + return quote(attr, safe=_SAFE_CHARS) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index ddd9f20377d795..71edb8741d39c2 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -3,38 +3,28 @@ from __future__ import annotations from functools import partial -from pathlib import Path from types import MappingProxyType from google.genai import Client from google.genai.errors import APIError, ClientError from requests.exceptions import Timeout -import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_API_KEY, Platform -from homeassistant.core import ( - HomeAssistant, - ServiceCall, - ServiceResponse, - SupportsResponse, -) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady, - HomeAssistantError, ) from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, - issue_registry as ir, ) from homeassistant.helpers.typing import ConfigType from .const import ( - CONF_PROMPT, DEFAULT_AI_TASK_NAME, DEFAULT_STT_NAME, DEFAULT_TITLE, @@ -47,11 +37,6 @@ RECOMMENDED_TTS_OPTIONS, TIMEOUT_MILLIS, ) -from .entity import async_prepare_files_for_prompt - -SERVICE_GENERATE_CONTENT = "generate_content" -CONF_IMAGE_FILENAME = "image_filename" -CONF_FILENAMES = "filenames" CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = ( @@ -69,88 +54,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await async_migrate_integration(hass) - async def generate_content(call: ServiceCall) -> ServiceResponse: - """Generate content from text and optionally images.""" - LOGGER.warning( - "Action '%s.%s' is deprecated and will be removed in the 2026.4.0 release. " - "Please use the 'ai_task.generate_data' action instead", - DOMAIN, - SERVICE_GENERATE_CONTENT, - ) - ir.async_create_issue( - hass, - DOMAIN, - "deprecated_generate_content", - breaks_in_ha_version="2026.4.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_generate_content", - ) - - prompt_parts = [call.data[CONF_PROMPT]] - - config_entry: GoogleGenerativeAIConfigEntry = ( - hass.config_entries.async_loaded_entries(DOMAIN)[0] - ) - - client = config_entry.runtime_data - - files = call.data[CONF_FILENAMES] - - if files: - for filename in files: - if not hass.config.is_allowed_path(filename): - raise HomeAssistantError( - f"Cannot read `{filename}`, no access to path; " - "`allowlist_external_dirs` may need to be adjusted in " - "`configuration.yaml`" - ) - - prompt_parts.extend( - await async_prepare_files_for_prompt( - hass, client, [(Path(filename), None) for filename in files] - ) - ) - - try: - response = await client.aio.models.generate_content( - model=RECOMMENDED_CHAT_MODEL, contents=prompt_parts - ) - except ( - APIError, - ValueError, - ) as err: - raise HomeAssistantError(f"Error generating content: {err}") from err - - if response.prompt_feedback: - raise HomeAssistantError( - f"Error generating content due to content violations, reason: {response.prompt_feedback.block_reason_message}" - ) - - if ( - not response.candidates - or not response.candidates[0].content - or not response.candidates[0].content.parts - ): - raise HomeAssistantError("Unknown error generating content") - - return {"text": response.text} - - hass.services.async_register( - DOMAIN, - SERVICE_GENERATE_CONTENT, - generate_content, - schema=vol.Schema( - { - vol.Required(CONF_PROMPT): cv.string, - vol.Optional(CONF_FILENAMES, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - } - ), - supports_response=SupportsResponse.ONLY, - description_placeholders={"example_image_path": "/config/www/image.jpg"}, - ) return True diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index fba51dcd7ef2f8..a347c88a0d6852 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -338,6 +338,7 @@ def _convert_content( async def _transform_stream( + chat_log: conversation.ChatLog, result: AsyncIterator[GenerateContentResponse], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: new_message = True @@ -346,6 +347,19 @@ async def _transform_stream( async for response in result: LOGGER.debug("Received response chunk: %s", response) + if (usage := response.usage_metadata) is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": usage.prompt_token_count, + "cached_input_tokens": ( + usage.cached_content_token_count or 0 + ), + "output_tokens": usage.candidates_token_count, + } + } + ) + if new_message: if part_details: yield {"native": ContentDetails(part_details=part_details)} @@ -623,7 +637,7 @@ async def _async_handle_chat_log( content async for content in chat_log.async_add_delta_content_stream( self.entity_id, - _transform_stream(chat_response_generator), + _transform_stream(chat_log, chat_response_generator), ) if isinstance(content, conversation.ToolResultContent) ] diff --git a/homeassistant/components/google_generative_ai_conversation/icons.json b/homeassistant/components/google_generative_ai_conversation/icons.json deleted file mode 100644 index 6ac3cc3b21c57b..00000000000000 --- a/homeassistant/components/google_generative_ai_conversation/icons.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "services": { - "generate_content": { - "service": "mdi:receipt-text" - } - } -} diff --git a/homeassistant/components/google_generative_ai_conversation/services.yaml b/homeassistant/components/google_generative_ai_conversation/services.yaml deleted file mode 100644 index 30077dec6507c3..00000000000000 --- a/homeassistant/components/google_generative_ai_conversation/services.yaml +++ /dev/null @@ -1,12 +0,0 @@ -generate_content: - fields: - prompt: - required: true - selector: - text: - multiline: true - filenames: - required: false - selector: - text: - multiple: true diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index b74babe70859ed..bd5ef1e968f8d9 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -149,29 +149,5 @@ } } } - }, - "issues": { - "deprecated_generate_content": { - "description": "Action 'google_generative_ai_conversation.generate_content' is deprecated and will be removed in the 2026.4.0 release. Please use the 'ai_task.generate_data' action instead", - "title": "Deprecated 'generate_content' action" - } - }, - "services": { - "generate_content": { - "description": "Generate content from a prompt consisting of text and optionally images (deprecated)", - "fields": { - "filenames": { - "description": "Attachments to add to the prompt (images, PDFs, etc)", - "example": "{example_image_path}", - "name": "Attachment filenames" - }, - "prompt": { - "description": "The prompt", - "example": "Describe what you see in these images", - "name": "Prompt" - } - }, - "name": "Generate content (deprecated)" - } } } diff --git a/homeassistant/components/green_planet_energy/sensor.py b/homeassistant/components/green_planet_energy/sensor.py index dac92b8c4e1157..2bb9cc01b33fde 100644 --- a/homeassistant/components/green_planet_energy/sensor.py +++ b/homeassistant/components/green_planet_energy/sensor.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta import logging from typing import Any @@ -36,6 +36,40 @@ class GreenPlanetEnergySensorEntityDescription(SensorEntityDescription): value_fn: Callable[[GreenPlanetEnergyAPI, dict[str, Any]], float | datetime | None] +def _get_lowest_price_day_time( + api: GreenPlanetEnergyAPI, data: dict[str, Any] +) -> datetime | None: + """Return timestamp of the lowest-priced day hour (06:00–18:00).""" + now = dt_util.now() + now_h = now.hour + hour = api.get_lowest_price_day_with_hour(data, now_h)[1] + if hour is None: + return None + # After 18:00 the day period is over; use tomorrow's date + base = dt_util.start_of_local_day(now + timedelta(days=1) if now_h >= 18 else now) + return base.replace(hour=hour) + + +def _get_lowest_price_night_time( + api: GreenPlanetEnergyAPI, data: dict[str, Any] +) -> datetime | None: + """Return timestamp of the lowest-priced night hour (18:00-06:00).""" + now = dt_util.now() + now_h = now.hour + hour = api.get_lowest_price_night_with_hour(data)[1] + if hour is None: + return None + + if now_h < 6: + base = dt_util.start_of_local_day( + now - timedelta(days=1) if hour >= 18 else now + ) + else: + base = dt_util.start_of_local_day(now + timedelta(days=1) if hour < 6 else now) + + return base.replace(hour=hour) + + SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [ # Statistical sensors only - hourly prices available via service GreenPlanetEnergySensorEntityDescription( @@ -67,7 +101,7 @@ class GreenPlanetEnergySensorEntityDescription(SensorEntityDescription): translation_placeholders={"time_range": "(06:00-18:00)"}, value_fn=lambda api, data: ( price / 100 - if (price := api.get_lowest_price_day(data)) is not None + if (price := api.get_lowest_price_day(data, dt_util.now().hour)) is not None else None ), ), @@ -76,11 +110,7 @@ class GreenPlanetEnergySensorEntityDescription(SensorEntityDescription): translation_key="lowest_price_day_time", device_class=SensorDeviceClass.TIMESTAMP, translation_placeholders={"time_range": "(06:00-18:00)"}, - value_fn=lambda api, data: ( - dt_util.start_of_local_day().replace(hour=hour) - if (hour := api.get_lowest_price_day_with_hour(data)[1]) is not None - else None - ), + value_fn=_get_lowest_price_day_time, ), GreenPlanetEnergySensorEntityDescription( key="gpe_lowest_price_night", @@ -99,11 +129,7 @@ class GreenPlanetEnergySensorEntityDescription(SensorEntityDescription): translation_key="lowest_price_night_time", device_class=SensorDeviceClass.TIMESTAMP, translation_placeholders={"time_range": "(18:00-06:00)"}, - value_fn=lambda api, data: ( - dt_util.start_of_local_day().replace(hour=hour) - if (hour := api.get_lowest_price_night_with_hour(data)[1]) is not None - else None - ), + value_fn=_get_lowest_price_night_time, ), GreenPlanetEnergySensorEntityDescription( key="gpe_current_price", diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py index 2a88788a2b5bc8..92dcc6435f91e2 100644 --- a/homeassistant/components/hassio/addon_panel.py +++ b/homeassistant/components/hassio/addon_panel.py @@ -8,7 +8,7 @@ from aiohttp import web from homeassistant.components import frontend -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.core import HomeAssistant from .handler import get_supervisor_client @@ -43,6 +43,7 @@ def __init__(self, hass: HomeAssistant) -> None: self.hass = hass self.client = get_supervisor_client(hass) + @require_admin async def post(self, request: web.Request, addon: str) -> web.Response: """Handle new add-on panel requests.""" panels = await self.get_panels() @@ -56,6 +57,7 @@ async def post(self, request: web.Request, addon: str) -> web.Response: _register_panel(self.hass, addon, panels[addon]) return web.Response() + @require_admin async def delete(self, request: web.Request, addon: str) -> web.Response: """Handle remove add-on panel requests.""" frontend.async_remove_panel(self.hass, addon) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 8ae79dfb5f1460..aa635b7464a943 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -72,8 +72,6 @@ X_AUTH_TOKEN = "X-Supervisor-Token" X_INGRESS_PATH = "X-Ingress-Path" -X_HASS_USER_ID = "X-Hass-User-ID" -X_HASS_IS_ADMIN = "X-Hass-Is-Admin" X_HASS_SOURCE = "X-Hass-Source" WS_TYPE = "type" diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 1973984d878b0c..58cbccd3769c7f 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -13,7 +13,7 @@ from aiohttp.web_exceptions import HTTPServiceUnavailable from homeassistant import config_entries -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.const import ATTR_SERVICE, EVENT_HOMEASSISTANT_START from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import discovery_flow @@ -82,6 +82,7 @@ def __init__(self, hass: HomeAssistant) -> None: self.hass = hass self._supervisor_client = get_supervisor_client(hass) + @require_admin async def post(self, request: web.Request, uuid: str) -> web.Response: """Handle new discovery requests.""" # Fetch discovery data and prevent injections @@ -94,6 +95,7 @@ async def post(self, request: web.Request, uuid: str) -> web.Response: await self.async_process_new(data) return web.Response() + @require_admin async def delete(self, request: web.Request, uuid: str) -> web.Response: """Handle remove discovery requests.""" data: dict[str, Any] = await request.json() diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 44a273e5e887db..8e50841342e4a4 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -24,11 +24,9 @@ from homeassistant.components.http import ( KEY_AUTHENTICATED, - KEY_HASS, KEY_HASS_USER, HomeAssistantView, ) -from homeassistant.components.onboarding import async_is_onboarded from .const import X_HASS_SOURCE @@ -53,16 +51,7 @@ r")$" ) -# fmt: off -# Onboarding can upload backups and restore it -PATHS_NOT_ONBOARDED = re.compile( - r"^(?:" - r"|backups/[a-f0-9]{8}(/info|/new/upload|/download|/restore/full|/restore/partial)?" - r"|backups/new/upload" - r")$" -) - -# Authenticated users manage backups + download logs, changelog and documentation +# Admin users manage backups + download logs, changelog and documentation PATHS_ADMIN = re.compile( r"^(?:" r"|backups/[a-f0-9]{8}(/info|/download|/restore/full|/restore/partial)?" @@ -142,27 +131,19 @@ async def _handle(self, request: web.Request, path: str) -> web.StreamResponse: """Return a client request with proxy origin for Hass.io supervisor. Use cases: - - Onboarding allows restoring backups - Load Supervisor panel and add-on logo unauthenticated - - User upload/restore backups + - Admin users upload/restore backups and access logs """ # No bullshit if path != unquote(path): return web.Response(status=HTTPStatus.BAD_REQUEST) - hass = request.app[KEY_HASS] is_admin = request[KEY_AUTHENTICATED] and request[KEY_HASS_USER].is_admin authorized = is_admin if is_admin: allowed_paths = PATHS_ADMIN - elif not async_is_onboarded(hass): - allowed_paths = PATHS_NOT_ONBOARDED - - # During onboarding we need the user to manage backups - authorized = True - else: # Either unauthenticated or not an admin allowed_paths = PATHS_NO_AUTH diff --git a/homeassistant/components/hassio/services.py b/homeassistant/components/hassio/services.py index 4c2cc98b387cd0..c141015e4a2ba1 100644 --- a/homeassistant/components/hassio/services.py +++ b/homeassistant/components/hassio/services.py @@ -29,6 +29,7 @@ device_registry as dr, selector, ) +from homeassistant.helpers.service import async_register_admin_service from homeassistant.util.dt import now from .const import ( @@ -196,8 +197,8 @@ async def async_simple_app_service_handler(service: ServiceCall) -> None: ) from err for service in simple_app_services: - hass.services.async_register( - DOMAIN, service, async_simple_app_service_handler, schema=SCHEMA_APP + async_register_admin_service( + hass, DOMAIN, service, async_simple_app_service_handler, schema=SCHEMA_APP ) async def async_app_stdin_service_handler(service: ServiceCall) -> None: @@ -220,7 +221,8 @@ async def async_app_stdin_service_handler(service: ServiceCall) -> None: f"Failed to write stdin to app {app_slug}: {err}" ) from err - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_APP_STDIN, async_app_stdin_service_handler, @@ -247,8 +249,12 @@ async def async_simple_addon_service_handler(service: ServiceCall) -> None: ) from err for service in simple_addon_services: - hass.services.async_register( - DOMAIN, service, async_simple_addon_service_handler, schema=SCHEMA_ADDON + async_register_admin_service( + hass, + DOMAIN, + service, + async_simple_addon_service_handler, + schema=SCHEMA_ADDON, ) async def async_addon_stdin_service_handler(service: ServiceCall) -> None: @@ -267,7 +273,8 @@ async def async_addon_stdin_service_handler(service: ServiceCall) -> None: f"Failed to write stdin to app {addon_slug}: {err}" ) from err - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_ADDON_STDIN, async_addon_stdin_service_handler, @@ -294,8 +301,12 @@ async def async_simple_host_service_handler(service: ServiceCall) -> None: raise HomeAssistantError(f"Failed to {action} the host: {err}") from err for service in simple_host_services: - hass.services.async_register( - DOMAIN, service, async_simple_host_service_handler, schema=SCHEMA_NO_DATA + async_register_admin_service( + hass, + DOMAIN, + service, + async_simple_host_service_handler, + schema=SCHEMA_NO_DATA, ) @@ -319,7 +330,8 @@ async def async_full_backup_service_handler( return {"backup": backup.slug} - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_BACKUP_FULL, async_full_backup_service_handler, @@ -345,7 +357,8 @@ async def async_partial_backup_service_handler( return {"backup": backup.slug} - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_BACKUP_PARTIAL, async_partial_backup_service_handler, @@ -367,7 +380,8 @@ async def async_full_restore_service_handler(service: ServiceCall) -> None: f"Failed to full restore from backup {backup_slug}: {err}" ) from err - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_RESTORE_FULL, async_full_restore_service_handler, @@ -389,7 +403,8 @@ async def async_partial_restore_service_handler(service: ServiceCall) -> None: f"Failed to partial restore from backup {backup_slug}: {err}" ) from err - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_RESTORE_PARTIAL, async_partial_restore_service_handler, @@ -434,6 +449,6 @@ async def async_mount_reload(service: ServiceCall) -> None: translation_placeholders={"name": device.name, "error": str(error)}, ) from error - hass.services.async_register( - DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD + async_register_admin_service( + hass, DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD ) diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 7adae8d87465e4..92838755cd002e 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -87,7 +87,18 @@ async def async_setup_entry( class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): - """Update entity to handle updates for the Supervisor add-ons.""" + """Update entity to handle updates for the Supervisor add-ons. + + The ``addon_manager_update`` job emits a ``done=True`` WS event as soon as + Supervisor finishes the container work, a few milliseconds before the + ``/store/addons//update`` HTTP call returns. If we clear + ``_attr_in_progress`` on that event while the coordinator data still + carries the pre-update version, the UI briefly flips back to + "Update available" before ``async_install`` can refresh. ``_update_ongoing`` + survives both the WS done event and the base ``UpdateEntity`` reset, so + the installing state remains until the coordinator confirms a new + ``installed_version``. + """ _attr_supported_features = ( UpdateEntityFeature.INSTALL @@ -95,6 +106,8 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): | UpdateEntityFeature.RELEASE_NOTES | UpdateEntityFeature.PROGRESS ) + _update_ongoing: bool = False + _version_before_update: str | None = None @property def _addon_data(self) -> dict: @@ -121,6 +134,13 @@ def installed_version(self) -> str | None: """Version installed and in use.""" return self._addon_data[ATTR_VERSION] + @property + def in_progress(self) -> bool | None: + """Return combined progress from the update job and refresh phase.""" + if self._update_ongoing: + return True + return self._attr_in_progress + @property def entity_picture(self) -> str | None: """Return the icon of the add-on if any.""" @@ -154,13 +174,34 @@ async def async_install( **kwargs: Any, ) -> None: """Install an update.""" + self._version_before_update = self.installed_version + self._update_ongoing = True self._attr_in_progress = True self.async_write_ha_state() - await update_addon( - self.hass, self._addon_slug, backup, self.title, self.installed_version - ) + try: + await update_addon( + self.hass, self._addon_slug, backup, self.title, self.installed_version + ) + except HomeAssistantError: + self._update_ongoing = False + self._version_before_update = None + self._attr_in_progress = False + self._attr_update_percentage = None + self.async_write_ha_state() + raise await self.coordinator.async_refresh() + @callback + def _handle_coordinator_update(self) -> None: + """Clear the ongoing flag once the installed version has changed.""" + if ( + self._update_ongoing + and self.installed_version != self._version_before_update + ): + self._update_ongoing = False + self._version_before_update = None + super()._handle_coordinator_update() + @callback def _update_job_changed(self, job: Job) -> None: """Process update for this entity's update job.""" diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 21b8dbf8e124fd..4362eca19859b1 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -47,15 +47,15 @@ extra=vol.ALLOW_EXTRA, ) -# Endpoints needed for ingress can't require admin because addons can set `panel_admin: false` -# fmt: off +# Endpoints needed for ingress can't require admin because add-ons can set `panel_admin: false` +RE_ADDONS_INFO_ENDPOINT = r"/addons/[^/]+/info" +WS_ADDONS_INFO_ENDPOINT = re.compile(r"^" + RE_ADDONS_INFO_ENDPOINT + r"$") WS_NO_ADMIN_ENDPOINTS = re.compile( r"^(?:" - r"|/ingress/(session|validate_session)" - r"|/addons/[^/]+/info" + r"/ingress/(session|validate_session)" + f"|{RE_ADDONS_INFO_ENDPOINT}" r")$" ) -# fmt: on _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -92,6 +92,7 @@ def forward_messages(data: dict[str, str]) -> None: @callback +@websocket_api.ws_require_user(only_supervisor=True) @websocket_api.websocket_command( { vol.Required(WS_TYPE): WS_TYPE_EVENT, @@ -150,7 +151,12 @@ async def websocket_supervisor_api( msg[WS_ID], code=websocket_api.ERR_UNKNOWN_ERROR, message=str(err) ) else: - connection.send_result(msg[WS_ID], result.get(ATTR_DATA, {})) + data = result.get(ATTR_DATA, {}) + # Remove options from add-on info for non-admin users, as options can contain + # sensitive information and the frontend does not require it for ingress. + if not connection.user.is_admin and WS_ADDONS_INFO_ENDPOINT.match(command): + data.pop("options", None) + connection.send_result(msg[WS_ID], data) @websocket_api.require_admin diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 03f30da74a1866..7bb5d03af95208 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.94", "babel==2.15.0"] + "requirements": ["holidays==0.95", "babel==2.15.0"] } diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 3ae43ded4b13d7..9c3ad877d994d8 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -637,16 +637,19 @@ async def update_options(self, program_key: ProgramKey) -> None: options.update(await self.get_options_definitions(resolved_program_key)) for option in options.values(): - option_value = option.constraints.default if option.constraints else None - if option_value is not None: - option_event_key = EventKey(option.key) + option_event_key = EventKey(option.key) + if ( + option_event_key not in events + and option.constraints + and (option_default_value := option.constraints.default) is not None + ): events[option_event_key] = Event( option_event_key, option.key.value, 0, "", "", - option_value, + option_default_value, option.name, unit=option.unit, ) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 419b1ce1fc77e6..b87ac7d8136304 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -94,7 +94,7 @@ "title": "[%key:component::homeassistant::issues::config_entry_unique_id_collision::title%]" }, "country_not_configured": { - "description": "No country has been configured, please update the configuration by clicking on the \"learn more\" button below.", + "description": "No country has been configured. Click the \"Learn more\" button below to set your country.", "title": "The country has not been configured" }, "deprecated_architecture": { diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index 27c63742f7b883..0fc5618c122fb3 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -225,7 +225,7 @@ def update_entity_trigger( elif ( new_state.domain == "sensor" and new_state.attributes.get(ATTR_DEVICE_CLASS) - == sensor.SensorDeviceClass.TIMESTAMP + in (sensor.SensorDeviceClass.TIMESTAMP, sensor.SensorDeviceClass.UPTIME) and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) ): trigger_dt = dt_util.parse_datetime(new_state.state) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 41d965fab11064..4c64fdb5f42d7b 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -26,7 +26,9 @@ ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType +from homeassistant.util.async_ import run_callback_threadsafe from .const import ( ATTR_ADDRESS, @@ -381,12 +383,15 @@ def _service_handle_install_mode(service: ServiceCall) -> None: homematic.setInstallMode(interface, t=time, mode=mode, address=address) - hass.services.register( + run_callback_threadsafe( + hass.loop, + async_register_admin_service, + hass, DOMAIN, SERVICE_SET_INSTALL_MODE, _service_handle_install_mode, - schema=SCHEMA_SERVICE_SET_INSTALL_MODE, - ) + SCHEMA_SERVICE_SET_INSTALL_MODE, + ).result() def _service_put_paramset(service: ServiceCall) -> None: """Service to call the putParamset method on a HomeMatic connection.""" diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 30038d1f8977ed..e3c242275429fc 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -1,5 +1,9 @@ """Support for HomematicIP Cloud devices.""" +from __future__ import annotations + +import logging + import voluptuous as vol from homeassistant import config_entries @@ -21,8 +25,11 @@ HMIPC_NAME, ) from .hap import HomematicIPConfigEntry, HomematicipHAP +from .migration import _migrate_unique_id from .services import async_setup_services +_LOGGER = logging.getLogger(__name__) + CONFIG_SCHEMA = vol.Schema( { vol.Optional(DOMAIN, default=[]): vol.All( @@ -85,8 +92,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomematicIPConfigEntry) if not await hap.async_setup(): return False - _async_remove_obsolete_entities(hass, entry, hap) - # Register on HA stop event to gracefully shutdown HomematicIP Cloud connection hap.reset_connection_listener = hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, hap.shutdown @@ -119,22 +124,61 @@ async def async_unload_entry( return await hap.async_reset() -@callback -def _async_remove_obsolete_entities( - hass: HomeAssistant, entry: HomematicIPConfigEntry, hap: HomematicipHAP -): - """Remove obsolete entities from entity registry.""" +async def async_migrate_entry( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> bool: + """Migrate the config entry from version 1 to version 2.""" + if config_entry.version > 2: + return False + + if config_entry.version == 1: + _LOGGER.debug("Migrating HomematicIP Cloud config entry to version 2") + + # Remove obsolete entities before the bulk unique_id rewrite. + # After rewrite, old-format patterns would no longer be matchable. + # HomematicipAccesspointStatus* entities are always obsolete (removed + # in firmware 2.2.12+). HomematicipBatterySensor_{hapid} entities for + # access points are also obsolete. Those legacy access point battery + # entities do not belong to a device registry device, unlike real + # device battery sensors, so we can safely remove them before rewrite. + entity_registry = er.async_get(hass) + entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + for entry in entries: + if entry.unique_id.startswith("HomematicipAccesspointStatus") or ( + entry.unique_id.startswith("HomematicipBatterySensor_") + and entry.device_id is None + ): + _LOGGER.debug( + "Removing obsolete entity: %s (%s)", + entry.entity_id, + entry.unique_id, + ) + entity_registry.async_remove(entry.entity_id) + + @callback + def _update_unique_id( + entity_entry: er.RegistryEntry, + ) -> dict[str, str] | None: + new_unique_id = _migrate_unique_id(entity_entry.unique_id) + if new_unique_id is None: + _LOGGER.debug( + "Skipping unique_id %s (already stable format)", + entity_entry.unique_id, + ) + return None + _LOGGER.debug( + "Migrating %s: %s -> %s", + entity_entry.entity_id, + entity_entry.unique_id, + new_unique_id, + ) + return {"new_unique_id": new_unique_id} - if hap.home.currentAPVersion < "2.2.12": - return + await er.async_migrate_entries(hass, config_entry.entry_id, _update_unique_id) - entity_registry = er.async_get(hass) - er_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) - for er_entry in er_entries: - if er_entry.unique_id.startswith("HomematicipAccesspointStatus"): - entity_registry.async_remove(er_entry.entity_id) - continue + hass.config_entries.async_update_entry(config_entry, version=2) + _LOGGER.info("Migration to version 2 successful") - for hapid in hap.home.accessPointUpdateStates: - if er_entry.unique_id == f"HomematicipBatterySensor_{hapid}": - entity_registry.async_remove(er_entry.entity_id) + return True diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index ddfe10fba54b89..1807405ffa00a0 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -42,6 +42,7 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.ARM_AWAY ) _attr_code_arm_required = False + _feature_id = "alarm" def __init__(self, hap: HomematicipHAP) -> None: """Initialize the alarm control panel.""" @@ -127,4 +128,4 @@ def available(self) -> bool: @property def unique_id(self) -> str: """Return a unique ID.""" - return f"{self.__class__.__name__}_{self._home.id}" + return f"{self._home.id}_{self._feature_id}" diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index d3b164209cebe5..7c14056e0fe469 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -179,7 +179,7 @@ class HomematicipCloudConnectionSensor(HomematicipGenericEntity, BinarySensorEnt def __init__(self, hap: HomematicipHAP) -> None: """Initialize the cloud connection sensor.""" - super().__init__(hap, hap.home) + super().__init__(hap, hap.home, feature_id="cloud_connection") @property def name(self) -> str: @@ -245,10 +245,18 @@ def extra_state_attributes(self) -> dict[str, Any]: class HomematicipAccelerationSensor(HomematicipBaseActionSensor): """Representation of the HomematicIP acceleration sensor.""" + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the acceleration sensor.""" + super().__init__(hap, device, feature_id="acceleration") + class HomematicipTiltVibrationSensor(HomematicipBaseActionSensor): """Representation of the HomematicIP tilt vibration sensor.""" + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the tilt vibration sensor.""" + super().__init__(hap, device, feature_id="tilt_vibration") + class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP multi room/area contact interface.""" @@ -262,6 +270,7 @@ def __init__( channel=1, is_multi_channel=True, channel_real_index=None, + feature_id: str = "contact", ) -> None: """Initialize the multi contact entity.""" super().__init__( @@ -270,6 +279,7 @@ def __init__( channel=channel, is_multi_channel=is_multi_channel, channel_real_index=channel_real_index, + feature_id=feature_id, ) @property @@ -286,7 +296,7 @@ class HomematicipContactInterface(HomematicipMultiContactInterface, BinarySensor def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the multi contact entity.""" - super().__init__(hap, device, is_multi_channel=False) + super().__init__(hap, device, is_multi_channel=False, feature_id="contact") class HomematicipShutterContact(HomematicipMultiContactInterface, BinarySensorEntity): @@ -298,7 +308,9 @@ def __init__( self, hap: HomematicipHAP, device, has_additional_state: bool = False ) -> None: """Initialize the shutter contact.""" - super().__init__(hap, device, is_multi_channel=False) + super().__init__( + hap, device, is_multi_channel=False, feature_id="shutter_contact" + ) self.has_additional_state = has_additional_state @property @@ -319,6 +331,10 @@ class HomematicipMotionDetector(HomematicipGenericEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOTION + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the motion detector.""" + super().__init__(hap, device, feature_id="motion") + @property def is_on(self) -> bool: """Return true if motion is detected.""" @@ -334,7 +350,7 @@ class HomematicipFullFlushLockControllerLocked( def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the full flush lock controller lock sensor.""" - super().__init__(hap, device, post="Locked") + super().__init__(hap, device, post="Locked", feature_id="lock_locked") @property def is_on(self) -> bool: @@ -359,7 +375,7 @@ class HomematicipFullFlushLockControllerGlassBreak( def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the full flush lock controller glass break sensor.""" - super().__init__(hap, device, post="Glass break") + super().__init__(hap, device, post="Glass break", feature_id="glass_break") @property def is_on(self) -> bool: @@ -379,6 +395,10 @@ class HomematicipPresenceDetector(HomematicipGenericEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.PRESENCE + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the presence detector.""" + super().__init__(hap, device, feature_id="presence") + @property def is_on(self) -> bool: """Return true if presence is detected.""" @@ -390,6 +410,10 @@ class HomematicipSmokeDetector(HomematicipGenericEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.SMOKE + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the smoke detector.""" + super().__init__(hap, device, feature_id="smoke") + @property def is_on(self) -> bool: """Return true if smoke is detected.""" @@ -410,7 +434,9 @@ class HomematicipSmokeDetectorChamberDegraded( def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize smoke detector chamber health sensor.""" - super().__init__(hap, device, post="Chamber Degraded") + super().__init__( + hap, device, post="Chamber Degraded", feature_id="chamber_degraded" + ) @property def is_on(self) -> bool: @@ -423,6 +449,10 @@ class HomematicipWaterDetector(HomematicipGenericEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOISTURE + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the water detector.""" + super().__init__(hap, device, feature_id="water") + @property def is_on(self) -> bool: """Return true, if moisture or waterlevel is detected.""" @@ -434,7 +464,7 @@ class HomematicipStormSensor(HomematicipGenericEntity, BinarySensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize storm sensor.""" - super().__init__(hap, device, "Storm") + super().__init__(hap, device, "Storm", feature_id="storm") @property def icon(self) -> str: @@ -454,7 +484,7 @@ class HomematicipRainSensor(HomematicipGenericEntity, BinarySensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize rain sensor.""" - super().__init__(hap, device, "Raining") + super().__init__(hap, device, "Raining", feature_id="rain") @property def is_on(self) -> bool: @@ -469,7 +499,7 @@ class HomematicipSunshineSensor(HomematicipGenericEntity, BinarySensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize sunshine sensor.""" - super().__init__(hap, device, post="Sunshine") + super().__init__(hap, device, post="Sunshine", feature_id="sunshine") @property def is_on(self) -> bool: @@ -495,7 +525,7 @@ class HomematicipBatterySensor(HomematicipGenericEntity, BinarySensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize battery sensor.""" - super().__init__(hap, device, post="Battery") + super().__init__(hap, device, post="Battery", channel=0, feature_id="battery") @property def is_on(self) -> bool: @@ -512,7 +542,7 @@ class HomematicipPluggableMainsFailureSurveillanceSensor( def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize pluggable mains failure surveillance sensor.""" - super().__init__(hap, device) + super().__init__(hap, device, feature_id="mains_failure") @property def is_on(self) -> bool: @@ -525,10 +555,16 @@ class HomematicipSecurityZoneSensorGroup(HomematicipGenericEntity, BinarySensorE _attr_device_class = BinarySensorDeviceClass.SAFETY - def __init__(self, hap: HomematicipHAP, device, post: str = "SecurityZone") -> None: + def __init__( + self, + hap: HomematicipHAP, + device, + post: str = "SecurityZone", + feature_id: str = "security_zone", + ) -> None: """Initialize security zone group.""" device.modelType = f"HmIP-{post}" - super().__init__(hap, device, post=post) + super().__init__(hap, device, post=post, feature_id=feature_id) @property def available(self) -> bool: @@ -578,7 +614,7 @@ class HomematicipSecuritySensorGroup( def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize security group.""" - super().__init__(hap, device, post="Sensors") + super().__init__(hap, device, post="Sensors", feature_id="security") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/homematicip_cloud/button.py b/homeassistant/components/homematicip_cloud/button.py index bcd157d44d6be7..96ed3ae77e9984 100644 --- a/homeassistant/components/homematicip_cloud/button.py +++ b/homeassistant/components/homematicip_cloud/button.py @@ -45,7 +45,7 @@ class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEnti def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize a wall mounted garage door controller.""" - super().__init__(hap, device) + super().__init__(hap, device, feature_id="garage_button") self._attr_icon = "mdi:arrow-up-down" async def async_press(self) -> None: @@ -58,7 +58,9 @@ class HomematicipFullFlushLockControllerButton(HomematicipGenericEntity, ButtonE def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the full flush lock controller opener button.""" - super().__init__(hap, device, post="Door opener") + super().__init__( + hap, device, post="Door opener", feature_id="lock_opener_button" + ) self._attr_icon = "mdi:door-open" async def async_press(self) -> None: diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 689bce9243f4ba..881cf4878bb392 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -83,7 +83,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): def __init__(self, hap: HomematicipHAP, device: HeatingGroup) -> None: """Initialize heating group.""" device.modelType = "HmIP-Heating-Group" - super().__init__(hap, device) + super().__init__(hap, device, feature_id="climate") self._simple_heating = None if device.actualTemperature is None: self._simple_heating = self._first_radiator_thermostat diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py index 3a8614b99592e4..144770abfa6364 100644 --- a/homeassistant/components/homematicip_cloud/config_flow.py +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -16,7 +16,7 @@ class HomematicipCloudFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for the HomematicIP Cloud component.""" - VERSION = 1 + VERSION = 2 auth: HomematicipAuth diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index a8070c455d1aff..e926d2212c2808 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -69,6 +69,10 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): _attr_device_class = CoverDeviceClass.BLIND + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the blind module entity.""" + super().__init__(hap, device, feature_id="blind") + @property def current_cover_position(self) -> int | None: """Return current position of cover.""" @@ -153,10 +157,15 @@ def __init__( device, channel=1, is_multi_channel=True, + feature_id="shutter", ) -> None: """Initialize the multi cover entity.""" super().__init__( - hap, device, channel=channel, is_multi_channel=is_multi_channel + hap, + device, + channel=channel, + is_multi_channel=is_multi_channel, + feature_id=feature_id, ) @property @@ -218,7 +227,11 @@ def __init__( ) -> None: """Initialize the multi slats entity.""" super().__init__( - hap, device, channel=channel, is_multi_channel=is_multi_channel + hap, + device, + channel=channel, + is_multi_channel=is_multi_channel, + feature_id="slats", ) @property @@ -269,6 +282,10 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): _attr_device_class = CoverDeviceClass.GARAGE + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the garage door module entity.""" + super().__init__(hap, device, feature_id="garage_door") + @property def current_cover_position(self) -> int | None: """Return current position of cover.""" @@ -310,7 +327,9 @@ class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): def __init__(self, hap: HomematicipHAP, device, post: str = "ShutterGroup") -> None: """Initialize switching group.""" device.modelType = f"HmIP-{post}" - super().__init__(hap, device, post, is_multi_channel=False) + super().__init__( + hap, device, post, is_multi_channel=False, feature_id="shutter" + ) @property def available(self) -> bool: diff --git a/homeassistant/components/homematicip_cloud/entity.py b/homeassistant/components/homematicip_cloud/entity.py index 81f2c7e8c7eb5f..e92b51f92ba4ab 100644 --- a/homeassistant/components/homematicip_cloud/entity.py +++ b/homeassistant/components/homematicip_cloud/entity.py @@ -86,6 +86,8 @@ def __init__( channel: int | None = None, is_multi_channel: bool | None = False, channel_real_index: int | None = None, + *, + feature_id: str, ) -> None: """Initialize the generic entity.""" self._hap = hap @@ -101,6 +103,7 @@ def __init__( # Using channel_real_index ensures you reference the correct channel. self._channel_real_index: int | None = channel_real_index + self._feature_id = feature_id self._is_multi_channel = is_multi_channel self.functional_channel = None with contextlib.suppress(ValueError): @@ -237,11 +240,10 @@ def available(self) -> bool: @property def unique_id(self) -> str: """Return a unique ID.""" - unique_id = f"{self.__class__.__name__}_{self._device.id}" - if self._is_multi_channel: - unique_id = f"{self.__class__.__name__}_Channel{self.get_channel_index()}_{self._device.id}" - - return unique_id + if not isinstance(self._device, Device): + return f"{self._device.id}_{self._feature_id}" + channel_index = self.get_channel_index() + return f"{self._device.id}_{channel_index}_{self._feature_id}" @property def icon(self) -> str | None: diff --git a/homeassistant/components/homematicip_cloud/event.py b/homeassistant/components/homematicip_cloud/event.py index f98b078ab73614..a0502f72f54789 100644 --- a/homeassistant/components/homematicip_cloud/event.py +++ b/homeassistant/components/homematicip_cloud/event.py @@ -85,6 +85,7 @@ def __init__( post=description.key, channel=channel, is_multi_channel=False, + feature_id="doorbell", ) self.entity_description = description diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index 6affad00b3fcc9..e311c87904ba8d 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -126,7 +126,7 @@ class HomematicipLight(HomematicipGenericEntity, LightEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the light entity.""" - super().__init__(hap, device) + super().__init__(hap, device, feature_id="light") @property def is_on(self) -> bool: @@ -147,7 +147,13 @@ class HomematicipColorLight(HomematicipGenericEntity, LightEntity): def __init__(self, hap: HomematicipHAP, device: Device, channel_index: int) -> None: """Initialize the light entity.""" - super().__init__(hap, device, channel=channel_index, is_multi_channel=True) + super().__init__( + hap, + device, + channel=channel_index, + is_multi_channel=True, + feature_id="color_light", + ) def _supports_color(self) -> bool: """Return true if device supports hue/saturation color control.""" @@ -243,7 +249,11 @@ def __init__( ) -> None: """Initialize the dimmer light entity.""" super().__init__( - hap, device, channel=channel, is_multi_channel=is_multi_channel + hap, + device, + channel=channel, + is_multi_channel=is_multi_channel, + feature_id="dimmer", ) @property @@ -290,7 +300,14 @@ class HomematicipNotificationLight(HomematicipGenericEntity, LightEntity): def __init__(self, hap: HomematicipHAP, device, channel: int, post: str) -> None: """Initialize the notification light entity.""" - super().__init__(hap, device, post=post, channel=channel, is_multi_channel=True) + super().__init__( + hap, + device, + post=post, + channel=channel, + is_multi_channel=True, + feature_id="notification_light", + ) self._color_switcher: dict[str, tuple[float, float]] = { RGBColorState.WHITE: (0.0, 0.0), @@ -335,11 +352,6 @@ def extra_state_attributes(self) -> dict[str, Any]: return state_attr - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self.__class__.__name__}_{self._post}_{self._device.id}" - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" # Use hs_color from kwargs, @@ -513,6 +525,7 @@ def __init__( channel=channel_index, is_multi_channel=True, channel_real_index=channel_index, + feature_id="optical_signal_light", ) @property @@ -614,7 +627,13 @@ def __init__( self, hap: HomematicipHAP, device: CombinationSignallingDevice ) -> None: """Initialize the combination signalling light entity.""" - super().__init__(hap, device, channel=1, is_multi_channel=False) + super().__init__( + hap, + device, + channel=1, + is_multi_channel=False, + feature_id="combination_signalling_light", + ) @property def _func_channel(self) -> NotificationMp3SoundChannel: diff --git a/homeassistant/components/homematicip_cloud/lock.py b/homeassistant/components/homematicip_cloud/lock.py index bae075e1a17143..03f26c99a34597 100644 --- a/homeassistant/components/homematicip_cloud/lock.py +++ b/homeassistant/components/homematicip_cloud/lock.py @@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import HomematicipGenericEntity -from .hap import HomematicIPConfigEntry +from .hap import HomematicIPConfigEntry, HomematicipHAP from .helpers import handle_errors _LOGGER = logging.getLogger(__name__) @@ -53,6 +53,10 @@ class HomematicipDoorLockDrive(HomematicipGenericEntity, LockEntity): _attr_supported_features = LockEntityFeature.OPEN + def __init__(self, hap: HomematicipHAP, device: DoorLockDrive) -> None: + """Initialize the door lock drive.""" + super().__init__(hap, device, feature_id="lock") + @property def is_locked(self) -> bool | None: """Return true if device is locked.""" diff --git a/homeassistant/components/homematicip_cloud/migration.py b/homeassistant/components/homematicip_cloud/migration.py new file mode 100644 index 00000000000000..632a830e9597b5 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/migration.py @@ -0,0 +1,233 @@ +"""Unique ID migration for HomematicIP Cloud entities.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +import re + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class _MigrationConfig: + """Configuration for migrating a single entity class to the new unique_id format.""" + + feature_id: str + channel: int | None = None + is_group: bool = False + + +UNIQUE_ID_MIGRATION_MAP: dict[str, _MigrationConfig] = { + # binary_sensor + "HomematicipCloudConnectionSensor": _MigrationConfig( + "cloud_connection", is_group=True + ), + "HomematicipAccelerationSensor": _MigrationConfig("acceleration", channel=1), + "HomematicipTiltVibrationSensor": _MigrationConfig("tilt_vibration", channel=1), + "HomematicipMultiContactInterface": _MigrationConfig("contact"), + "HomematicipContactInterface": _MigrationConfig("contact", channel=1), + "HomematicipShutterContact": _MigrationConfig("shutter_contact", channel=1), + "HomematicipMotionDetector": _MigrationConfig("motion", channel=1), + "HomematicipPresenceDetector": _MigrationConfig("presence", channel=1), + "HomematicipSmokeDetector": _MigrationConfig("smoke", channel=1), + "HomematicipWaterDetector": _MigrationConfig("water", channel=1), + "HomematicipStormSensor": _MigrationConfig("storm", channel=1), + "HomematicipRainSensor": _MigrationConfig("rain", channel=1), + "HomematicipSunshineSensor": _MigrationConfig("sunshine", channel=1), + "HomematicipBatterySensor": _MigrationConfig("battery", channel=0), + "HomematicipPluggableMainsFailureSurveillanceSensor": _MigrationConfig( + "mains_failure", channel=1 + ), + "HomematicipSecurityZoneSensorGroup": _MigrationConfig( + "security_zone", is_group=True + ), + "HomematicipSecuritySensorGroup": _MigrationConfig("security", is_group=True), + "HomematicipFullFlushLockControllerLocked": _MigrationConfig( + "lock_locked", channel=1 + ), + "HomematicipFullFlushLockControllerGlassBreak": _MigrationConfig( + "glass_break", channel=1 + ), + "HomematicipSmokeDetectorChamberDegraded": _MigrationConfig( + "chamber_degraded", channel=1 + ), + # sensor + "HomematicipAccesspointDutyCycle": _MigrationConfig("duty_cycle", channel=0), + "HomematicipHeatingThermostat": _MigrationConfig("valve_position", channel=1), + "HomematicipHumiditySensor": _MigrationConfig("humidity", channel=1), + "HomematicipTemperatureSensor": _MigrationConfig("temperature", channel=1), + "HomematicipAbsoluteHumiditySensor": _MigrationConfig( + "absolute_humidity", channel=1 + ), + "HomematicipIlluminanceSensor": _MigrationConfig("illuminance", channel=1), + "HomematicipPowerSensor": _MigrationConfig("power", channel=1), + "HomematicipEnergySensor": _MigrationConfig("energy", channel=1), + "HomematicipWindspeedSensor": _MigrationConfig("wind_speed", channel=1), + "HomematicipTodayRainSensor": _MigrationConfig("today_rain", channel=1), + "HomematicipPassageDetectorDeltaCounter": _MigrationConfig( + "passage_counter", channel=1 + ), + "HomematicipWaterFlowSensor": _MigrationConfig("water_flow"), + "HomematicipWaterVolumeSensor": _MigrationConfig("water_volume"), + "HomematicipWaterVolumeSinceOpenSensor": _MigrationConfig( + "water_volume_since_open" + ), + "HomematicipTiltAngleSensor": _MigrationConfig("tilt_angle", channel=1), + "HomematicipTiltStateSensor": _MigrationConfig("tilt_state", channel=1), + "HomematicipFloorTerminalBlockMechanicChannelValve": _MigrationConfig( + "ftb_valve_position" + ), + "HomematicpTemperatureExternalSensorCh1": _MigrationConfig( + "temperature_external_ch1", channel=1 + ), + "HomematicpTemperatureExternalSensorCh2": _MigrationConfig( + "temperature_external_ch2", channel=1 + ), + "HomematicpTemperatureExternalSensorDelta": _MigrationConfig( + "temperature_external_delta", channel=1 + ), + "HmipEsiIecPowerConsumption": _MigrationConfig("esi_iec_power", channel=1), + "HmipEsiIecEnergyCounterHighTariff": _MigrationConfig( + "esi_iec_energy_high", channel=1 + ), + "HmipEsiIecEnergyCounterLowTariff": _MigrationConfig( + "esi_iec_energy_low", channel=1 + ), + "HmipEsiIecEnergyCounterInputSingleTariff": _MigrationConfig( + "esi_iec_energy_input", channel=1 + ), + "HmipEsiGasCurrentGasFlow": _MigrationConfig("esi_gas_flow", channel=1), + "HmipEsiGasGasVolume": _MigrationConfig("esi_gas_volume", channel=1), + "HmipEsiLedCurrentPowerConsumption": _MigrationConfig("esi_led_power", channel=1), + "HmipEsiLedEnergyCounterHighTariff": _MigrationConfig( + "esi_led_energy_high", channel=1 + ), + "HomematicipSoilMoistureSensor": _MigrationConfig("soil_moisture", channel=1), + "HomematicipSoilTemperatureSensor": _MigrationConfig("soil_temperature", channel=1), + # light + "HomematicipLight": _MigrationConfig("light", channel=1), + "HomematicipLightHS": _MigrationConfig("light"), + "HomematicipLightMeasuring": _MigrationConfig("light", channel=1), + "HomematicipMultiDimmer": _MigrationConfig("dimmer"), + "HomematicipDimmer": _MigrationConfig("dimmer", channel=1), + "HomematicipNotificationLight": _MigrationConfig("notification_light"), + "HomematicipNotificationLightV2": _MigrationConfig("notification_light"), + "HomematicipColorLight": _MigrationConfig("color_light", channel=1), + "HomematicipOpticalSignalLight": _MigrationConfig( + "optical_signal_light", channel=1 + ), + "HomematicipCombinationSignallingLight": _MigrationConfig( + "combination_signalling_light", channel=1 + ), + # switch + "HomematicipMultiSwitch": _MigrationConfig("switch"), + "HomematicipSwitch": _MigrationConfig("switch", channel=1), + "HomematicipGroupSwitch": _MigrationConfig("switch", is_group=True), + "HomematicipSwitchMeasuring": _MigrationConfig("switch", channel=1), + # cover + "HomematicipBlindModule": _MigrationConfig("blind", channel=1), + "HomematicipMultiCoverShutter": _MigrationConfig("shutter"), + "HomematicipCoverShutter": _MigrationConfig("shutter", channel=1), + "HomematicipMultiCoverSlats": _MigrationConfig("slats"), + "HomematicipCoverSlats": _MigrationConfig("slats", channel=1), + "HomematicipGarageDoorModule": _MigrationConfig("garage_door", channel=1), + "HomematicipCoverShutterGroup": _MigrationConfig("shutter", is_group=True), + # climate + "HomematicipHeatingGroup": _MigrationConfig("climate", is_group=True), + # weather + "HomematicipWeatherSensor": _MigrationConfig("weather", channel=1), + "HomematicipWeatherSensorPro": _MigrationConfig("weather", channel=1), + "HomematicipHomeWeather": _MigrationConfig("home_weather", is_group=True), + # valve + "HomematicipWateringValve": _MigrationConfig("watering"), + # lock + "HomematicipDoorLockDrive": _MigrationConfig("lock", channel=1), + # button + "HomematicipGarageDoorControllerButton": _MigrationConfig( + "garage_button", channel=1 + ), + "HomematicipFullFlushLockControllerButton": _MigrationConfig( + "lock_opener_button", channel=1 + ), + # event + "HomematicipDoorBellEvent": _MigrationConfig("doorbell", channel=1), + # alarm_control_panel + "HomematicipAlarmControlPanelEntity": _MigrationConfig("alarm", is_group=True), + # siren + "HomematicipMP3Siren": _MigrationConfig("siren", channel=1), +} + +# Sorted by length descending so longer class names match before shorter ones +# (e.g., "HomematicipSwitchMeasuring" before "HomematicipSwitch") +_SORTED_CLASS_NAMES = sorted(UNIQUE_ID_MIGRATION_MAP, key=len, reverse=True) + +_CHANNEL_RE = re.compile(r"^Channel(\d+)_(.+)$") +_NOTIFICATION_LIGHT_RE = re.compile(r"^(Top|Bottom)_(.+)$") + +_NOTIFICATION_LIGHT_CHANNEL_MAP = {"Top": 2, "Bottom": 3} + + +def _migrate_unique_id(old_unique_id: str) -> str | None: + """Convert an old-format unique_id to the new format. + + Old formats: + {ClassName}_{device_id} + {ClassName}_Channel{N}_{device_id} + {ClassName}_{Top|Bottom}_{device_id} (NotificationLight only) + + New format: + {device_id}_{channel}_{feature_id} (device entities) + {device_id}_{feature_id} (group/home entities) + """ + # Find the matching class name (longest first) + matched_class: str | None = None + for class_name in _SORTED_CLASS_NAMES: + prefix = class_name + "_" + if old_unique_id.startswith(prefix): + matched_class = class_name + break + + if matched_class is None: + return None + + config = UNIQUE_ID_MIGRATION_MAP[matched_class] + remainder = old_unique_id[len(matched_class) + 1 :] + + # Parse remainder to extract channel and device_id + channel: int | None = None + device_id: str + + # Check for Channel{N}_{rest} pattern + channel_match = _CHANNEL_RE.match(remainder) + if channel_match: + channel = int(channel_match.group(1)) + device_id = channel_match.group(2) + elif matched_class in ( + "HomematicipNotificationLight", + "HomematicipNotificationLightV2", + ): + # Check for Top/Bottom pattern + notif_match = _NOTIFICATION_LIGHT_RE.match(remainder) + if notif_match: + channel = _NOTIFICATION_LIGHT_CHANNEL_MAP[notif_match.group(1)] + device_id = notif_match.group(2) + else: + device_id = remainder + channel = config.channel + else: + device_id = remainder + channel = config.channel + + # Build new unique_id + if config.is_group: + return f"{device_id}_{config.feature_id}" + + if channel is not None: + return f"{device_id}_{channel}_{config.feature_id}" + + _LOGGER.warning( + "Cannot determine channel for unique_id: %s", + old_unique_id, + ) + return None diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 211dddd881147c..f941616bc22e55 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -383,7 +383,14 @@ def __init__( self, hap: HomematicipHAP, device: Device, channel: int, post: str ) -> None: """Initialize the watering flow sensor device.""" - super().__init__(hap, device, post=post, channel=channel, is_multi_channel=True) + super().__init__( + hap, + device, + post=post, + channel=channel, + is_multi_channel=True, + feature_id="water_flow", + ) @property def native_value(self) -> float | None: @@ -405,9 +412,17 @@ def __init__( channel: int, post: str, attribute: str, + feature_id: str = "water_volume", ) -> None: """Initialize the watering volume sensor device.""" - super().__init__(hap, device, post=post, channel=channel, is_multi_channel=True) + super().__init__( + hap, + device, + post=post, + channel=channel, + is_multi_channel=True, + feature_id=feature_id, + ) self._attribute_name = attribute @property @@ -430,6 +445,7 @@ def __init__(self, hap: HomematicipHAP, device: Device, channel: int) -> None: channel=channel, post="waterVolumeSinceOpen", attribute="waterVolumeSinceOpen", + feature_id="water_volume_since_open", ) @@ -441,7 +457,7 @@ class HomematicipTiltAngleSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the tilt angle sensor device.""" - super().__init__(hap, device, post="Tilt Angle") + super().__init__(hap, device, post="Tilt Angle", feature_id="tilt_angle") @property def native_value(self) -> int | None: @@ -458,7 +474,7 @@ class HomematicipTiltStateSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the tilt sensor device.""" - super().__init__(hap, device, post="Tilt State") + super().__init__(hap, device, post="Tilt State", feature_id="tilt_state") @property def native_value(self) -> str | None: @@ -502,6 +518,7 @@ def __init__( channel=channel, is_multi_channel=is_multi_channel, post="Valve Position", + feature_id="ftb_valve_position", ) @property @@ -540,7 +557,9 @@ class HomematicipAccesspointDutyCycle(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize access point status entity.""" - super().__init__(hap, device, post="Duty Cycle") + super().__init__( + hap, device, post="Duty Cycle", channel=0, feature_id="duty_cycle" + ) @property def native_value(self) -> float: @@ -555,7 +574,7 @@ class HomematicipHeatingThermostat(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize heating thermostat device.""" - super().__init__(hap, device, post="Heating") + super().__init__(hap, device, post="Heating", feature_id="valve_position") @property def icon(self) -> str | None: @@ -583,7 +602,7 @@ class HomematicipHumiditySensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the thermometer device.""" - super().__init__(hap, device, post="Humidity") + super().__init__(hap, device, post="Humidity", feature_id="humidity") @property def native_value(self) -> int: @@ -600,7 +619,7 @@ class HomematicipTemperatureSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the thermometer device.""" - super().__init__(hap, device, post="Temperature") + super().__init__(hap, device, post="Temperature", feature_id="temperature") @property def native_value(self) -> float: @@ -633,7 +652,9 @@ class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the thermometer device.""" - super().__init__(hap, device, post="Absolute Humidity") + super().__init__( + hap, device, post="Absolute Humidity", feature_id="absolute_humidity" + ) @property def native_value(self) -> float | None: @@ -654,7 +675,7 @@ class HomematicipIlluminanceSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Illuminance") + super().__init__(hap, device, post="Illuminance", feature_id="illuminance") @property def native_value(self) -> float: @@ -685,7 +706,7 @@ class HomematicipPowerSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Power") + super().__init__(hap, device, post="Power", feature_id="power") @property def native_value(self) -> float: @@ -702,7 +723,7 @@ class HomematicipEnergySensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Energy") + super().__init__(hap, device, post="Energy", feature_id="energy") @property def native_value(self) -> float: @@ -719,7 +740,7 @@ class HomematicipWindspeedSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the windspeed sensor.""" - super().__init__(hap, device, post="Windspeed") + super().__init__(hap, device, post="Windspeed", feature_id="wind_speed") @property def native_value(self) -> float: @@ -751,7 +772,7 @@ class HomematicipTodayRainSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Today Rain") + super().__init__(hap, device, post="Today Rain", feature_id="today_rain") @property def native_value(self) -> float: @@ -768,7 +789,12 @@ class HomematicpTemperatureExternalSensorCh1(HomematicipGenericEntity, SensorEnt def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Channel 1 Temperature") + super().__init__( + hap, + device, + post="Channel 1 Temperature", + feature_id="temperature_external_ch1", + ) @property def native_value(self) -> float: @@ -785,7 +811,12 @@ class HomematicpTemperatureExternalSensorCh2(HomematicipGenericEntity, SensorEnt def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Channel 2 Temperature") + super().__init__( + hap, + device, + post="Channel 2 Temperature", + feature_id="temperature_external_ch2", + ) @property def native_value(self) -> float: @@ -802,7 +833,12 @@ class HomematicpTemperatureExternalSensorDelta(HomematicipGenericEntity, SensorE def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Delta Temperature") + super().__init__( + hap, + device, + post="Delta Temperature", + feature_id="temperature_external_delta", + ) @property def native_value(self) -> float: @@ -820,6 +856,7 @@ def __init__( key: str, value_fn: Callable[[FunctionalChannel], StateType], type_fn: Callable[[FunctionalChannel], str], + feature_id: str, ) -> None: """Initialize Sensor Entity.""" super().__init__( @@ -828,6 +865,7 @@ def __init__( channel=1, post=key, is_multi_channel=False, + feature_id=feature_id, ) self._value_fn = value_fn @@ -862,6 +900,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: key="CurrentPowerConsumption", value_fn=lambda channel: channel.currentPowerConsumption, type_fn=lambda channel: "CurrentPowerConsumption", + feature_id="esi_iec_power", ) @@ -880,6 +919,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: key=ESI_TYPE_ENERGY_COUNTER_USAGE_HIGH_TARIFF, value_fn=lambda channel: channel.energyCounterOne, type_fn=lambda channel: channel.energyCounterOneType, + feature_id="esi_iec_energy_high", ) @@ -898,6 +938,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: key=ESI_TYPE_ENERGY_COUNTER_USAGE_LOW_TARIFF, value_fn=lambda channel: channel.energyCounterTwo, type_fn=lambda channel: channel.energyCounterTwoType, + feature_id="esi_iec_energy_low", ) @@ -916,6 +957,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: key=ESI_TYPE_ENERGY_COUNTER_INPUT_SINGLE_TARIFF, value_fn=lambda channel: channel.energyCounterThree, type_fn=lambda channel: channel.energyCounterThreeType, + feature_id="esi_iec_energy_input", ) @@ -934,6 +976,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: key="CurrentGasFlow", value_fn=lambda channel: channel.currentGasFlow, type_fn=lambda channel: "CurrentGasFlow", + feature_id="esi_gas_flow", ) @@ -952,6 +995,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: key="GasVolume", value_fn=lambda channel: channel.gasVolume, type_fn=lambda channel: "GasVolume", + feature_id="esi_gas_volume", ) @@ -970,6 +1014,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: key="CurrentPowerConsumption", value_fn=lambda channel: channel.currentPowerConsumption, type_fn=lambda channel: "CurrentPowerConsumption", + feature_id="esi_led_power", ) @@ -988,12 +1033,17 @@ def __init__(self, hap: HomematicipHAP, device) -> None: key=ESI_TYPE_ENERGY_COUNTER_USAGE_HIGH_TARIFF, value_fn=lambda channel: channel.energyCounterOne, type_fn=lambda channel: ESI_TYPE_ENERGY_COUNTER_USAGE_HIGH_TARIFF, + feature_id="esi_led_energy_high", ) class HomematicipPassageDetectorDeltaCounter(HomematicipGenericEntity, SensorEntity): """Representation of the HomematicIP passage detector delta counter.""" + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the passage detector delta counter.""" + super().__init__(hap, device, feature_id="passage_counter") + @property def native_value(self) -> int: """Return the passage detector delta counter value.""" @@ -1022,7 +1072,9 @@ def __init__( description: HmipSmokeDetectorSensorDescription, ) -> None: """Initialize the smoke detector sensor.""" - super().__init__(hap, device, post=description.key) + super().__init__( + hap, device, post=description.key, feature_id="smoke_detector_sensor" + ) self.entity_description = description self._sensor_unique_id = f"{device.id}_{description.key}" @@ -1047,7 +1099,12 @@ class HomematicipSoilMoistureSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the soil moisture sensor device.""" super().__init__( - hap, device, post="Soil Moisture", channel=1, is_multi_channel=True + hap, + device, + post="Soil Moisture", + channel=1, + is_multi_channel=True, + feature_id="soil_moisture", ) @property @@ -1068,7 +1125,12 @@ class HomematicipSoilTemperatureSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the soil temperature sensor device.""" super().__init__( - hap, device, post="Soil Temperature", channel=1, is_multi_channel=True + hap, + device, + post="Soil Temperature", + channel=1, + is_multi_channel=True, + feature_id="soil_temperature", ) @property diff --git a/homeassistant/components/homematicip_cloud/siren.py b/homeassistant/components/homematicip_cloud/siren.py index 5fb4d73a27b35b..d7ee8c577e1569 100644 --- a/homeassistant/components/homematicip_cloud/siren.py +++ b/homeassistant/components/homematicip_cloud/siren.py @@ -60,7 +60,14 @@ def __init__( self, hap: HomematicipHAP, device: CombinationSignallingDevice ) -> None: """Initialize the siren entity.""" - super().__init__(hap, device, post="Siren", channel=1, is_multi_channel=False) + super().__init__( + hap, + device, + post="Siren", + channel=1, + is_multi_channel=False, + feature_id="siren", + ) @property def _func_channel(self) -> NotificationMp3SoundChannel: diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 59216c904a4977..8ec993124c5af2 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -109,7 +109,11 @@ def __init__( ) -> None: """Initialize the multi switch device.""" super().__init__( - hap, device, channel=channel, is_multi_channel=is_multi_channel + hap, + device, + channel=channel, + is_multi_channel=is_multi_channel, + feature_id="switch", ) @property @@ -143,7 +147,7 @@ class HomematicipGroupSwitch(HomematicipGenericEntity, SwitchEntity): def __init__(self, hap: HomematicipHAP, device, post: str = "Group") -> None: """Initialize switching group.""" device.modelType = f"HmIP-{post}" - super().__init__(hap, device, post) + super().__init__(hap, device, post, feature_id="switch") @property def is_on(self) -> bool: diff --git a/homeassistant/components/homematicip_cloud/valve.py b/homeassistant/components/homematicip_cloud/valve.py index a97ec157d170de..d759b7cf242fba 100644 --- a/homeassistant/components/homematicip_cloud/valve.py +++ b/homeassistant/components/homematicip_cloud/valve.py @@ -42,7 +42,12 @@ class HomematicipWateringValve(HomematicipGenericEntity, ValveEntity): def __init__(self, hap: HomematicipHAP, device: Device, channel: int) -> None: """Initialize the valve.""" super().__init__( - hap, device=device, channel=channel, post="watering", is_multi_channel=True + hap, + device=device, + channel=channel, + post="watering", + is_multi_channel=True, + feature_id="watering", ) async def async_open_valve(self) -> None: diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py index 061f6642bb221d..623491c0e46635 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -72,7 +72,7 @@ class HomematicipWeatherSensor(HomematicipGenericEntity, WeatherEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the weather sensor.""" - super().__init__(hap, device) + super().__init__(hap, device, feature_id="weather") @property def name(self) -> str: @@ -125,7 +125,7 @@ class HomematicipHomeWeather(HomematicipGenericEntity, WeatherEntity): def __init__(self, hap: HomematicipHAP) -> None: """Initialize the home weather.""" hap.home.modelType = "HmIP-Home-Weather" - super().__init__(hap, hap.home) + super().__init__(hap, hap.home, feature_id="home_weather") @property def available(self) -> bool: diff --git a/homeassistant/components/honeywell_string_lights/__init__.py b/homeassistant/components/honeywell_string_lights/__init__.py new file mode 100644 index 00000000000000..f5c7b4b09a5d33 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/__init__.py @@ -0,0 +1,20 @@ +"""The Honeywell String Lights integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Honeywell String Lights from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/honeywell_string_lights/config_flow.py b/homeassistant/components/honeywell_string_lights/config_flow.py new file mode 100644 index 00000000000000..f659a1403d4b23 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for the Honeywell String Lights integration.""" + +from __future__ import annotations + +from typing import Any + +from rf_protocols import RadioFrequencyCommand +import voluptuous as vol + +from homeassistant.components.radio_frequency import async_get_transmitters +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er, selector + +from .const import CONF_TRANSMITTER, DOMAIN +from .light import COMMANDS + + +class HoneywellStringLightsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Honeywell String Lights.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + sample_command: RadioFrequencyCommand = await self.hass.async_add_executor_job( + COMMANDS.load_command, "turn_on" + ) + try: + transmitters = async_get_transmitters( + self.hass, sample_command.frequency, sample_command.modulation + ) + except HomeAssistantError: + return self.async_abort(reason="no_transmitters") + + if not transmitters: + return self.async_abort(reason="no_compatible_transmitters") + + if user_input is not None: + registry = er.async_get(self.hass) + entity_entry = registry.async_get(user_input[CONF_TRANSMITTER]) + assert entity_entry is not None + await self.async_set_unique_id(entity_entry.id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title="Honeywell String Lights", + data={CONF_TRANSMITTER: entity_entry.id}, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_TRANSMITTER): selector.EntitySelector( + selector.EntitySelectorConfig(include_entities=transmitters), + ), + } + ), + ) diff --git a/homeassistant/components/honeywell_string_lights/const.py b/homeassistant/components/honeywell_string_lights/const.py new file mode 100644 index 00000000000000..c55c712f6c7a5f --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/const.py @@ -0,0 +1,9 @@ +"""Constants for the Honeywell String Lights integration.""" + +from __future__ import annotations + +from typing import Final + +DOMAIN: Final = "honeywell_string_lights" + +CONF_TRANSMITTER: Final = "transmitter" diff --git a/homeassistant/components/honeywell_string_lights/entity.py b/homeassistant/components/honeywell_string_lights/entity.py new file mode 100644 index 00000000000000..76363e1efa4278 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/entity.py @@ -0,0 +1,76 @@ +"""Common entity for Honeywell String Lights integration.""" + +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import Event, EventStateChangedData, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_state_change_event + +from .const import CONF_TRANSMITTER, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class HoneywellStringLightsEntity(Entity): + """Honeywell String Lights base entity.""" + + _attr_has_entity_name = True + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the entity.""" + self._transmitter = entry.data[CONF_TRANSMITTER] + self._attr_unique_id = entry.entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="Honeywell", + model="String Lights", + ) + + async def async_added_to_hass(self) -> None: + """Subscribe to transmitter entity state changes.""" + await super().async_added_to_hass() + + transmitter_entity_id = er.async_validate_entity_id( + er.async_get(self.hass), self._transmitter + ) + + @callback + def _async_transmitter_state_changed( + event: Event[EventStateChangedData], + ) -> None: + """Handle transmitter entity state changes.""" + new_state = event.data["new_state"] + transmitter_available = ( + new_state is not None and new_state.state != STATE_UNAVAILABLE + ) + if transmitter_available != self.available: + _LOGGER.info( + "Transmitter %s used by %s is %s", + transmitter_entity_id, + self.entity_id, + "available" if transmitter_available else "unavailable", + ) + + self._attr_available = transmitter_available + self.async_write_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, + [transmitter_entity_id], + _async_transmitter_state_changed, + ) + ) + + # Set initial availability based on current transmitter entity state + transmitter_state = self.hass.states.get(transmitter_entity_id) + self._attr_available = ( + transmitter_state is not None + and transmitter_state.state != STATE_UNAVAILABLE + ) diff --git a/homeassistant/components/honeywell_string_lights/light.py b/homeassistant/components/honeywell_string_lights/light.py new file mode 100644 index 00000000000000..d430e1f90e8c67 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/light.py @@ -0,0 +1,65 @@ +"""Light platform for Honeywell String Lights.""" + +from __future__ import annotations + +from typing import Any + +from rf_protocols import get_codes + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.components.radio_frequency import async_send_command +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .entity import HoneywellStringLightsEntity + +PARALLEL_UPDATES = 1 + +COMMANDS = get_codes("honeywell/string_lights") + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Honeywell String Lights light platform.""" + async_add_entities([HoneywellStringLight(config_entry)]) + + +class HoneywellStringLight(HoneywellStringLightsEntity, LightEntity, RestoreEntity): + """Representation of a Honeywell String Lights set controlled via RF.""" + + _attr_assumed_state = True + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_name = None + _attr_should_poll = False + + async def async_added_to_hass(self) -> None: + """Restore last known state.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) is not None: + self._attr_is_on = last_state.state == STATE_ON + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + await self._async_send_command("turn_on") + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + await self._async_send_command("turn_off") + self._attr_is_on = False + self.async_write_ha_state() + + async def _async_send_command(self, name: str) -> None: + """Load the named command and send it via the configured transmitter.""" + command = await COMMANDS.async_load_command(name) + await async_send_command( + self.hass, self._transmitter, command, context=self._context + ) diff --git a/homeassistant/components/honeywell_string_lights/manifest.json b/homeassistant/components/honeywell_string_lights/manifest.json new file mode 100644 index 00000000000000..62d65edb28e300 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "honeywell_string_lights", + "name": "Honeywell String Lights", + "codeowners": ["@balloob"], + "config_flow": true, + "dependencies": ["radio_frequency"], + "documentation": "https://www.home-assistant.io/integrations/honeywell_string_lights", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "bronze", + "requirements": ["rf-protocols==2.2.0"] +} diff --git a/homeassistant/components/honeywell_string_lights/quality_scale.yaml b/homeassistant/components/honeywell_string_lights/quality_scale.yaml new file mode 100644 index 00000000000000..54bcb3f12c1a0a --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/quality_scale.yaml @@ -0,0 +1,124 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not register custom service actions. + appropriate-polling: + status: exempt + comment: | + This integration transmits RF commands and does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not register custom service actions. + 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: + status: exempt + comment: | + This integration does not use runtime data. + test-before-configure: + status: exempt + comment: | + RF transmission is a one-way broadcast with no device to contact. + test-before-setup: + status: exempt + comment: | + RF transmission is a one-way broadcast with no device to contact. + unique-config-entry: done + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration has no options. + docs-installation-parameters: todo + entity-unavailable: + status: exempt + comment: | + RF transmission is a one-way broadcast; the light uses assumed state. + integration-owner: done + log-when-unavailable: + status: exempt + comment: | + RF transmission is a one-way broadcast; the light uses assumed state. + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not authenticate. + test-coverage: todo + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration does not support discovery. + discovery: + status: exempt + comment: | + RF devices cannot be discovered. + docs-data-update: + status: exempt + comment: | + RF transmission is one-way; there is no data update. + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Each config entry represents a single static device. + entity-category: + status: exempt + comment: | + The single entity represents the primary device function. + entity-device-class: + status: exempt + comment: | + Light entities do not have device classes. + entity-disabled-by-default: + status: exempt + comment: | + The single entity represents the primary device function. + entity-translations: + status: exempt + comment: | + The entity uses the device name. + exception-translations: todo + icon-translations: + status: exempt + comment: | + Light uses the default icon for its state. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + No known repairable issues. + stale-devices: + status: exempt + comment: | + Each config entry represents a single static device. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration does not use a web session. + strict-typing: todo diff --git a/homeassistant/components/honeywell_string_lights/strings.json b/homeassistant/components/honeywell_string_lights/strings.json new file mode 100644 index 00000000000000..a5c995ace08703 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_compatible_transmitters": "No radio frequency transmitter supports 433.92 MHz OOK transmissions. Please add a compatible transmitter first.", + "no_transmitters": "No radio frequency transmitters are available. Please set up a transmitter first." + }, + "step": { + "user": { + "data": { + "transmitter": "Radio frequency transmitter" + }, + "data_description": { + "transmitter": "The radio frequency transmitter used to control the Honeywell String Lights." + } + } + } + } +} diff --git a/homeassistant/components/html5/manifest.json b/homeassistant/components/html5/manifest.json index b958ab46461e30..9a71b05e348f93 100644 --- a/homeassistant/components/html5/manifest.json +++ b/homeassistant/components/html5/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/html5", + "integration_type": "service", "iot_class": "cloud_push", "loggers": ["http_ece", "py_vapid", "pywebpush"], "requirements": ["pywebpush==2.3.0", "py_vapid==1.9.4"], diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py index 8a4aac098ffa91..9dfa610e343726 100644 --- a/homeassistant/components/hue/scene.py +++ b/homeassistant/components/hue/scene.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from aiohue.v2 import HueBridgeV2 @@ -29,6 +30,8 @@ ATTR_SPEED = "speed" ATTR_BRIGHTNESS = "brightness" +LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -49,10 +52,18 @@ def async_add_entity( event_type: EventType, resource: HueScene | HueSmartScene ) -> None: """Add entity from Hue resource.""" - if isinstance(resource, HueSmartScene): - async_add_entities([HueSmartSceneEntity(bridge, api.scenes, resource)]) - else: - async_add_entities([HueSceneEntity(bridge, api.scenes, resource)]) + # Catch creation errors to continue adding other scenes even if one fails + try: + entity: HueSceneEntityBase + if isinstance(resource, HueSmartScene): + entity = HueSmartSceneEntity(bridge, api.scenes, resource) + else: + entity = HueSceneEntity(bridge, api.scenes, resource) + except KeyError, StopIteration: + LOGGER.exception("Unable to create Hue scene entity for %s", resource.id) + return + + async_add_entities([entity]) # add all current items in controller for item in api.scenes: diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index aa77ae2f7b727b..203aa52be56103 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2.7.3"] + "requirements": ["aioautomower==2.7.4"] } diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index d7344f56ab57d0..5a1abf2b02480d 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -23,6 +23,8 @@ from .coordinator import HydrawiseConfigEntry from .entity import HydrawiseEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class HydrawiseBinarySensorEntityDescription(BinarySensorEntityDescription): diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 19fcd0295a2cb8..2880ef7ca1aa9c 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -22,6 +22,8 @@ from .coordinator import HydrawiseConfigEntry from .entity import HydrawiseEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class HydrawiseSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 238e249e1f69bf..5ba88d6d7fcbb7 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -22,6 +22,8 @@ from .coordinator import HydrawiseConfigEntry from .entity import HydrawiseEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class HydrawiseSwitchEntityDescription(SwitchEntityDescription): diff --git a/homeassistant/components/hydrawise/valve.py b/homeassistant/components/hydrawise/valve.py index 56dd56e7d21dd7..9ed55ae9beec11 100644 --- a/homeassistant/components/hydrawise/valve.py +++ b/homeassistant/components/hydrawise/valve.py @@ -19,6 +19,8 @@ from .coordinator import HydrawiseConfigEntry from .entity import HydrawiseEntity +PARALLEL_UPDATES = 1 + VALVE_TYPES: tuple[ValveEntityDescription, ...] = ( ValveEntityDescription( key="zone", diff --git a/homeassistant/components/iaqualink/config_flow.py b/homeassistant/components/iaqualink/config_flow.py index 78d79d06befff7..6536ddd44aab3a 100644 --- a/homeassistant/components/iaqualink/config_flow.py +++ b/homeassistant/components/iaqualink/config_flow.py @@ -13,7 +13,11 @@ ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.httpx_client import get_async_client from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2 @@ -84,12 +88,16 @@ async def async_step_reauth_confirm( """Handle confirmation of reauthentication.""" errors = {} - reauth_entry = self._get_reauth_entry() + config_entry = ( + self._get_reconfigure_entry() + if self.source == SOURCE_RECONFIGURE + else self._get_reauth_entry() + ) if user_input is not None: errors = await self._async_test_credentials(user_input) if not errors: return self.async_update_reload_and_abort( - reauth_entry, + config_entry, title=user_input[CONF_USERNAME], data_updates={ CONF_USERNAME: user_input[CONF_USERNAME], @@ -98,7 +106,15 @@ async def async_step_reauth_confirm( ) return self.async_show_form( - step_id="reauth_confirm", + step_id=( + "reconfigure" if self.source == SOURCE_RECONFIGURE else "reauth_confirm" + ), data_schema=CREDENTIALS_DATA_SCHEMA, errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + return await self.async_step_reauth_confirm(user_input) diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json index c875b389e7b43e..d977a4c87f645e 100644 --- a/homeassistant/components/iaqualink/manifest.json +++ b/homeassistant/components/iaqualink/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["iaqualink"], "quality_scale": "bronze", - "requirements": ["iaqualink==0.6.0", "h2==4.3.0"], + "requirements": ["iaqualink==0.7.0", "h2==4.3.0"], "single_config_entry": true } diff --git a/homeassistant/components/iaqualink/quality_scale.yaml b/homeassistant/components/iaqualink/quality_scale.yaml index 8a346ea5800e52..cefaaca753cd1c 100644 --- a/homeassistant/components/iaqualink/quality_scale.yaml +++ b/homeassistant/components/iaqualink/quality_scale.yaml @@ -58,7 +58,7 @@ rules: entity-translations: todo exception-translations: todo icon-translations: todo - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: todo diff --git a/homeassistant/components/iaqualink/strings.json b/homeassistant/components/iaqualink/strings.json index 9ede89f5ea7dfb..23857c34817c14 100644 --- a/homeassistant/components/iaqualink/strings.json +++ b/homeassistant/components/iaqualink/strings.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -19,9 +20,21 @@ "password": "[%key:component::iaqualink::config::step::user::data_description::password%]", "username": "[%key:component::iaqualink::config::step::user::data_description::username%]" }, - "description": "Please enter the username and password for your iAquaLink account.", + "description": "[%key:component::iaqualink::config::step::user::description%]", "title": "Reauthenticate iAquaLink" }, + "reconfigure": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::iaqualink::config::step::user::data_description::password%]", + "username": "[%key:component::iaqualink::config::step::user::data_description::username%]" + }, + "description": "[%key:component::iaqualink::config::step::user::description%]", + "title": "Reconnect iAquaLink" + }, "user": { "data": { "password": "[%key:common::config_flow::data::password%]", diff --git a/homeassistant/components/indevolt/__init__.py b/homeassistant/components/indevolt/__init__.py index 7a4341d602be4d..3e8d8b7177b91a 100644 --- a/homeassistant/components/indevolt/__init__.py +++ b/homeassistant/components/indevolt/__init__.py @@ -4,8 +4,12 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN from .coordinator import IndevoltConfigEntry, IndevoltCoordinator +from .services import async_setup_services PLATFORMS: list[Platform] = [ Platform.BUTTON, @@ -14,6 +18,7 @@ Platform.SENSOR, Platform.SWITCH, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) -> bool: @@ -29,6 +34,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) -> return True +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up indevolt services (actions).""" + + await async_setup_services(hass) + + return True + + async def async_unload_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) -> bool: """Unload a config entry / clean up resources (when integration is removed / reloaded).""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/indevolt/button.py b/homeassistant/components/indevolt/button.py index 6abcf50048bee9..320c5ce4f54c6d 100644 --- a/homeassistant/components/indevolt/button.py +++ b/homeassistant/components/indevolt/button.py @@ -5,6 +5,8 @@ from dataclasses import dataclass, field from typing import Final +from indevolt_api import IndevoltRealtimeAction + from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -66,5 +68,4 @@ def __init__( async def async_press(self) -> None: """Handle the button press.""" - - await self.coordinator.async_execute_realtime_action([0, 0, 0]) + await self.coordinator.async_realtime_action(IndevoltRealtimeAction.STOP) diff --git a/homeassistant/components/indevolt/const.py b/homeassistant/components/indevolt/const.py index 3b469282a643c3..6c41a4d874c195 100644 --- a/homeassistant/components/indevolt/const.py +++ b/homeassistant/components/indevolt/const.py @@ -2,6 +2,14 @@ from typing import Final +from indevolt_api import ( + IndevoltBattery, + IndevoltConfig, + IndevoltGrid, + IndevoltSolar, + IndevoltSystem, +) + DOMAIN: Final = "indevolt" # Default configurations @@ -11,108 +19,99 @@ CONF_SERIAL_NUMBER: Final = "serial_number" CONF_GENERATION: Final = "generation" -# API write/read keys for energy and value for outdoor/portable mode -ENERGY_MODE_READ_KEY: Final = "7101" -ENERGY_MODE_WRITE_KEY: Final = "47005" -PORTABLE_MODE: Final = 0 - -# API write key and value for real-time control mode -REALTIME_ACTION_KEY: Final = "47015" -REALTIME_ACTION_MODE: Final = 4 - # API key fields SENSOR_KEYS: Final[dict[int, list[str]]] = { 1: [ - "606", - "7101", - "2101", - "2108", - "2107", - "6000", - "6001", - "6002", - "1501", - "1502", - "1664", - "1665", - "1666", - "1667", - "6105", - "21028", - "1505", + IndevoltSystem.OPERATING_MODE, + IndevoltConfig.READ_ENERGY_MODE, + IndevoltSystem.INPUT_POWER, + IndevoltSystem.OUTPUT_POWER, + IndevoltSystem.TOTAL_INPUT_ENERGY, + IndevoltBattery.POWER, + IndevoltBattery.CHARGE_DISCHARGE_STATE, + IndevoltBattery.SOC, + IndevoltSolar.DC_OUTPUT_POWER, + IndevoltSolar.DAILY_PRODUCTION, + IndevoltSolar.DC_INPUT_POWER_1, + IndevoltSolar.DC_INPUT_POWER_2, + IndevoltSolar.DC_INPUT_POWER_3, + IndevoltSolar.DC_INPUT_POWER_4, + IndevoltConfig.READ_DISCHARGE_LIMIT, + IndevoltGrid.METER_POWER_GEN1, + IndevoltSolar.CUMULATIVE_PRODUCTION, ], 2: [ - "606", - "7101", - "2101", - "2108", - "2107", - "6000", - "6001", - "6002", - "1501", - "1502", - "1664", - "1665", - "1666", - "1667", - "142", - "667", - "2104", - "2105", - "11034", - "6004", - "6005", - "6006", - "6007", - "11016", - "2600", - "2612", - "1632", - "1600", - "1633", - "1601", - "1634", - "1602", - "1635", - "1603", - "9008", - "9032", - "9051", - "9070", - "9165", - "9218", - "9000", - "9016", - "9035", - "9054", - "9149", - "9202", - "9012", - "9030", - "9049", - "9068", - "9163", - "9216", - "9004", - "9020", - "9039", - "9058", - "9153", - "9206", - "9013", - "19173", - "19174", - "19175", - "19176", - "19177", - "680", - "2618", - "7171", - "11011", - "11009", - "11010", - "6105", - "1505", + IndevoltSystem.OPERATING_MODE, + IndevoltConfig.READ_ENERGY_MODE, + IndevoltSystem.INPUT_POWER, + IndevoltSystem.OUTPUT_POWER, + IndevoltSystem.TOTAL_INPUT_ENERGY, + IndevoltBattery.POWER, + IndevoltBattery.CHARGE_DISCHARGE_STATE, + IndevoltBattery.SOC, + IndevoltSolar.DC_OUTPUT_POWER, + IndevoltSolar.DAILY_PRODUCTION, + IndevoltSolar.DC_INPUT_POWER_1, + IndevoltSolar.DC_INPUT_POWER_2, + IndevoltSolar.DC_INPUT_POWER_3, + IndevoltSolar.DC_INPUT_POWER_4, + IndevoltBattery.RATED_CAPACITY_GEN2, + IndevoltSystem.BYPASS_POWER, + IndevoltSystem.TOTAL_OUTPUT_ENERGY, + IndevoltSystem.OFF_GRID_OUTPUT_ENERGY, + IndevoltSystem.BYPASS_INPUT_ENERGY, + IndevoltBattery.DAILY_CHARGING_ENERGY, + IndevoltBattery.DAILY_DISCHARGING_ENERGY, + IndevoltBattery.TOTAL_CHARGING_ENERGY, + IndevoltBattery.TOTAL_DISCHARGING_ENERGY, + IndevoltGrid.METER_POWER_GEN2, + IndevoltGrid.VOLTAGE, + IndevoltGrid.FREQUENCY, + IndevoltSolar.DC_INPUT_CURRENT_1, + IndevoltSolar.DC_INPUT_VOLTAGE_1, + IndevoltSolar.DC_INPUT_CURRENT_2, + IndevoltSolar.DC_INPUT_VOLTAGE_2, + IndevoltSolar.DC_INPUT_CURRENT_3, + IndevoltSolar.DC_INPUT_VOLTAGE_3, + IndevoltSolar.DC_INPUT_CURRENT_4, + IndevoltSolar.DC_INPUT_VOLTAGE_4, + IndevoltBattery.MAIN_SERIAL_NUMBER, + IndevoltBattery.PACK_1_SERIAL_NUMBER, + IndevoltBattery.PACK_2_SERIAL_NUMBER, + IndevoltBattery.PACK_3_SERIAL_NUMBER, + IndevoltBattery.PACK_4_SERIAL_NUMBER, + IndevoltBattery.PACK_5_SERIAL_NUMBER, + IndevoltBattery.MAIN_SOC, + IndevoltBattery.PACK_1_SOC, + IndevoltBattery.PACK_2_SOC, + IndevoltBattery.PACK_3_SOC, + IndevoltBattery.PACK_4_SOC, + IndevoltBattery.PACK_5_SOC, + IndevoltBattery.MAIN_TEMPERATURE, + IndevoltBattery.PACK_1_TEMPERATURE, + IndevoltBattery.PACK_2_TEMPERATURE, + IndevoltBattery.PACK_3_TEMPERATURE, + IndevoltBattery.PACK_4_TEMPERATURE, + IndevoltBattery.PACK_5_TEMPERATURE, + IndevoltBattery.MAIN_VOLTAGE, + IndevoltBattery.PACK_1_VOLTAGE, + IndevoltBattery.PACK_2_VOLTAGE, + IndevoltBattery.PACK_3_VOLTAGE, + IndevoltBattery.PACK_4_VOLTAGE, + IndevoltBattery.PACK_5_VOLTAGE, + IndevoltBattery.MAIN_CURRENT, + IndevoltBattery.PACK_1_CURRENT, + IndevoltBattery.PACK_2_CURRENT, + IndevoltBattery.PACK_3_CURRENT, + IndevoltBattery.PACK_4_CURRENT, + IndevoltBattery.PACK_5_CURRENT, + IndevoltConfig.READ_BYPASS, + IndevoltConfig.READ_GRID_CHARGING, + IndevoltConfig.READ_LIGHT, + IndevoltConfig.READ_MAX_AC_OUTPUT_POWER, + IndevoltConfig.READ_INVERTER_INPUT_LIMIT, + IndevoltConfig.READ_FEEDIN_POWER_LIMIT, + IndevoltConfig.READ_DISCHARGE_LIMIT, + IndevoltSolar.CUMULATIVE_PRODUCTION, ], } diff --git a/homeassistant/components/indevolt/coordinator.py b/homeassistant/components/indevolt/coordinator.py index 19320eec5441f6..7691df082c7599 100644 --- a/homeassistant/components/indevolt/coordinator.py +++ b/homeassistant/components/indevolt/coordinator.py @@ -7,7 +7,13 @@ from typing import Any, Final from aiohttp import ClientError -from indevolt_api import IndevoltAPI, TimeOutException +from indevolt_api import ( + IndevoltAPI, + IndevoltConfig, + IndevoltEnergyMode, + IndevoltRealtimeAction, + TimeOutException, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MODEL @@ -21,11 +27,6 @@ CONF_SERIAL_NUMBER, DEFAULT_PORT, DOMAIN, - ENERGY_MODE_READ_KEY, - ENERGY_MODE_WRITE_KEY, - PORTABLE_MODE, - REALTIME_ACTION_KEY, - REALTIME_ACTION_MODE, SENSOR_KEYS, ) @@ -70,10 +71,10 @@ def __init__(self, hass: HomeAssistant, entry: IndevoltConfigEntry) -> None: session=async_get_clientsession(hass), ) - self.friendly_name = entry.title - self.serial_number = entry.data[CONF_SERIAL_NUMBER] - self.device_model = entry.data[CONF_MODEL] - self.generation = entry.data[CONF_GENERATION] + self.friendly_name: str = entry.title + self.serial_number: str = entry.data[CONF_SERIAL_NUMBER] + self.device_model: str = entry.data[CONF_MODEL] + self.generation: int = entry.data[CONF_GENERATION] async def _async_setup(self) -> None: """Fetch device info once on boot.""" @@ -108,10 +109,10 @@ async def async_push_data(self, sensor_key: str, value: Any) -> bool: raise DeviceConnectionError(f"Device push failed: {err}") from err async def async_switch_energy_mode( - self, target_mode: int, refresh: bool = True + self, target_mode: IndevoltEnergyMode, refresh: bool = True ) -> None: """Attempt to switch device to given energy mode.""" - current_mode = self.data.get(ENERGY_MODE_READ_KEY) + current_mode = self.data.get(IndevoltConfig.READ_ENERGY_MODE) # Ensure current energy mode is known if current_mode is None: @@ -121,7 +122,7 @@ async def async_switch_energy_mode( ) # Ensure device is not in "Outdoor/Portable mode" - if current_mode == PORTABLE_MODE: + if current_mode == IndevoltEnergyMode.OUTDOOR_PORTABLE: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="energy_mode_change_unavailable_outdoor_portable", @@ -130,7 +131,9 @@ async def async_switch_energy_mode( # Switch energy mode if required if current_mode != target_mode: try: - success = await self.async_push_data(ENERGY_MODE_WRITE_KEY, target_mode) + success = await self.async_push_data( + IndevoltConfig.WRITE_ENERGY_MODE, target_mode + ) except (DeviceTimeoutError, DeviceConnectionError) as err: raise HomeAssistantError( translation_domain=DOMAIN, @@ -146,19 +149,27 @@ async def async_switch_energy_mode( if refresh: await self.async_request_refresh() - async def async_execute_realtime_action(self, action: list[int]) -> None: + async def async_realtime_action( + self, + action: IndevoltRealtimeAction, + power: int = 0, + target_soc: int = 0, + ) -> None: """Switch mode, execute action, and refresh for real-time control.""" - await self.async_switch_energy_mode(REALTIME_ACTION_MODE, refresh=False) + await self.async_switch_energy_mode( + IndevoltEnergyMode.REAL_TIME_CONTROL, refresh=False + ) - try: - success = await self.async_push_data(REALTIME_ACTION_KEY, action) + success = False - except (DeviceTimeoutError, DeviceConnectionError) as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="failed_to_execute_realtime_action", - ) from err + match action: + case IndevoltRealtimeAction.CHARGE: + success = await self.api.charge(power, target_soc) + case IndevoltRealtimeAction.DISCHARGE: + success = await self.api.discharge(power, target_soc) + case IndevoltRealtimeAction.STOP: + success = await self.api.stop() if not success: raise HomeAssistantError( @@ -167,3 +178,7 @@ async def async_execute_realtime_action(self, action: list[int]) -> None: ) await self.async_request_refresh() + + def get_emergency_soc(self) -> int: + """Get the emergency SOC value.""" + return int(self.data[IndevoltConfig.READ_DISCHARGE_LIMIT]) diff --git a/homeassistant/components/indevolt/diagnostics.py b/homeassistant/components/indevolt/diagnostics.py index fadc6e63403ec9..f9d4f8c201c489 100644 --- a/homeassistant/components/indevolt/diagnostics.py +++ b/homeassistant/components/indevolt/diagnostics.py @@ -4,6 +4,8 @@ from typing import Any +from indevolt_api import IndevoltBattery, IndevoltSystem + from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant @@ -15,13 +17,13 @@ TO_REDACT = { CONF_HOST, CONF_SERIAL_NUMBER, - "0", - "9008", - "9032", - "9051", - "9070", - "9218", - "9165", + IndevoltSystem.SERIAL_NUMBER, + IndevoltBattery.MAIN_SERIAL_NUMBER, + IndevoltBattery.PACK_1_SERIAL_NUMBER, + IndevoltBattery.PACK_2_SERIAL_NUMBER, + IndevoltBattery.PACK_3_SERIAL_NUMBER, + IndevoltBattery.PACK_4_SERIAL_NUMBER, + IndevoltBattery.PACK_5_SERIAL_NUMBER, } diff --git a/homeassistant/components/indevolt/icons.json b/homeassistant/components/indevolt/icons.json new file mode 100644 index 00000000000000..13499365b25943 --- /dev/null +++ b/homeassistant/components/indevolt/icons.json @@ -0,0 +1,10 @@ +{ + "services": { + "charge": { + "service": "mdi:battery-arrow-up" + }, + "discharge": { + "service": "mdi:battery-arrow-down" + } + } +} diff --git a/homeassistant/components/indevolt/manifest.json b/homeassistant/components/indevolt/manifest.json index 04034c9661d0ce..2f5159f956e7a3 100644 --- a/homeassistant/components/indevolt/manifest.json +++ b/homeassistant/components/indevolt/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["indevolt-api==1.4.2"] + "requirements": ["indevolt-api==1.6.4"] } diff --git a/homeassistant/components/indevolt/number.py b/homeassistant/components/indevolt/number.py index a6fb24bd9d7ad9..4a69c49708fafb 100644 --- a/homeassistant/components/indevolt/number.py +++ b/homeassistant/components/indevolt/number.py @@ -5,6 +5,8 @@ from dataclasses import dataclass, field from typing import Final +from indevolt_api import IndevoltConfig + from homeassistant.components.number import ( NumberDeviceClass, NumberEntity, @@ -37,8 +39,8 @@ class IndevoltNumberEntityDescription(NumberEntityDescription): key="discharge_limit", generation=[2], translation_key="discharge_limit", - read_key="6105", - write_key="1142", + read_key=IndevoltConfig.READ_DISCHARGE_LIMIT, + write_key=IndevoltConfig.WRITE_DISCHARGE_LIMIT, native_min_value=0, native_max_value=100, native_step=1, @@ -48,8 +50,8 @@ class IndevoltNumberEntityDescription(NumberEntityDescription): key="max_ac_output_power", generation=[2], translation_key="max_ac_output_power", - read_key="11011", - write_key="1147", + read_key=IndevoltConfig.READ_MAX_AC_OUTPUT_POWER, + write_key=IndevoltConfig.WRITE_MAX_AC_OUTPUT_POWER, native_min_value=0, native_max_value=2400, native_step=100, @@ -60,8 +62,8 @@ class IndevoltNumberEntityDescription(NumberEntityDescription): key="inverter_input_limit", generation=[2], translation_key="inverter_input_limit", - read_key="11009", - write_key="1138", + read_key=IndevoltConfig.READ_INVERTER_INPUT_LIMIT, + write_key=IndevoltConfig.WRITE_INVERTER_INPUT_LIMIT, native_min_value=100, native_max_value=2400, native_step=100, @@ -72,8 +74,8 @@ class IndevoltNumberEntityDescription(NumberEntityDescription): key="feedin_power_limit", generation=[2], translation_key="feedin_power_limit", - read_key="11010", - write_key="1146", + read_key=IndevoltConfig.READ_FEEDIN_POWER_LIMIT, + write_key=IndevoltConfig.WRITE_FEEDIN_POWER_LIMIT, native_min_value=0, native_max_value=2400, native_step=100, diff --git a/homeassistant/components/indevolt/quality_scale.yaml b/homeassistant/components/indevolt/quality_scale.yaml index a532a7868ac3b2..713f32f91f63df 100644 --- a/homeassistant/components/indevolt/quality_scale.yaml +++ b/homeassistant/components/indevolt/quality_scale.yaml @@ -1,17 +1,13 @@ rules: # Bronze (mandatory for core integrations) - action-setup: - status: exempt - comment: Integration does not register custom actions + action-setup: done appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: Integration does not register custom actions + docs-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done @@ -26,9 +22,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: Integration does not register custom actions + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt diff --git a/homeassistant/components/indevolt/select.py b/homeassistant/components/indevolt/select.py index 2850ae2da522ea..4d7343364e1e27 100644 --- a/homeassistant/components/indevolt/select.py +++ b/homeassistant/components/indevolt/select.py @@ -5,6 +5,8 @@ from dataclasses import dataclass, field from typing import Final +from indevolt_api import IndevoltConfig, IndevoltEnergyMode + from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -23,8 +25,8 @@ class IndevoltSelectEntityDescription(SelectEntityDescription): read_key: str write_key: str - value_to_option: dict[int, str] - unavailable_values: list[int] = field(default_factory=list) + value_to_option: dict[IndevoltEnergyMode, str] + unavailable_values: list[IndevoltEnergyMode] = field(default_factory=list) generation: list[int] = field(default_factory=lambda: [1, 2]) @@ -32,14 +34,14 @@ class IndevoltSelectEntityDescription(SelectEntityDescription): IndevoltSelectEntityDescription( key="energy_mode", translation_key="energy_mode", - read_key="7101", - write_key="47005", + read_key=IndevoltConfig.READ_ENERGY_MODE, + write_key=IndevoltConfig.WRITE_ENERGY_MODE, value_to_option={ - 1: "self_consumed_prioritized", - 4: "real_time_control", - 5: "charge_discharge_schedule", + IndevoltEnergyMode.SELF_CONSUMED_PRIORITIZED: "self_consumed_prioritized", + IndevoltEnergyMode.REAL_TIME_CONTROL: "real_time_control", + IndevoltEnergyMode.CHARGE_DISCHARGE_SCHEDULE: "charge_discharge_schedule", }, - unavailable_values=[0], + unavailable_values=[IndevoltEnergyMode.OUTDOOR_PORTABLE], ), ) diff --git a/homeassistant/components/indevolt/sensor.py b/homeassistant/components/indevolt/sensor.py index cd080ce735d8f4..d99697431810f7 100644 --- a/homeassistant/components/indevolt/sensor.py +++ b/homeassistant/components/indevolt/sensor.py @@ -3,6 +3,14 @@ from dataclasses import dataclass, field from typing import Final +from indevolt_api import ( + IndevoltBattery, + IndevoltConfig, + IndevoltGrid, + IndevoltSolar, + IndevoltSystem, +) + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -40,7 +48,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): SENSORS: Final = ( # System Operating Information IndevoltSensorEntityDescription( - key="606", + key=IndevoltSystem.OPERATING_MODE, translation_key="mode", state_mapping={"1000": "main", "1001": "sub", "1002": "standalone"}, device_class=SensorDeviceClass.ENUM, @@ -48,7 +56,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="7101", + key=IndevoltConfig.READ_ENERGY_MODE, translation_key="energy_mode", state_mapping={ 0: "outdoor_portable", @@ -59,7 +67,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.ENUM, ), IndevoltSensorEntityDescription( - key="142", + key=IndevoltBattery.RATED_CAPACITY_GEN2, generation=[2], translation_key="rated_capacity", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -67,27 +75,27 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="6105", + key=IndevoltConfig.READ_DISCHARGE_LIMIT, generation=[1], translation_key="discharge_limit", native_unit_of_measurement=PERCENTAGE, ), IndevoltSensorEntityDescription( - key="2101", + key=IndevoltSystem.INPUT_POWER, translation_key="ac_input_power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), IndevoltSensorEntityDescription( - key="2108", + key=IndevoltSystem.OUTPUT_POWER, translation_key="ac_output_power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), IndevoltSensorEntityDescription( - key="667", + key=IndevoltSystem.BYPASS_POWER, generation=[2], translation_key="bypass_power", native_unit_of_measurement=UnitOfPower.WATT, @@ -96,14 +104,14 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): ), # Electrical Energy Information IndevoltSensorEntityDescription( - key="2107", + key=IndevoltSystem.TOTAL_INPUT_ENERGY, translation_key="total_ac_input_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="2104", + key=IndevoltSystem.TOTAL_OUTPUT_ENERGY, generation=[2], translation_key="total_ac_output_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -111,7 +119,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="2105", + key=IndevoltSystem.OFF_GRID_OUTPUT_ENERGY, generation=[2], translation_key="off_grid_output_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -119,7 +127,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="11034", + key=IndevoltSystem.BYPASS_INPUT_ENERGY, generation=[2], translation_key="bypass_input_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -127,7 +135,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="6004", + key=IndevoltBattery.DAILY_CHARGING_ENERGY, generation=[2], translation_key="battery_daily_charging_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -135,7 +143,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="6005", + key=IndevoltBattery.DAILY_DISCHARGING_ENERGY, generation=[2], translation_key="battery_daily_discharging_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -143,7 +151,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="6006", + key=IndevoltBattery.TOTAL_CHARGING_ENERGY, generation=[2], translation_key="battery_total_charging_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -151,7 +159,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="6007", + key=IndevoltBattery.TOTAL_DISCHARGING_ENERGY, generation=[2], translation_key="battery_total_discharging_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -160,7 +168,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): ), # Electricity Meter Status IndevoltSensorEntityDescription( - key="11016", + key=IndevoltGrid.METER_POWER_GEN2, generation=[2], translation_key="meter_power", native_unit_of_measurement=UnitOfPower.WATT, @@ -168,7 +176,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, ), IndevoltSensorEntityDescription( - key="21028", + key=IndevoltGrid.METER_POWER_GEN1, generation=[1], translation_key="meter_power", native_unit_of_measurement=UnitOfPower.WATT, @@ -177,7 +185,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): ), # Grid information IndevoltSensorEntityDescription( - key="2600", + key=IndevoltGrid.VOLTAGE, generation=[2], translation_key="grid_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -186,7 +194,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="2612", + key=IndevoltGrid.FREQUENCY, generation=[2], translation_key="grid_frequency", native_unit_of_measurement=UnitOfFrequency.HERTZ, @@ -196,20 +204,20 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): ), # Battery Pack Operating Parameters IndevoltSensorEntityDescription( - key="6000", + key=IndevoltBattery.POWER, translation_key="battery_power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), IndevoltSensorEntityDescription( - key="6001", + key=IndevoltBattery.CHARGE_DISCHARGE_STATE, translation_key="battery_charge_discharge_state", state_mapping={1000: "static", 1001: "charging", 1002: "discharging"}, device_class=SensorDeviceClass.ENUM, ), IndevoltSensorEntityDescription( - key="6002", + key=IndevoltBattery.SOC, translation_key="battery_soc", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, @@ -217,21 +225,21 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): ), # PV Operating Parameters IndevoltSensorEntityDescription( - key="1501", + key=IndevoltSolar.DC_OUTPUT_POWER, translation_key="dc_output_power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), IndevoltSensorEntityDescription( - key="1502", + key=IndevoltSolar.DAILY_PRODUCTION, translation_key="daily_production", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="1505", + key=IndevoltSolar.CUMULATIVE_PRODUCTION, translation_key="cumulative_production", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -239,7 +247,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="1632", + key=IndevoltSolar.DC_INPUT_CURRENT_1, generation=[2], translation_key="dc_input_current_1", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -248,7 +256,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1600", + key=IndevoltSolar.DC_INPUT_VOLTAGE_1, generation=[2], translation_key="dc_input_voltage_1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -257,7 +265,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1664", + key=IndevoltSolar.DC_INPUT_POWER_1, translation_key="dc_input_power_1", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -265,7 +273,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1633", + key=IndevoltSolar.DC_INPUT_CURRENT_2, generation=[2], translation_key="dc_input_current_2", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -274,7 +282,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1601", + key=IndevoltSolar.DC_INPUT_VOLTAGE_2, generation=[2], translation_key="dc_input_voltage_2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -283,7 +291,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1665", + key=IndevoltSolar.DC_INPUT_POWER_2, translation_key="dc_input_power_2", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -291,7 +299,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1634", + key=IndevoltSolar.DC_INPUT_CURRENT_3, generation=[2], translation_key="dc_input_current_3", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -300,7 +308,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1602", + key=IndevoltSolar.DC_INPUT_VOLTAGE_3, generation=[2], translation_key="dc_input_voltage_3", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -309,7 +317,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1666", + key=IndevoltSolar.DC_INPUT_POWER_3, generation=[2], translation_key="dc_input_power_3", native_unit_of_measurement=UnitOfPower.WATT, @@ -318,7 +326,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1635", + key=IndevoltSolar.DC_INPUT_CURRENT_4, generation=[2], translation_key="dc_input_current_4", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -327,7 +335,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1603", + key=IndevoltSolar.DC_INPUT_VOLTAGE_4, generation=[2], translation_key="dc_input_voltage_4", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -336,7 +344,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1667", + key=IndevoltSolar.DC_INPUT_POWER_4, generation=[2], translation_key="dc_input_power_4", native_unit_of_measurement=UnitOfPower.WATT, @@ -346,42 +354,42 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): ), # Battery Pack Serial Numbers IndevoltSensorEntityDescription( - key="9008", + key=IndevoltBattery.MAIN_SERIAL_NUMBER, generation=[2], translation_key="main_serial_number", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9032", + key=IndevoltBattery.PACK_1_SERIAL_NUMBER, generation=[2], translation_key="battery_pack_1_serial_number", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9051", + key=IndevoltBattery.PACK_2_SERIAL_NUMBER, generation=[2], translation_key="battery_pack_2_serial_number", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9070", + key=IndevoltBattery.PACK_3_SERIAL_NUMBER, generation=[2], translation_key="battery_pack_3_serial_number", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9165", + key=IndevoltBattery.PACK_4_SERIAL_NUMBER, generation=[2], translation_key="battery_pack_4_serial_number", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9218", + key=IndevoltBattery.PACK_5_SERIAL_NUMBER, generation=[2], translation_key="battery_pack_5_serial_number", entity_category=EntityCategory.DIAGNOSTIC, @@ -389,7 +397,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): ), # Battery Pack SOC IndevoltSensorEntityDescription( - key="9000", + key=IndevoltBattery.MAIN_SOC, generation=[2], translation_key="main_soc", native_unit_of_measurement=PERCENTAGE, @@ -399,7 +407,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9016", + key=IndevoltBattery.PACK_1_SOC, generation=[2], translation_key="battery_pack_1_soc", native_unit_of_measurement=PERCENTAGE, @@ -409,7 +417,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9035", + key=IndevoltBattery.PACK_2_SOC, generation=[2], translation_key="battery_pack_2_soc", native_unit_of_measurement=PERCENTAGE, @@ -419,7 +427,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9054", + key=IndevoltBattery.PACK_3_SOC, generation=[2], translation_key="battery_pack_3_soc", native_unit_of_measurement=PERCENTAGE, @@ -429,7 +437,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9149", + key=IndevoltBattery.PACK_4_SOC, generation=[2], translation_key="battery_pack_4_soc", native_unit_of_measurement=PERCENTAGE, @@ -439,7 +447,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9202", + key=IndevoltBattery.PACK_5_SOC, generation=[2], translation_key="battery_pack_5_soc", native_unit_of_measurement=PERCENTAGE, @@ -450,7 +458,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): ), # Battery Pack Temperature IndevoltSensorEntityDescription( - key="9012", + key=IndevoltBattery.MAIN_TEMPERATURE, generation=[2], translation_key="main_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -460,7 +468,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9030", + key=IndevoltBattery.PACK_1_TEMPERATURE, generation=[2], translation_key="battery_pack_1_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -470,7 +478,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9049", + key=IndevoltBattery.PACK_2_TEMPERATURE, generation=[2], translation_key="battery_pack_2_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -480,7 +488,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9068", + key=IndevoltBattery.PACK_3_TEMPERATURE, generation=[2], translation_key="battery_pack_3_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -490,7 +498,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9163", + key=IndevoltBattery.PACK_4_TEMPERATURE, generation=[2], translation_key="battery_pack_4_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -500,7 +508,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9216", + key=IndevoltBattery.PACK_5_TEMPERATURE, generation=[2], translation_key="battery_pack_5_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -511,7 +519,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): ), # Battery Pack Voltage IndevoltSensorEntityDescription( - key="9004", + key=IndevoltBattery.MAIN_VOLTAGE, generation=[2], translation_key="main_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -521,7 +529,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9020", + key=IndevoltBattery.PACK_1_VOLTAGE, generation=[2], translation_key="battery_pack_1_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -531,7 +539,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9039", + key=IndevoltBattery.PACK_2_VOLTAGE, generation=[2], translation_key="battery_pack_2_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -541,7 +549,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9058", + key=IndevoltBattery.PACK_3_VOLTAGE, generation=[2], translation_key="battery_pack_3_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -551,7 +559,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9153", + key=IndevoltBattery.PACK_4_VOLTAGE, generation=[2], translation_key="battery_pack_4_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -561,7 +569,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9206", + key=IndevoltBattery.PACK_5_VOLTAGE, generation=[2], translation_key="battery_pack_5_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -572,7 +580,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): ), # Battery Pack Current IndevoltSensorEntityDescription( - key="9013", + key=IndevoltBattery.MAIN_CURRENT, generation=[2], translation_key="main_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -582,7 +590,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="19173", + key=IndevoltBattery.PACK_1_CURRENT, generation=[2], translation_key="battery_pack_1_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -592,7 +600,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="19174", + key=IndevoltBattery.PACK_2_CURRENT, generation=[2], translation_key="battery_pack_2_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -602,7 +610,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="19175", + key=IndevoltBattery.PACK_3_CURRENT, generation=[2], translation_key="battery_pack_3_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -612,7 +620,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="19176", + key=IndevoltBattery.PACK_4_CURRENT, generation=[2], translation_key="battery_pack_4_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -622,7 +630,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="19177", + key=IndevoltBattery.PACK_5_CURRENT, generation=[2], translation_key="battery_pack_5_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -635,11 +643,41 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): # Sensors per battery pack (SN, SOC, Temperature, Voltage, Current) BATTERY_PACK_SENSOR_KEYS = [ - ("9032", "9016", "9030", "9020", "19173"), # Battery Pack 1 - ("9051", "9035", "9049", "9039", "19174"), # Battery Pack 2 - ("9070", "9054", "9068", "9058", "19175"), # Battery Pack 3 - ("9165", "9149", "9163", "9153", "19176"), # Battery Pack 4 - ("9218", "9202", "9216", "9206", "19177"), # Battery Pack 5 + ( + IndevoltBattery.PACK_1_SERIAL_NUMBER, + IndevoltBattery.PACK_1_SOC, + IndevoltBattery.PACK_1_TEMPERATURE, + IndevoltBattery.PACK_1_VOLTAGE, + IndevoltBattery.PACK_1_CURRENT, + ), # Battery Pack 1 + ( + IndevoltBattery.PACK_2_SERIAL_NUMBER, + IndevoltBattery.PACK_2_SOC, + IndevoltBattery.PACK_2_TEMPERATURE, + IndevoltBattery.PACK_2_VOLTAGE, + IndevoltBattery.PACK_2_CURRENT, + ), # Battery Pack 2 + ( + IndevoltBattery.PACK_3_SERIAL_NUMBER, + IndevoltBattery.PACK_3_SOC, + IndevoltBattery.PACK_3_TEMPERATURE, + IndevoltBattery.PACK_3_VOLTAGE, + IndevoltBattery.PACK_3_CURRENT, + ), # Battery Pack 3 + ( + IndevoltBattery.PACK_4_SERIAL_NUMBER, + IndevoltBattery.PACK_4_SOC, + IndevoltBattery.PACK_4_TEMPERATURE, + IndevoltBattery.PACK_4_VOLTAGE, + IndevoltBattery.PACK_4_CURRENT, + ), # Battery Pack 4 + ( + IndevoltBattery.PACK_5_SERIAL_NUMBER, + IndevoltBattery.PACK_5_SOC, + IndevoltBattery.PACK_5_TEMPERATURE, + IndevoltBattery.PACK_5_VOLTAGE, + IndevoltBattery.PACK_5_CURRENT, + ), # Battery Pack 5 ] diff --git a/homeassistant/components/indevolt/services.py b/homeassistant/components/indevolt/services.py new file mode 100644 index 00000000000000..25b37951b90dee --- /dev/null +++ b/homeassistant/components/indevolt/services.py @@ -0,0 +1,218 @@ +"""Services for Indevolt integration.""" + +from __future__ import annotations + +import asyncio +from typing import Final, Never + +from indevolt_api import ( + IndevoltRealtimeAction, + PowerExceedsMaxError, + SocBelowMinimumError, +) +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service import async_extract_config_entry_ids + +from .const import DOMAIN +from .coordinator import IndevoltCoordinator + +RT_ACTION_SERVICE_SCHEMA: Final = vol.Schema( + { + vol.Required("device_id"): vol.All( + cv.ensure_list, + [cv.string], + ), + vol.Required("target_soc"): vol.All( + vol.Coerce(int), + vol.Range(min=0, max=100), + ), + vol.Required("power"): vol.All( + vol.Coerce(int), + vol.Range(min=1, max=2400), + ), + } +) + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for Indevolt integration.""" + + async def charge(call: ServiceCall) -> None: + """Handle the service call to start charging.""" + await _async_handle_realtime_action(hass, call, IndevoltRealtimeAction.CHARGE) + + async def discharge(call: ServiceCall) -> None: + """Handle the service call to start discharging.""" + await _async_handle_realtime_action( + hass, call, IndevoltRealtimeAction.DISCHARGE + ) + + hass.services.async_register( + DOMAIN, "charge", charge, schema=RT_ACTION_SERVICE_SCHEMA + ) + hass.services.async_register( + DOMAIN, "discharge", discharge, schema=RT_ACTION_SERVICE_SCHEMA + ) + + +async def _async_handle_realtime_action( + hass: HomeAssistant, + call: ServiceCall, + action: IndevoltRealtimeAction, +) -> None: + """Validate and execute a realtime action for one or more coordinators.""" + coordinators = await _async_get_coordinators_from_call(hass, call) + + power: int = call.data["power"] + target_soc: int = call.data["target_soc"] + + _validate_realtime_action(coordinators, action, power, target_soc) + await _execute_realtime_action(coordinators, action, power, target_soc) + + +async def _async_get_coordinators_from_call( + hass: HomeAssistant, + call: ServiceCall, +) -> list[IndevoltCoordinator]: + """Resolve coordinator(s) targeted by a service call.""" + entry_ids = await async_extract_config_entry_ids(call) + + coordinators: list[IndevoltCoordinator] = [ + entry.runtime_data + for entry in hass.config_entries.async_loaded_entries(DOMAIN) + if entry.entry_id in entry_ids + ] + + if not coordinators: + _raise_no_target_entries() + + return coordinators + + +def _validate_realtime_action( + coordinators: list[IndevoltCoordinator], + action: IndevoltRealtimeAction, + power: int, + target_soc: int, +) -> None: + """Validate parameters prior to calling `_execute_realtime_action`.""" + + errors: list[str] = [] + + for coordinator in coordinators: + try: + try: + match action: + case IndevoltRealtimeAction.CHARGE: + coordinator.api.check_charge_limits( + power, target_soc, coordinator.generation + ) + case IndevoltRealtimeAction.DISCHARGE: + coordinator.api.check_discharge_limits( + power, target_soc, coordinator.generation + ) + + except PowerExceedsMaxError as err: + _raise_power_exceeds_max(err.power, err.max_power, err.generation) + + except SocBelowMinimumError as err: + _raise_soc_below_minimum(err.target_soc, err.minimum_soc) + + # Validate target SOC against known emergency SOC (soft limit) + emergency_soc = coordinator.get_emergency_soc() + if target_soc < emergency_soc: + _raise_soc_below_emergency(target_soc, emergency_soc) + + except ServiceValidationError as err: + if len(coordinators) == 1: + raise + + errors.append(f"{coordinator.friendly_name}: {err}") + + if errors: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="multi_device_errors", + translation_placeholders={"errors": "; ".join(errors)}, + ) + + +async def _execute_realtime_action( + coordinators: list[IndevoltCoordinator], + action: IndevoltRealtimeAction, + power: int, + target_soc: int, +) -> None: + """Execute async_execute_realtime_action on all coordinators concurrently.""" + results: list[None | BaseException] = await asyncio.gather( + *( + coordinator.async_realtime_action(action, power, target_soc) + for coordinator in coordinators + ), + return_exceptions=True, + ) + + errors: list[str] = [] + + for coordinator, result in zip(coordinators, results, strict=True): + if isinstance(result, BaseException): + if len(coordinators) == 1 or not isinstance(result, Exception): + raise result + + errors.append(f"{coordinator.friendly_name}: {result}") + + if errors: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="multi_device_errors", + translation_placeholders={"errors": "; ".join(errors)}, + ) + + +def _raise_power_exceeds_max(power: int, max_power: int, generation: int) -> Never: + """Raise a translated validation error for out-of-range power.""" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="power_exceeds_max", + translation_placeholders={ + "power": str(power), + "max_power": str(max_power), + "generation": str(generation), + }, + ) + + +def _raise_soc_below_minimum(target_soc: int, minimum_soc: int) -> Never: + """Raise a translated validation error when SOC is below the device's hard minimum.""" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="soc_below_minimum", + translation_placeholders={ + "target": str(target_soc), + "minimum_soc": str(minimum_soc), + }, + ) + + +def _raise_soc_below_emergency(target: int, emergency_soc: int) -> Never: + """Raise a translated validation error for out-of-range SOC.""" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="soc_below_emergency", + translation_placeholders={ + "target": str(target), + "emergency_soc": str(emergency_soc), + }, + ) + + +def _raise_no_target_entries() -> Never: + """Raise a translated validation error for missing/invalid service targets.""" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_matching_target_entries", + ) diff --git a/homeassistant/components/indevolt/services.yaml b/homeassistant/components/indevolt/services.yaml new file mode 100644 index 00000000000000..786cdbfbc2e10c --- /dev/null +++ b/homeassistant/components/indevolt/services.yaml @@ -0,0 +1,49 @@ +charge: + fields: + device_id: + required: true + selector: + device: + multiple: true + integration: indevolt + target_soc: + required: true + selector: + number: + min: 0 + max: 100 + step: 1 + unit_of_measurement: "%" + power: + required: true + selector: + number: + min: 1 + max: 2400 + step: 1 + unit_of_measurement: "W" + +discharge: + fields: + device_id: + required: true + selector: + device: + multiple: true + integration: indevolt + target_soc: + required: true + selector: + number: + min: 0 + max: 100 + step: 1 + unit_of_measurement: "%" + power: + required: true + selector: + number: + min: 1 + max: 2400 + step: 1 + unit_of_measurement: "W" diff --git a/homeassistant/components/indevolt/strings.json b/homeassistant/components/indevolt/strings.json index 0ad757e4616a17..44de0f565201ff 100644 --- a/homeassistant/components/indevolt/strings.json +++ b/homeassistant/components/indevolt/strings.json @@ -310,6 +310,59 @@ }, "failed_to_switch_energy_mode": { "message": "Failed to switch to requested energy mode" + }, + "multi_device_errors": { + "message": "One or more devices reported errors: {errors}" + }, + "no_matching_target_entries": { + "message": "No matching Indevolt devices found in the selected targets" + }, + "power_exceeds_max": { + "message": "Power ({power}W) exceeds maximum ({max_power}W) for generation ({generation}) devices" + }, + "soc_below_emergency": { + "message": "Target SOC ({target}%) is below emergency SOC ({emergency_soc}%)" + }, + "soc_below_minimum": { + "message": "Target SOC ({target}%) is below the device minimum ({minimum_soc}%)" + } + }, + "services": { + "charge": { + "description": "Real-time control: Starts charging with configured power until the target SOC is reached.", + "fields": { + "device_id": { + "description": "The Indevolt device(s) to start charging.", + "name": "Device(s)" + }, + "power": { + "description": "Maximum charging power in watts.", + "name": "Max. power" + }, + "target_soc": { + "description": "Target state of charge percentage.", + "name": "Target SOC" + } + }, + "name": "Charge" + }, + "discharge": { + "description": "Real-time control: Starts discharging with configured power until the target SOC is reached.", + "fields": { + "device_id": { + "description": "The Indevolt device(s) to start discharging.", + "name": "[%key:component::indevolt::services::charge::fields::device_id::name%]" + }, + "power": { + "description": "Maximum discharging power in watts.", + "name": "Max. power" + }, + "target_soc": { + "description": "[%key:component::indevolt::services::charge::fields::target_soc::description%]", + "name": "[%key:component::indevolt::services::charge::fields::target_soc::name%]" + } + }, + "name": "Discharge" } } } diff --git a/homeassistant/components/indevolt/switch.py b/homeassistant/components/indevolt/switch.py index c5bab6053ad963..a908b5d9782d27 100644 --- a/homeassistant/components/indevolt/switch.py +++ b/homeassistant/components/indevolt/switch.py @@ -5,6 +5,8 @@ from dataclasses import dataclass, field from typing import Any, Final +from indevolt_api import IndevoltConfig + from homeassistant.components.switch import ( SwitchDeviceClass, SwitchEntity, @@ -37,8 +39,8 @@ class IndevoltSwitchEntityDescription(SwitchEntityDescription): key="grid_charging", translation_key="grid_charging", generation=[2], - read_key="2618", - write_key="1143", + read_key=IndevoltConfig.READ_GRID_CHARGING, + write_key=IndevoltConfig.WRITE_GRID_CHARGING, read_on_value=1001, read_off_value=1000, device_class=SwitchDeviceClass.SWITCH, @@ -47,16 +49,16 @@ class IndevoltSwitchEntityDescription(SwitchEntityDescription): key="light", translation_key="light", generation=[2], - read_key="7171", - write_key="7265", + read_key=IndevoltConfig.READ_LIGHT, + write_key=IndevoltConfig.WRITE_LIGHT, device_class=SwitchDeviceClass.SWITCH, ), IndevoltSwitchEntityDescription( key="bypass", translation_key="bypass", generation=[2], - read_key="680", - write_key="7266", + read_key=IndevoltConfig.READ_BYPASS, + write_key=IndevoltConfig.WRITE_BYPASS, device_class=SwitchDeviceClass.SWITCH, ), ) diff --git a/homeassistant/components/insteon/services.py b/homeassistant/components/insteon/services.py index eb671a720ad0a2..7bcdabbc06865e 100644 --- a/homeassistant/components/insteon/services.py +++ b/homeassistant/components/insteon/services.py @@ -35,6 +35,7 @@ async_dispatcher_send, dispatcher_send, ) +from homeassistant.helpers.service import async_register_admin_service from .const import ( CONF_CAT, @@ -231,11 +232,19 @@ async def async_remove_insteon_device( ) await async_srv_save_devices() - hass.services.async_register( - DOMAIN, SRV_ADD_ALL_LINK, async_srv_add_all_link, schema=ADD_ALL_LINK_SCHEMA + async_register_admin_service( + hass, + DOMAIN, + SRV_ADD_ALL_LINK, + async_srv_add_all_link, + schema=ADD_ALL_LINK_SCHEMA, ) - hass.services.async_register( - DOMAIN, SRV_DEL_ALL_LINK, async_srv_del_all_link, schema=DEL_ALL_LINK_SCHEMA + async_register_admin_service( + hass, + DOMAIN, + SRV_DEL_ALL_LINK, + async_srv_del_all_link, + schema=DEL_ALL_LINK_SCHEMA, ) hass.services.async_register( DOMAIN, SRV_LOAD_ALDB, async_srv_load_aldb, schema=LOAD_ALDB_SCHEMA @@ -269,7 +278,8 @@ async def async_remove_insteon_device( DOMAIN, SRV_SCENE_OFF, async_srv_scene_off, schema=TRIGGER_SCENE_SCHEMA ) - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SRV_ADD_DEFAULT_LINKS, async_add_default_links, diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index 8d58a0dd45b58f..4005c294bcec21 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -78,7 +78,10 @@ async def async_reload(hass: HomeAssistant, service_call: ServiceCall) -> None: new_config = await async_integration_yaml_config(hass, DOMAIN) existing_intents = hass.data[DOMAIN] - for intent_type in existing_intents: + for intent_type, conf in existing_intents.items(): + if isinstance(conf.get(CONF_ACTION), script.Script): + await conf[CONF_ACTION].async_stop() + conf[CONF_ACTION].async_unload() intent.async_remove(hass, intent_type) if not new_config or DOMAIN not in new_config: diff --git a/homeassistant/components/israel_rail/coordinator.py b/homeassistant/components/israel_rail/coordinator.py index 190ed938790a37..4ed29908af5660 100644 --- a/homeassistant/components/israel_rail/coordinator.py +++ b/homeassistant/components/israel_rail/coordinator.py @@ -25,6 +25,7 @@ class DataConnection: """A connection data class.""" departure: datetime | None + departure_delay: int | None platform: str start: str destination: str @@ -83,6 +84,7 @@ async def _async_update_data(self) -> list[DataConnection]: return [ DataConnection( departure=departure_time(train_routes[i]), + departure_delay=train_routes[i].trains[0].departure_delay, train_number=train_routes[i].trains[0].data["trainNumber"], platform=train_routes[i].trains[0].platform, trains=len(train_routes[i].trains), diff --git a/homeassistant/components/israel_rail/manifest.json b/homeassistant/components/israel_rail/manifest.json index 0362f7d2224cb5..ad9f3c1a17f9b5 100644 --- a/homeassistant/components/israel_rail/manifest.json +++ b/homeassistant/components/israel_rail/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["israelrailapi"], - "requirements": ["israel-rail-api==0.1.4"] + "requirements": ["israel-rail-api==0.1.5"] } diff --git a/homeassistant/components/israel_rail/sensor.py b/homeassistant/components/israel_rail/sensor.py index 6e3324de7ae850..f4ea5f589ed038 100644 --- a/homeassistant/components/israel_rail/sensor.py +++ b/homeassistant/components/israel_rail/sensor.py @@ -12,7 +12,9 @@ SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) +from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -67,6 +69,15 @@ class IsraelRailSensorEntityDescription(SensorEntityDescription): translation_key="train_number", value_fn=lambda data_connection: data_connection.train_number, ), + IsraelRailSensorEntityDescription( + key="departure_delay", + translation_key="departure_delay", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value_fn=lambda data_connection: data_connection.departure_delay, + ), ) diff --git a/homeassistant/components/israel_rail/strings.json b/homeassistant/components/israel_rail/strings.json index 3b16015fe3495d..e7380c80245755 100644 --- a/homeassistant/components/israel_rail/strings.json +++ b/homeassistant/components/israel_rail/strings.json @@ -28,6 +28,9 @@ "departure2": { "name": "Departure +2" }, + "departure_delay": { + "name": "Departure delay" + }, "platform": { "name": "Platform" }, diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index b43385a0e5de7e..9a0acf73601857 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -16,6 +16,7 @@ HVACMode, ) from homeassistant.components.lock import LockState +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, @@ -431,7 +432,7 @@ "127": UnitOfPressure.MMHG, "128": "J", "129": "BMI", # Body Mass Index - "130": f"{UnitOfVolume.LITERS}/{UnitOfTime.HOURS}", + "130": UnitOfVolumeFlowRate.LITERS_PER_HOUR, "131": SIGNAL_STRENGTH_DECIBELS_MILLIWATT, "132": "bpm", # Breaths per minute "133": UnitOfFrequency.KILOHERTZ, @@ -444,8 +445,8 @@ "140": f"{UnitOfMass.MILLIGRAMS}/{UnitOfVolume.LITERS}", "141": "N", # Netwon "142": f"{UnitOfVolume.GALLONS}/{UnitOfTime.SECONDS}", - "143": "gpm", # Gallon per Minute - "144": "gph", # Gallon per Hour + "143": UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + "144": UnitOfVolumeFlowRate.GALLONS_PER_HOUR, } UOM_TO_STATES = { @@ -653,6 +654,13 @@ HA_FAN_TO_ISY = {FAN_ON: "on", FAN_AUTO: "auto"} +TOTAL_INCREASING_DEVICE_CLASSES = { + SensorDeviceClass.ENERGY, + SensorDeviceClass.WATER, + SensorDeviceClass.GAS, + SensorDeviceClass.PRECIPITATION, +} + BINARY_SENSOR_DEVICE_TYPES_ISY = { BinarySensorDeviceClass.MOISTURE: ["16.8.", "16.13.", "16.14."], BinarySensorDeviceClass.OPENING: [ diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 6e0b5a89637953..6a0b19d3284f96 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -29,13 +29,19 @@ SensorEntity, SensorStateClass, ) -from homeassistant.const import EntityCategory, Platform, UnitOfTemperature +from homeassistant.const import ( + EntityCategory, + Platform, + UnitOfTemperature, + UnitOfVolumeFlowRate, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( _LOGGER, + TOTAL_INCREASING_DEVICE_CLASSES, UOM_DOUBLE_TEMP, UOM_FRIENDLY_NAME, UOM_INDEX, @@ -73,6 +79,7 @@ "DISTANC": SensorDeviceClass.DISTANCE, "ETO": SensorDeviceClass.PRECIPITATION_INTENSITY, # codespell:ignore eto "FATM": SensorDeviceClass.WEIGHT, + "FLOW": SensorDeviceClass.VOLUME_FLOW_RATE, "FREQ": SensorDeviceClass.FREQUENCY, "MUSCLEM": SensorDeviceClass.WEIGHT, "PF": SensorDeviceClass.POWER_FACTOR, @@ -95,9 +102,56 @@ "WEIGHT": SensorDeviceClass.WEIGHT, "WINDCH": SensorDeviceClass.TEMPERATURE, } -ISY_CONTROL_TO_STATE_CLASS = dict.fromkeys( - ISY_CONTROL_TO_DEVICE_CLASS, SensorStateClass.MEASUREMENT -) +UOM_TO_DEVICE_CLASS = { + "1": SensorDeviceClass.CURRENT, + "3": SensorDeviceClass.POWER, + "4": SensorDeviceClass.TEMPERATURE, + "7": SensorDeviceClass.VOLUME_FLOW_RATE, + "12": SensorDeviceClass.SOUND_PRESSURE, + "13": SensorDeviceClass.SOUND_PRESSURE, + "17": SensorDeviceClass.TEMPERATURE, + "23": SensorDeviceClass.ATMOSPHERIC_PRESSURE, + "24": SensorDeviceClass.PRECIPITATION_INTENSITY, + "26": SensorDeviceClass.TEMPERATURE, + "28": SensorDeviceClass.WEIGHT, + "29": SensorDeviceClass.VOLTAGE, + "30": SensorDeviceClass.POWER, + "31": SensorDeviceClass.PRESSURE, + "32": SensorDeviceClass.SPEED, + "33": SensorDeviceClass.ENERGY, + "35": SensorDeviceClass.WATER, + "39": SensorDeviceClass.VOLUME_FLOW_RATE, + "40": SensorDeviceClass.SPEED, + "41": SensorDeviceClass.CURRENT, + "43": SensorDeviceClass.VOLTAGE, + "46": SensorDeviceClass.PRECIPITATION_INTENSITY, + "48": SensorDeviceClass.SPEED, + "49": SensorDeviceClass.SPEED, + "52": SensorDeviceClass.WEIGHT, + "54": SensorDeviceClass.CO2, + "69": SensorDeviceClass.WATER, + "72": SensorDeviceClass.VOLTAGE, + "73": SensorDeviceClass.POWER, + "74": SensorDeviceClass.IRRADIANCE, + "82": SensorDeviceClass.DISTANCE, + "83": SensorDeviceClass.DISTANCE, + "90": SensorDeviceClass.FREQUENCY, + "105": SensorDeviceClass.DISTANCE, + "106": SensorDeviceClass.PRECIPITATION_INTENSITY, + "116": SensorDeviceClass.DISTANCE, + "117": SensorDeviceClass.PRESSURE, + "118": SensorDeviceClass.ATMOSPHERIC_PRESSURE, + "119": SensorDeviceClass.ENERGY, + "120": SensorDeviceClass.PRECIPITATION_INTENSITY, + "127": SensorDeviceClass.PRESSURE, + "130": SensorDeviceClass.VOLUME_FLOW_RATE, + "131": SensorDeviceClass.SIGNAL_STRENGTH, + "133": SensorDeviceClass.FREQUENCY, + "138": SensorDeviceClass.PRESSURE, + "142": SensorDeviceClass.VOLUME_FLOW_RATE, + "143": SensorDeviceClass.VOLUME_FLOW_RATE, + "144": SensorDeviceClass.VOLUME_FLOW_RATE, +} ISY_CONTROL_TO_ENTITY_CATEGORY = { PROP_RAMP_RATE: EntityCategory.DIAGNOSTIC, PROP_ON_LEVEL: EntityCategory.DIAGNOSTIC, @@ -105,6 +159,21 @@ } +def _check_volume_flow_rate_uom( + device_class: SensorDeviceClass | None, + uom: str | list[str] | None, +) -> SensorDeviceClass | None: + """Check if the volume flow rate unit is supported.""" + if device_class != SensorDeviceClass.VOLUME_FLOW_RATE: + return device_class + # Backwards compatibility for ISYv4 firmware which may return a list. + if isinstance(uom, list): + uom = uom[0] if uom else None + if uom is not None and UOM_FRIENDLY_NAME.get(uom) in UnitOfVolumeFlowRate: + return device_class + return None + + async def async_setup_entry( hass: HomeAssistant, entry: IsyConfigEntry, @@ -141,6 +210,26 @@ async def async_setup_entry( class ISYSensorEntity(ISYNodeEntity, SensorEntity): """Representation of an ISY sensor device.""" + def __init__(self, node: Node, device_info: DeviceInfo | None = None) -> None: + """Initialize the ISY sensor.""" + super().__init__(node, device_info=device_info) + uom = self._node.uom + if isinstance(uom, list): + uom = uom[0] + + # Determine device class + self._attr_device_class = _check_volume_flow_rate_uom( + UOM_TO_DEVICE_CLASS.get(uom), uom + ) + + # Determine state class + if self._attr_device_class in TOTAL_INCREASING_DEVICE_CLASSES: + self._attr_state_class = SensorStateClass.TOTAL_INCREASING + elif self._attr_device_class is not None: + self._attr_state_class = SensorStateClass.MEASUREMENT + else: + self._attr_state_class = None + @property def target(self) -> Node | NodeProperty | None: """Return target for the sensor.""" @@ -240,8 +329,24 @@ def __init__( self._control = control self._attr_entity_registry_enabled_default = enabled_default self._attr_entity_category = ISY_CONTROL_TO_ENTITY_CATEGORY.get(control) - self._attr_device_class = ISY_CONTROL_TO_DEVICE_CLASS.get(control) - self._attr_state_class = ISY_CONTROL_TO_STATE_CLASS.get(control) + + uom = None + if control in self._node.aux_properties: + uom = self._node.aux_properties[control].uom + + # Determine device class + self._attr_device_class = _check_volume_flow_rate_uom( + ISY_CONTROL_TO_DEVICE_CLASS.get(control), uom + ) + + # Determine state class + if self._attr_device_class in TOTAL_INCREASING_DEVICE_CLASSES: + self._attr_state_class = SensorStateClass.TOTAL_INCREASING + elif self._attr_device_class is not None: + self._attr_state_class = SensorStateClass.MEASUREMENT + else: + self._attr_state_class = None + self._attr_unique_id = unique_id self._change_handler: EventListener = None self._availability_handler: EventListener = None diff --git a/homeassistant/components/jvc_projector/coordinator.py b/homeassistant/components/jvc_projector/coordinator.py index cbde80b65bc902..52d45acd33b726 100644 --- a/homeassistant/components/jvc_projector/coordinator.py +++ b/homeassistant/components/jvc_projector/coordinator.py @@ -7,7 +7,12 @@ import logging from typing import TYPE_CHECKING, Any -from jvcprojector import JvcProjector, JvcProjectorTimeoutError, command as cmd +from jvcprojector import ( + JvcProjector, + JvcProjectorCommandError, + JvcProjectorTimeoutError, + command as cmd, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -144,7 +149,16 @@ async def _update_command_state( self, command: type[Command], new_state: dict[type[Command], str] ) -> str | None: """Update state with the current value of a command.""" - value = await self.device.get(command) + try: + value = await self.device.get(command) + except JvcProjectorCommandError as err: + _LOGGER.warning("Command %s failed: %s", command.name, err) + cached = self.state.get(command) + if command is cmd.Power and cached is None: + raise UpdateFailed( + f"Failed to fetch {command.name} and no cached value is available" + ) from err + return cached if value != self.state.get(command): new_state[command] = value diff --git a/homeassistant/components/jvc_projector/manifest.json b/homeassistant/components/jvc_projector/manifest.json index 0d9fb766a3f856..d2913b5dd902bc 100644 --- a/homeassistant/components/jvc_projector/manifest.json +++ b/homeassistant/components/jvc_projector/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["jvcprojector"], - "requirements": ["pyjvcprojector==2.0.5"] + "requirements": ["pyjvcprojector==2.0.6"] } diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 3946c5ee12f101..7bfaf0d831df09 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.15.0", "xknxproject==3.8.2", - "knx-frontend==2026.4.22.141111" + "knx-frontend==2026.4.25.155016" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index 3df90ed45fd417..7a51b01d9c9f61 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -14,7 +14,7 @@ from ..const import DOMAIN, KNX_MODULE_KEY from . import migration from .const import CONF_DATA -from .expose_controller import KNXExposeStoreModel, KNXExposeStoreOptionModel +from .expose_controller import KNXExposeStoreConfigModel, KNXExposeStoreModel from .time_server import KNXTimeServerStoreModel _LOGGER = logging.getLogger(__name__) @@ -201,20 +201,26 @@ def get_exposes(self) -> KNXExposeStoreModel: def get_expose_groups(self) -> dict[str, list[str]]: """Return KNX entity state exposes and their group addresses.""" return { - entity_id: [option["ga"]["write"] for option in config] + entity_id: [option["ga"]["write"] for option in config["options"]] for entity_id, config in self.data["expose"].items() } - def get_expose_config(self, entity_id: str) -> list[KNXExposeStoreOptionModel]: - """Return KNX entity state expose configuration for an entity.""" - return self.data["expose"].get(entity_id, []) + def get_expose_config(self, entity_id: str) -> KNXExposeStoreConfigModel: + """Return KNX entity state expose configuration and notes for an entity.""" + return self.data["expose"].get(entity_id, KNXExposeStoreConfigModel(options=[])) async def update_expose( - self, entity_id: str, expose_config: list[KNXExposeStoreOptionModel] + self, entity_id: str, expose_config: KNXExposeStoreConfigModel ) -> None: - """Update KNX expose configuration for an entity.""" + """Update KNX expose configuration for an entity. + + Args: + entity_id: The entity ID to configure. + expose_config: Expose configuration with options and optional notes. + """ knx_module = self.hass.data[KNX_MODULE_KEY] expose_controller = knx_module.ui_expose_controller + expose_controller.update_entity_expose( self.hass, knx_module.xknx, entity_id, expose_config ) diff --git a/homeassistant/components/knx/storage/expose_controller.py b/homeassistant/components/knx/storage/expose_controller.py index cd67cdd44f60dc..524ceabaab09c8 100644 --- a/homeassistant/components/knx/storage/expose_controller.py +++ b/homeassistant/components/knx/storage/expose_controller.py @@ -7,7 +7,6 @@ from xknx.dpt import DPTBase from xknx.telegram.address import parse_device_group_address -from homeassistant.const import CONF_ENTITY_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, @@ -19,10 +18,6 @@ from .entity_store_validation import validate_config_store_data from .knx_selector import GASelector -type KNXExposeStoreModel = dict[ - str, list[KNXExposeStoreOptionModel] # entity_id: configuration -] - class KNXExposeStoreOptionModel(TypedDict): """Represent KNX entity state expose configuration for an entity.""" @@ -36,11 +31,21 @@ class KNXExposeStoreOptionModel(TypedDict): value_template: NotRequired[str] +class KNXExposeStoreConfigModel(TypedDict): + """Represent stored KNX expose configuration with metadata.""" + + options: list[KNXExposeStoreOptionModel] + notes: NotRequired[str] + + +type KNXExposeStoreModel = dict[str, KNXExposeStoreConfigModel] # dict[entity_id: conf] + + class KNXExposeDataModel(TypedDict): """Represent a loaded KNX expose config for validation.""" entity_id: str - options: list[KNXExposeStoreOptionModel] + data: KNXExposeStoreConfigModel def validate_expose_template_no_coerce(value: str) -> str: @@ -72,8 +77,13 @@ def validate_expose_template_no_coerce(value: str) -> str: EXPOSE_CONFIG_SCHEMA = vol.Schema( { - vol.Required(CONF_ENTITY_ID): selector.EntitySelector(), - vol.Required("options"): [EXPOSE_OPTION_SCHEMA], + vol.Required("entity_id"): selector.EntitySelector(), + vol.Required("data"): vol.Schema( + { + vol.Required("options"): [EXPOSE_OPTION_SCHEMA], + vol.Optional("notes"): str, + } + ), }, extra=vol.REMOVE_EXTRA, ) @@ -135,13 +145,13 @@ def update_entity_expose( hass: HomeAssistant, xknx: XKNX, entity_id: str, - expose_config: list[KNXExposeStoreOptionModel], + expose_config: KNXExposeStoreConfigModel, ) -> None: """Update entity expose configuration for an entity.""" self.remove_entity_expose(entity_id) expose_options = [ - _store_to_expose_option(hass, config) for config in expose_config + _store_to_expose_option(hass, config) for config in expose_config["options"] ] expose = KnxExposeEntity(hass, xknx, entity_id, expose_options) self._entity_exposes[entity_id] = expose diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index f56dd3db254fac..3abb766c958e62 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -978,6 +978,10 @@ "ga": { "label": "[%key:component::knx::config_panel::common::group_address%]" }, + "notes": { + "label": "Notes", + "placeholder": "Add your notes here..." + }, "periodic_send": { "description": "Time interval to automatically resend the current value to the KNX bus, even if it hasn’t changed.", "label": "Periodic send interval" diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 9ad7e8023b4dab..c48aab486c07d0 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -644,7 +644,7 @@ def ws_get_expose_config( { vol.Required("type"): "knx/update_expose", vol.Required("entity_id"): str, - vol.Required("options"): list, # validation done in handler + vol.Required("data"): dict, # validation done in handler } ) @websocket_api.async_response @@ -663,7 +663,7 @@ async def ws_update_expose( return try: await knx.config_store.update_expose( - validated_data["entity_id"], validated_data["options"] + validated_data["entity_id"], validated_data["data"] ) except ConfigStoreException as err: connection.send_error( @@ -706,7 +706,7 @@ async def ws_delete_expose( { vol.Required("type"): "knx/validate_expose", vol.Required("entity_id"): str, - vol.Required("options"): list, # validation done in handler + vol.Required("data"): dict, # validation done in handler } ) @callback diff --git a/homeassistant/components/lg_netcast/__init__.py b/homeassistant/components/lg_netcast/__init__.py index c2509889760134..d97464d9a9c10c 100644 --- a/homeassistant/components/lg_netcast/__init__.py +++ b/homeassistant/components/lg_netcast/__init__.py @@ -11,7 +11,7 @@ from .const import DOMAIN -PLATFORMS: Final[list[Platform]] = [Platform.MEDIA_PLAYER] +PLATFORMS: Final[list[Platform]] = [Platform.MEDIA_PLAYER, Platform.REMOTE] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) diff --git a/homeassistant/components/lg_netcast/remote.py b/homeassistant/components/lg_netcast/remote.py new file mode 100644 index 00000000000000..db5562a598e82b --- /dev/null +++ b/homeassistant/components/lg_netcast/remote.py @@ -0,0 +1,83 @@ +"""Remote control support for LG Netcast TV.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any + +from pylgnetcast import LG_COMMAND, LgNetCastClient, LgNetCastError +from requests import RequestException + +from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import LgNetCastConfigEntry +from .const import ATTR_MANUFACTURER, DOMAIN + +VALID_COMMANDS: frozenset[str] = frozenset( + k + for k in vars(LG_COMMAND) + if not k.startswith("_") and isinstance(getattr(LG_COMMAND, k), int) +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LgNetCastConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up LG Netcast Remote from a config entry.""" + client = config_entry.runtime_data + unique_id = config_entry.unique_id + if TYPE_CHECKING: + assert unique_id is not None + + async_add_entities([LgNetCastRemote(client, unique_id)]) + + +class LgNetCastRemote(RemoteEntity): + """Device that sends commands to an LG Netcast TV.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, client: LgNetCastClient, unique_id: str) -> None: + """Initialize the LG Netcast remote.""" + self._client = client + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer=ATTR_MANUFACTURER, + ) + + def send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send commands to the TV.""" + num_repeats = kwargs[ATTR_NUM_REPEATS] + + commands: list[int] = [] + for cmd in command: + if cmd not in VALID_COMMANDS: + raise ServiceValidationError(f"Unknown command: {cmd!r}") + commands.append(getattr(LG_COMMAND, cmd)) + for _ in range(num_repeats): + try: + with self._client as client: + for lg_command in commands: + client.send_command(lg_command) + except LgNetCastError, RequestException: + self._attr_is_on = False + self.schedule_update_ha_state() + return + + def turn_on(self, **kwargs: Any) -> None: + """Turn on is handled via a separate turn_on trigger.""" + raise NotImplementedError( + "Turning on the TV is not supported by the LG Netcast remote entity" + ) + + def turn_off(self, **kwargs: Any) -> None: + """Turn off the TV.""" + self.send_command(["POWER"], **{ATTR_NUM_REPEATS: 1}) diff --git a/homeassistant/components/logbook/const.py b/homeassistant/components/logbook/const.py index 282580bdc95ce9..abbd5c8f050dc9 100644 --- a/homeassistant/components/logbook/const.py +++ b/homeassistant/components/logbook/const.py @@ -11,9 +11,9 @@ # Domains that are always continuous # # These are hard coded here to avoid importing -# the entire counter and proximity integrations +# the entire counter, image, and proximity integrations # to get the name of the domain. -ALWAYS_CONTINUOUS_DOMAINS = {"counter", "proximity"} +ALWAYS_CONTINUOUS_DOMAINS = {"counter", "image", "proximity"} # Domains that are continuous if there is a UOM set on the entity CONDITIONALLY_CONTINUOUS_DOMAINS = {SENSOR_DOMAIN} diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py index 8593b3c478e4e2..8f7575850f5d55 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -10,6 +10,7 @@ from homeassistant.const import EVENT_LOGGING_CHANGED # noqa: F401 from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from . import websocket_api @@ -86,14 +87,16 @@ def async_service_handler(service: ServiceCall) -> None: else: set_log_levels(hass, service.data) - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_SET_DEFAULT_LEVEL, async_service_handler, schema=SERVICE_SET_DEFAULT_LEVEL_SCHEMA, ) - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_SET_LEVEL, async_service_handler, diff --git a/homeassistant/components/logger/websocket_api.py b/homeassistant/components/logger/websocket_api.py index 660bdf4c599a5f..d20dc5cd680a16 100644 --- a/homeassistant/components/logger/websocket_api.py +++ b/homeassistant/components/logger/websocket_api.py @@ -67,6 +67,7 @@ def handle_integration_log_info( vol.Required("persistence"): vol.Coerce(LogPersistance), } ) +@websocket_api.require_admin @websocket_api.async_response async def handle_integration_log_level( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] @@ -99,6 +100,7 @@ async def handle_integration_log_level( vol.Required("persistence"): vol.Coerce(LogPersistance), } ) +@websocket_api.require_admin @websocket_api.async_response async def handle_module_log_level( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py index 5582356a244c76..3801feaf61a14f 100644 --- a/homeassistant/components/matter/button.py +++ b/homeassistant/components/matter/button.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any from chip.clusters import Objects as clusters +from matter_server.common.custom_clusters import HeimanCluster from homeassistant.components.button import ( ButtonDeviceClass, @@ -168,4 +169,15 @@ async def async_press(self) -> None: value_contains=clusters.WaterHeaterManagement.Commands.CancelBoost.command_id, allow_multi=True, # Also used in water_heater ), + MatterDiscoverySchema( + platform=Platform.BUTTON, + entity_description=MatterButtonEntityDescription( + key="HeimanSmokeCoAlarmTemporaryMuteRequest", + translation_key="temporary_mute_request", + command=HeimanCluster.Commands.MutingSensor, + ), + entity_class=MatterCommandButton, + required_attributes=(HeimanCluster.Attributes.AcceptedCommandList,), + value_contains=HeimanCluster.Commands.MutingSensor.command_id, + ), ] diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index bce75244d6d6db..375779bc99c383 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -440,6 +440,31 @@ def _update_from_device(self) -> None: featuremap_contains=clusters.OccupancySensing.Bitmaps.Feature.kPassiveInfrared, allow_multi=True, ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterRangeNumberEntityDescription( + key="BooleanStateConfigurationCurrentSensitivityLevel", + entity_category=EntityCategory.CONFIG, + translation_key="sensitivity_level", + native_min_value=1, + native_step=1, + device_to_ha=lambda x: x + 1, + ha_to_device=lambda x: int(x) - 1, + max_attribute=( + clusters.BooleanStateConfiguration.Attributes.SupportedSensitivityLevels + ), + mode=NumberMode.SLIDER, + ), + entity_class=MatterRangeNumber, + required_attributes=( + clusters.BooleanStateConfiguration.Attributes.CurrentSensitivityLevel, + clusters.BooleanStateConfiguration.Attributes.SupportedSensitivityLevels, + ), + featuremap_contains=( + clusters.BooleanStateConfiguration.Bitmaps.Feature.kSensitivityLevel + ), + allow_multi=True, + ), MatterDiscoverySchema( platform=Platform.NUMBER, entity_description=MatterNumberEntityDescription( diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index 21ee86302b82ec..481ebf6ade3b9f 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -559,11 +559,15 @@ def _update_from_device(self) -> None: clusters.PumpConfigurationAndControl.Attributes.OperationMode, ), ), + # Keep the legacy vendor-specific select entities until HA 2026.11.0, + # so existing users can migrate before we remove them in favor of the + # generic number slider. MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="AqaraBooleanStateConfigurationCurrentSensitivityLevel", entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, translation_key="sensitivity_level", options=["10 mm", "20 mm", "30 mm"], device_to_ha={ @@ -583,12 +587,14 @@ def _update_from_device(self) -> None: ), vendor_id=(4447,), product_id=(8194,), + allow_multi=True, ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="AqaraOccupancySensorBooleanStateConfigurationCurrentSensitivityLevel", entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, translation_key="sensitivity_level", options=["low", "standard", "high"], device_to_ha={ @@ -611,12 +617,14 @@ def _update_from_device(self) -> None: 8197, 8195, ), + allow_multi=True, ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="HeimanOccupancySensorBooleanStateConfigurationCurrentSensitivityLevel", entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, translation_key="sensitivity_level", options=["low", "standard", "high"], device_to_ha={ @@ -636,6 +644,7 @@ def _update_from_device(self) -> None: ), vendor_id=(4619,), product_id=(4097,), + allow_multi=True, ), MatterDiscoverySchema( platform=Platform.SELECT, diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 8708e76245617d..707da2a197a3e8 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -525,7 +525,6 @@ def _update_from_device(self) -> None: entity_description=MatterSensorEntityDescription( key="EveEnergySensorWatt", device_class=SensorDeviceClass.POWER, - entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, state_class=SensorStateClass.MEASUREMENT, @@ -553,7 +552,6 @@ def _update_from_device(self) -> None: entity_description=MatterSensorEntityDescription( key="EveEnergySensorWattAccumulated", device_class=SensorDeviceClass.ENERGY, - entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=3, state_class=SensorStateClass.TOTAL_INCREASING, @@ -788,7 +786,6 @@ def _update_from_device(self) -> None: entity_description=MatterSensorEntityDescription( key="ThirdRealityEnergySensorWatt", device_class=SensorDeviceClass.POWER, - entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, state_class=SensorStateClass.MEASUREMENT, @@ -805,7 +802,6 @@ def _update_from_device(self) -> None: entity_description=MatterSensorEntityDescription( key="ThirdRealityEnergySensorWattAccumulated", device_class=SensorDeviceClass.ENERGY, - entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_display_precision=3, state_class=SensorStateClass.TOTAL_INCREASING, @@ -822,7 +818,6 @@ def _update_from_device(self) -> None: entity_description=MatterSensorEntityDescription( key="NeoEnergySensorWatt", device_class=SensorDeviceClass.POWER, - entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, state_class=SensorStateClass.MEASUREMENT, @@ -837,7 +832,6 @@ def _update_from_device(self) -> None: entity_description=MatterSensorEntityDescription( key="NeoEnergySensorWattAccumulated", device_class=SensorDeviceClass.ENERGY, - entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_display_precision=1, state_class=SensorStateClass.TOTAL_INCREASING, @@ -895,7 +889,6 @@ def _update_from_device(self) -> None: entity_description=MatterSensorEntityDescription( key="ElectricalPowerMeasurementWatt", device_class=SensorDeviceClass.POWER, - entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfPower.MILLIWATT, suggested_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, @@ -1051,7 +1044,6 @@ def _update_from_device(self) -> None: entity_description=MatterSensorEntityDescription( key="ElectricalEnergyMeasurementCumulativeEnergyImported", device_class=SensorDeviceClass.ENERGY, - entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfEnergy.MILLIWATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=3, @@ -1071,7 +1063,6 @@ def _update_from_device(self) -> None: key="ElectricalEnergyMeasurementCumulativeEnergyExported", translation_key="energy_exported", device_class=SensorDeviceClass.ENERGY, - entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfEnergy.MILLIWATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=3, @@ -1090,7 +1081,6 @@ def _update_from_device(self) -> None: entity_description=MatterSensorEntityDescription( key="ElectricalMeasurementActivePower", device_class=SensorDeviceClass.POWER, - entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 1286b8bae94487..514ba606fa518e 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -141,6 +141,9 @@ }, "stop": { "name": "[%key:common::action::stop%]" + }, + "temporary_mute_request": { + "name": "Temporary mute" } }, "climate": { @@ -259,6 +262,9 @@ "pump_setpoint": { "name": "Setpoint" }, + "sensitivity_level": { + "name": "Sensitivity" + }, "speaker_setpoint": { "name": "Volume" }, diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index dc6a6bbcb5d8d6..9b757d8bbf3b70 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -316,4 +316,14 @@ def _update_from_device(self) -> None: value_contains=clusters.EnergyEvse.Commands.EnableCharging.command_id, allow_multi=True, ), + MatterDiscoverySchema( + platform=Platform.SWITCH, + entity_description=MatterNumericSwitchEntityDescription( + key="EveChildLock", + entity_category=EntityCategory.CONFIG, + translation_key="child_lock", + ), + entity_class=MatterNumericSwitch, + required_attributes=(clusters.EveCluster.Attributes.ChildLock,), + ), ] diff --git a/homeassistant/components/mcp_server/manifest.json b/homeassistant/components/mcp_server/manifest.json index 2e4c645441b721..ca07e22e6808f1 100644 --- a/homeassistant/components/mcp_server/manifest.json +++ b/homeassistant/components/mcp_server/manifest.json @@ -3,7 +3,7 @@ "name": "Model Context Protocol Server", "codeowners": ["@allenporter"], "config_flow": true, - "dependencies": ["homeassistant", "http", "conversation"], + "dependencies": ["http", "conversation"], "documentation": "https://www.home-assistant.io/integrations/mcp_server", "integration_type": "service", "iot_class": "local_push", diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index 01b3e2212680c0..b86669364f706c 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["aiomealie==1.2.3"] + "requirements": ["aiomealie==1.2.4"] } diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 32694f4727d525..232c4c50c6c336 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -414,7 +414,7 @@ async def webhook_render_template( { vol.Optional(ATTR_LOCATION_NAME): cv.string, vol.Optional(ATTR_GPS): cv.gps, - vol.Optional(ATTR_GPS_ACCURACY): cv.positive_int, + vol.Optional(ATTR_GPS_ACCURACY): cv.positive_float, vol.Optional(ATTR_BATTERY): cv.positive_int, vol.Optional(ATTR_SPEED): cv.positive_int, vol.Optional(ATTR_ALTITUDE): vol.Coerce(float), diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 6bd2bb4792311a..3ef84762be7540 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -257,6 +257,7 @@ "tit": "title", "t": "topic", "trns": "transition", + "tz": "timezone", "uniq_id": "unique_id", "unit_of_meas": "unit_of_measurement", "url_t": "url_topic", diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 8cbc9e1625a52e..4d013f0dc11508 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -477,7 +477,7 @@ "remote_code": REMOTE_CODE, "remote_code_text": REMOTE_CODE_TEXT, } -EXCLUDE_FROM_CONFIG_IF_NONE = {CONF_ENTITY_CATEGORY} +EXCLUDE_FROM_CONFIG_IF_NONE = {CONF_ENTITY_CATEGORY, CONF_UNIT_OF_MEASUREMENT} PWD_NOT_CHANGED = "__**password_not_changed**__" DEVELOPER_DOCUMENTATION_URL = "https://developers.home-assistant.io/" @@ -1133,11 +1133,13 @@ def validate_number_platform_config(config: dict[str, Any]) -> dict[str, str]: errors[CONF_MIN] = "max_below_min" errors[CONF_MAX] = "max_below_min" + if (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) == "None": + unit_of_measurement = None + if ( (device_class := config.get(CONF_DEVICE_CLASS)) is not None and device_class in NUMBER_DEVICE_CLASS_UNITS - and config.get(CONF_UNIT_OF_MEASUREMENT) - not in NUMBER_DEVICE_CLASS_UNITS[device_class] + and unit_of_measurement not in NUMBER_DEVICE_CLASS_UNITS[device_class] ): errors[CONF_UNIT_OF_MEASUREMENT] = "invalid_uom" @@ -1166,6 +1168,7 @@ def validate_sensor_platform_config( ): errors[CONF_OPTIONS] = "options_with_enum_device_class" + unit_of_measurement: str | None = None if ( device_class in DEVICE_CLASS_UNITS and (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) is None @@ -1175,6 +1178,10 @@ def validate_sensor_platform_config( errors[CONF_UNIT_OF_MEASUREMENT] = "uom_required_for_device_class" return errors + if unit_of_measurement == "None": + unit_of_measurement = None + config.pop(CONF_UNIT_OF_MEASUREMENT) + if ( device_class is not None and device_class in DEVICE_CLASS_UNITS @@ -4984,7 +4991,9 @@ async def async_step_export_yaml( self._subentry_data["device"].get("mqtt_settings", {}).copy() ) for field in EXCLUDE_FROM_CONFIG_IF_NONE: - if field in component_config and component_config[field] is None: + if field in component_config and ( + component_config[field] is None or component_config[field] == "None" + ): component_config.pop(field) mqtt_yaml_config.append({platform: component_config}) @@ -5033,7 +5042,9 @@ async def async_step_export_discovery( self._subentry_data["device"].get("mqtt_settings", {}).copy() ) for field in EXCLUDE_FROM_CONFIG_IF_NONE: - if field in component_config and component_config[field] is None: + if field in component_config and ( + component_config[field] is None or component_config[field] == "None" + ): component_config.pop(field) discovery_payload["cmps"][component_id] = component_config diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 7a445a815a4858..c342747deff65d 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -38,6 +38,7 @@ Platform.CLIMATE.value: vol.All(cv.ensure_list, [dict]), Platform.COVER.value: vol.All(cv.ensure_list, [dict]), Platform.DATE.value: vol.All(cv.ensure_list, [dict]), + Platform.DATETIME.value: vol.All(cv.ensure_list, [dict]), Platform.DEVICE_TRACKER.value: vol.All(cv.ensure_list, [dict]), Platform.EVENT.value: vol.All(cv.ensure_list, [dict]), Platform.FAN.value: vol.All(cv.ensure_list, [dict]), diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index be02529a87f6fe..1e163c6d41cc4d 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -402,6 +402,7 @@ Platform.CLIMATE, Platform.COVER, Platform.DATE, + Platform.DATETIME, Platform.DEVICE_TRACKER, Platform.EVENT, Platform.FAN, @@ -435,6 +436,7 @@ "climate", "cover", "date", + "datetime", "device_automation", "device_tracker", "event", diff --git a/homeassistant/components/mqtt/datetime.py b/homeassistant/components/mqtt/datetime.py new file mode 100644 index 00000000000000..2e6cb9f04b55cd --- /dev/null +++ b/homeassistant/components/mqtt/datetime.py @@ -0,0 +1,201 @@ +"""Support for MQTT datetime platform.""" + +from __future__ import annotations + +from collections.abc import Callable +import datetime as datetime_library +import logging +from typing import Any +from zoneinfo import ZoneInfo + +from dateutil.parser import ParserError, parse +from dateutil.tz import UTC +import voluptuous as vol + +from homeassistant.components import datetime +from homeassistant.components.datetime import DateTimeEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.service_info.mqtt import ReceivePayloadType +from homeassistant.helpers.typing import ConfigType, VolSchemaType +from homeassistant.util.dt import async_get_time_zone + +from . import subscription +from .config import MQTT_RW_SCHEMA +from .const import ( + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, + CONF_STATE_TOPIC, + PAYLOAD_NONE, +) +from .entity import MqttEntity, async_setup_entity_entry_helper +from .models import ( + MqttCommandTemplate, + MqttValueTemplate, + PublishPayloadType, + ReceiveMessage, +) +from .schemas import MQTT_ENTITY_COMMON_SCHEMA + +_LOGGER = logging.getLogger(__name__) + +CONF_TIMEZONE = "timezone" + +PARALLEL_UPDATES = 0 + +DEFAULT_NAME = "MQTT Date/Time" + +MQTT_DATETIME_ATTRIBUTES_BLOCKED: frozenset[str] = frozenset() + + +PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( + { + vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), + vol.Optional(CONF_TIMEZONE): str, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + }, +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + + +DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up MQTT datetime through YAML and through MQTT discovery.""" + async_setup_entity_entry_helper( + hass, + config_entry, + MqttDateTime, + datetime.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, + ) + + +class MqttDateTime(MqttEntity, DateTimeEntity): + """Representation of the MQTT datetime entity.""" + + _attr_native_value: datetime_library.datetime | None = None + _attributes_extra_blocked = MQTT_DATETIME_ATTRIBUTES_BLOCKED + _default_name = DEFAULT_NAME + _entity_id_format = datetime.ENTITY_ID_FORMAT + _zone_info: ZoneInfo | None = None + _time_zone_delta: datetime_library.timedelta | None + + _optimistic: bool + _command_template: Callable[ + [PublishPayloadType, dict[str, Any]], PublishPayloadType + ] + _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] + + @staticmethod + def config_schema() -> VolSchemaType: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """(Re)Setup the entity.""" + self._timezone_config = config.get(CONF_TIMEZONE) + + self._command_template = MqttCommandTemplate( + config.get(CONF_COMMAND_TEMPLATE), + entity=self, + ).async_render + self._value_template = MqttValueTemplate( + config.get(CONF_VALUE_TEMPLATE), + entity=self, + ).async_render_with_possible_json_value + optimistic: bool = config[CONF_OPTIMISTIC] + self._optimistic = optimistic or config.get(CONF_STATE_TOPIC) is None + self._attr_assumed_state = bool(self._optimistic) + + async def _async_finish_update_config(self) -> None: + """Called after added to hass and after discovery update.""" + self._zone_info = None + if timezone := self._config.get(CONF_TIMEZONE): + self._zone_info = await async_get_time_zone(timezone) + if not self._zone_info: + _LOGGER.warning( + "Ignoring invalid timezone identifier for entity %s, got '%s'", + self.entity_id, + timezone, + ) + + @callback + def _handle_state_message_received(self, msg: ReceiveMessage) -> None: + """Handle receiving state message via MQTT.""" + payload = str(self._value_template(msg.payload)) + if payload == PAYLOAD_NONE: + self._attr_native_value = None + return + if payload == "": + _LOGGER.debug( + "Ignoring empty state payload on topic %s for entity %s", + msg.topic, + self.entity_id, + ) + return + try: + value = parse(payload) + except ParserError: + _LOGGER.warning( + "Invalid received date/time expression on topic %s for entity %s, got %s", + msg.topic, + self.entity_id, + msg.payload, + ) + return + + if self._zone_info is not None: + if value.tzinfo is None: + # Convert to UTC + value = value.replace(tzinfo=self._zone_info).astimezone(UTC) + else: + _LOGGER.warning( + "Date/time expression on topic %s for entity %s was not expected " + "to have timezone info, as this is configured explicitly, got %s", + msg.topic, + self.entity_id, + msg.payload, + ) + return + elif value.tzinfo is None: + _LOGGER.warning( + "Date/time expression without required timezone info received " + "on topic %s for entity %s, got %s", + msg.topic, + self.entity_id, + msg.payload, + ) + return + self._attr_native_value = value + + @callback + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + self.add_subscription( + CONF_STATE_TOPIC, + self._handle_state_message_received, + {"_attr_native_value"}, + ) + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) + + async def async_set_value(self, value: datetime_library.datetime) -> None: + """Change the date and time.""" + payload = self._command_template(value.isoformat(), {"value": value}) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) + if self._optimistic: + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index c43f11d845ce16..8a26e454e9f836 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -29,6 +29,7 @@ CONF_MODEL_ID, CONF_NAME, CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, ) from homeassistant.core import Event, HassJobType, HomeAssistant, callback @@ -242,7 +243,7 @@ async def _async_setup_non_entity_entry_from_discovery( @callback -def async_setup_entity_entry_helper( +def async_setup_entity_entry_helper( # noqa: C901 hass: HomeAssistant, entry: ConfigEntry, entity_class: type[MqttEntity] | None, @@ -391,6 +392,8 @@ def _async_setup_entities() -> None: and component_config[CONF_ENTITY_CATEGORY] is None ): component_config.pop(CONF_ENTITY_CATEGORY) + if component_config.get(CONF_UNIT_OF_MEASUREMENT) == "None": + component_config.pop(CONF_UNIT_OF_MEASUREMENT) try: config = platform_schema_modern(component_config) @@ -1473,6 +1476,7 @@ async def async_added_to_hass(self) -> None: self._update_registry_entity_id = None await super().async_added_to_hass() + await self._async_finish_update_config() self._subscriptions = {} self._prepare_subscribe_topics() if self._subscriptions: @@ -1490,6 +1494,12 @@ async def mqtt_async_added_to_hass(self) -> None: To be extended by subclasses. """ + async def _async_finish_update_config(self) -> None: + """Called after added to hass and after discovery update. + + To be extended by subclasses. + """ + async def discovery_update(self, discovery_payload: MQTTDiscoveryPayload) -> None: """Handle updated discovery message.""" try: @@ -1500,6 +1510,7 @@ async def discovery_update(self, discovery_payload: MQTTDiscoveryPayload) -> Non self._config = config self._setup_from_config(self._config) self._setup_common_attributes_from_config(self._config) + await self._async_finish_update_config() # Prepare MQTT subscriptions self.attributes_prepare_discovery_update(config) diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index e8812407e4794f..b10d2309770d65 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -39,14 +39,15 @@ "write_mhs1", ] -NETATMO_CREATE_BATTERY = "netatmo_create_battery" NETATMO_CREATE_CAMERA = "netatmo_create_camera" NETATMO_CREATE_CAMERA_LIGHT = "netatmo_create_camera_light" NETATMO_CREATE_CLIMATE = "netatmo_create_climate" +NETATMO_CREATE_CLIMATE_BATTERY_SENSOR = "netatmo_create_climate_battery_sensor" NETATMO_CREATE_COVER = "netatmo_create_cover" NETATMO_CREATE_CONNECTIVITY_BINARY_SENSOR = "netatmo_create_connectivity_binary_sensor" NETATMO_CREATE_BUTTON = "netatmo_create_button" NETATMO_CREATE_FAN = "netatmo_create_fan" +NETATMO_CREATE_LEGACY_SENSOR = "netatmo_create_legacy_sensor" NETATMO_CREATE_LIGHT = "netatmo_create_light" NETATMO_CREATE_OPENING_BINARY_SENSOR = "netatmo_create_opening_binary_sensor" NETATMO_CREATE_ROOM_SENSOR = "netatmo_create_room_sensor" diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 887549eac5fbb9..4961ea3bf973d3 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -33,14 +33,15 @@ DATA_SCHEDULES, DOMAIN, MANUFACTURER, - NETATMO_CREATE_BATTERY, NETATMO_CREATE_BUTTON, NETATMO_CREATE_CAMERA, NETATMO_CREATE_CAMERA_LIGHT, NETATMO_CREATE_CLIMATE, + NETATMO_CREATE_CLIMATE_BATTERY_SENSOR, NETATMO_CREATE_CONNECTIVITY_BINARY_SENSOR, NETATMO_CREATE_COVER, NETATMO_CREATE_FAN, + NETATMO_CREATE_LEGACY_SENSOR, NETATMO_CREATE_LIGHT, NETATMO_CREATE_OPENING_BINARY_SENSOR, NETATMO_CREATE_ROOM_SENSOR, @@ -372,13 +373,14 @@ def setup_modules(self, home: pyatmo.Home, signal_home: str) -> None: NetatmoDeviceCategory.switch: [ NETATMO_CREATE_LIGHT, NETATMO_CREATE_SWITCH, - NETATMO_CREATE_SENSOR, + NETATMO_CREATE_LEGACY_SENSOR, ], - NetatmoDeviceCategory.meter: [NETATMO_CREATE_SENSOR], + NetatmoDeviceCategory.meter: [NETATMO_CREATE_LEGACY_SENSOR], NetatmoDeviceCategory.fan: [NETATMO_CREATE_FAN], NetatmoDeviceCategory.opening: [ NETATMO_CREATE_CONNECTIVITY_BINARY_SENSOR, NETATMO_CREATE_OPENING_BINARY_SENSOR, + NETATMO_CREATE_SENSOR, ], } for module in home.modules.values(): @@ -431,7 +433,7 @@ def setup_rooms(self, home: pyatmo.Home, signal_home: str) -> None: if module.device_category is NetatmoDeviceCategory.climate: async_dispatcher_send( self.hass, - NETATMO_CREATE_BATTERY, + NETATMO_CREATE_CLIMATE_BATTERY_SENSOR, NetatmoDevice( self, module, diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 058d948da6296b..a77765a5a08f81 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -4,11 +4,13 @@ from collections.abc import Callable from dataclasses import dataclass +from functools import partial import logging -from typing import Any, cast +from typing import Any, Final, cast import pyatmo from pyatmo.modules import PublicWeatherArea +from pyatmo.modules.device_types import DeviceCategory as NetatmoDeviceCategory from homeassistant.components.sensor import ( SensorDeviceClass, @@ -41,11 +43,14 @@ from homeassistant.helpers.typing import StateType from .const import ( + CONF_URL_CONTROL, CONF_URL_ENERGY, CONF_URL_PUBLIC_WEATHER, + CONF_URL_SECURITY, CONF_WEATHER_AREAS, DOMAIN, - NETATMO_CREATE_BATTERY, + NETATMO_CREATE_CLIMATE_BATTERY_SENSOR, + NETATMO_CREATE_LEGACY_SENSOR, NETATMO_CREATE_ROOM_SENSOR, NETATMO_CREATE_SENSOR, NETATMO_CREATE_WEATHER_SENSOR, @@ -123,11 +128,21 @@ def process_wifi(strength: StateType) -> str | None: class NetatmoSensorEntityDescription(SensorEntityDescription): """Describes Netatmo sensor entity.""" - netatmo_name: str + # For legacy sensors netatmo_name is set and is used as the translation_key! + # Legacy sensors are: weather, climate, switch and meter sensors, as they were the first ones implemented. + # For new sensors, translation_key should be set explicitly on key + # and netatmo_name should be used only to retrieve the value from the device. + # If the netatmo_name is not set, the key is used to retrieve the value from the device. + netatmo_name: str | None = None + # Mark sensors whose last known native_value may be retained when fresh data is unavailable. + # This is intended for sensors where the last reported value remains useful, such as battery + # level or a last known state. This flag does not by itself keep the entity available; the + # entity may still become unavailable when the device is unreachable. + is_sticky: bool | None = None value_fn: Callable[[StateType], StateType] = lambda x: x -SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( +NETATMO_WEATHER_SENSOR_DESCRIPTIONS: Final[list[NetatmoSensorEntityDescription]] = [ NetatmoSensorEntityDescription( key="temperature", netatmo_name="temperature", @@ -286,8 +301,7 @@ class NetatmoSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), -) -SENSOR_TYPES_KEYS = [desc.key for desc in SENSOR_TYPES] +] @dataclass(frozen=True, kw_only=True) @@ -383,14 +397,73 @@ class NetatmoPublicWeatherSensorEntityDescription(SensorEntityDescription): ), ) -BATTERY_SENSOR_DESCRIPTION = NetatmoSensorEntityDescription( - key="battery", - netatmo_name="battery", - entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=PERCENTAGE, - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.BATTERY, -) +NETATMO_CLIMATE_BATTERY_SENSOR_DESCRIPTIONS: Final[ + list[NetatmoSensorEntityDescription] +] = [ + NetatmoSensorEntityDescription( + key="battery", + netatmo_name="battery", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + ) +] + +NETATMO_OPENING_SENSOR_DESCRIPTIONS: Final[list[NetatmoSensorEntityDescription]] = [ + NetatmoSensorEntityDescription( + key="battery", + netatmo_name="battery", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + is_sticky=True, + ), + NetatmoSensorEntityDescription( + key="rf_status", + netatmo_name="rf_strength", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=process_rf, + ), +] + +DEVICE_CATEGORY_CLIMATE_BATTERY_SENSORS: Final[ + dict[NetatmoDeviceCategory, list[NetatmoSensorEntityDescription]] +] = { + NetatmoDeviceCategory.climate: NETATMO_CLIMATE_BATTERY_SENSOR_DESCRIPTIONS, +} + +DEVICE_CATEGORY_NEW_SENSORS: Final[ + dict[NetatmoDeviceCategory, list[NetatmoSensorEntityDescription]] +] = { + NetatmoDeviceCategory.opening: NETATMO_OPENING_SENSOR_DESCRIPTIONS, +} + +DEVICE_CATEGORY_WEATHER_SENSORS: Final[ + dict[NetatmoDeviceCategory, list[NetatmoSensorEntityDescription]] +] = { + NetatmoDeviceCategory.air_care: NETATMO_WEATHER_SENSOR_DESCRIPTIONS, + NetatmoDeviceCategory.weather: NETATMO_WEATHER_SENSOR_DESCRIPTIONS, +} + +# Duplicate for meter, climate, switch sensors for legacy reasons +# (as originally weather definitions reused - target for future simplification) +DEVICE_CATEGORY_LEGACY_SENSORS: Final[ + dict[NetatmoDeviceCategory, list[NetatmoSensorEntityDescription]] +] = { + NetatmoDeviceCategory.meter: NETATMO_WEATHER_SENSOR_DESCRIPTIONS, + NetatmoDeviceCategory.switch: NETATMO_WEATHER_SENSOR_DESCRIPTIONS, + NetatmoDeviceCategory.climate: NETATMO_WEATHER_SENSOR_DESCRIPTIONS, +} + +DEVICE_CATEGORY_SENSOR_URLS: Final[dict[NetatmoDeviceCategory, str]] = { + NetatmoDeviceCategory.climate: CONF_URL_ENERGY, + NetatmoDeviceCategory.meter: CONF_URL_ENERGY, + NetatmoDeviceCategory.opening: CONF_URL_SECURITY, + NetatmoDeviceCategory.switch: CONF_URL_CONTROL, +} async def async_setup_entry( @@ -401,56 +474,91 @@ async def async_setup_entry( """Set up the Netatmo sensor platform.""" @callback - def _create_battery_entity(netatmo_device: NetatmoDevice) -> None: - if not hasattr(netatmo_device.device, "battery"): - return - entity = NetatmoClimateBatterySensor(netatmo_device) - async_add_entities([entity]) + def _create_base_sensor_entity( + sensorClass: type[NetatmoBaseSensor], + descriptions: dict[NetatmoDeviceCategory, list[NetatmoSensorEntityDescription]], + netatmo_device: NetatmoDevice, + ) -> None: + """Create sensor entities for a Netatmo device.""" - entry.async_on_unload( - async_dispatcher_connect(hass, NETATMO_CREATE_BATTERY, _create_battery_entity) - ) + if netatmo_device.device.device_category is None: + return - @callback - def _create_weather_sensor_entity(netatmo_device: NetatmoDevice) -> None: - async_add_entities( - NetatmoWeatherSensor(netatmo_device, description) - for description in SENSOR_TYPES - if description.netatmo_name in netatmo_device.device.features + descriptions_to_add = descriptions.get( + netatmo_device.device.device_category, [] ) - entry.async_on_unload( - async_dispatcher_connect( - hass, NETATMO_CREATE_WEATHER_SENSOR, _create_weather_sensor_entity - ) - ) + entities: list[NetatmoBaseSensor] = [] + + # Create sensors for module + for description in descriptions_to_add: + if description.netatmo_name is None: + feature_check = description.key + else: + feature_check = description.netatmo_name + if feature_check in netatmo_device.device.features: + _LOGGER.debug( + 'Adding key = "%s" / netatmo_name = "%s" sensor for device %s', + description.key, + description.netatmo_name, + netatmo_device.device.name, + ) + entities.append( + sensorClass( + netatmo_device, + description, + ) + ) - @callback - def _create_sensor_entity(netatmo_device: NetatmoDevice) -> None: - _LOGGER.debug( - "Adding %s sensor %s", - netatmo_device.device.device_category, - netatmo_device.device.name, - ) - async_add_entities( - NetatmoSensor(netatmo_device, description) - for description in SENSOR_TYPES - if description.key in netatmo_device.device.features + if entities: + async_add_entities(entities) + + sensor_subscriptions = [ + ( + NETATMO_CREATE_CLIMATE_BATTERY_SENSOR, + NetatmoClimateBatterySensor, + DEVICE_CATEGORY_CLIMATE_BATTERY_SENSORS, + ), + ( + NETATMO_CREATE_SENSOR, + NetatmoSensor, + DEVICE_CATEGORY_NEW_SENSORS, + ), + ( + NETATMO_CREATE_WEATHER_SENSOR, + NetatmoWeatherSensor, + DEVICE_CATEGORY_WEATHER_SENSORS, + ), + ( + NETATMO_CREATE_LEGACY_SENSOR, + NetatmoLegacySensor, + DEVICE_CATEGORY_LEGACY_SENSORS, + ), + ] + + for signal, sensor_class, descriptions in sensor_subscriptions: + entry.async_on_unload( + async_dispatcher_connect( + hass, + signal, + partial(_create_base_sensor_entity, sensor_class, descriptions), + ) ) - entry.async_on_unload( - async_dispatcher_connect(hass, NETATMO_CREATE_SENSOR, _create_sensor_entity) - ) - @callback def _create_room_sensor_entity(netatmo_device: NetatmoRoom) -> None: if not netatmo_device.room.climate_type: msg = f"No climate type found for this room: {netatmo_device.room.name}" _LOGGER.debug(msg) return + + descriptions_to_add = DEVICE_CATEGORY_LEGACY_SENSORS.get( + NetatmoDeviceCategory.climate, [] + ) + async_add_entities( NetatmoRoomSensor(netatmo_device, description) - for description in SENSOR_TYPES + for description in descriptions_to_add if description.key in netatmo_device.room.features ) @@ -518,7 +626,54 @@ async def add_public_entities(update: bool = True) -> None: await add_public_entities(False) -class NetatmoWeatherSensor(NetatmoWeatherModuleEntity, SensorEntity): +class NetatmoBaseSensor(NetatmoModuleEntity, SensorEntity): + """Implementation of a Netatmo sensor.""" + + entity_description: NetatmoSensorEntityDescription + + def __init__( + self, + netatmo_device: NetatmoDevice, + description: NetatmoSensorEntityDescription, + **kwargs: Any, + ) -> None: + """Initialize the sensor.""" + + # To prevent exception about missing URL we need to set it explicitly + if netatmo_device.device.device_category is not None: + if ( + DEVICE_CATEGORY_SENSOR_URLS.get(netatmo_device.device.device_category) + is not None + ): + self._attr_configuration_url = DEVICE_CATEGORY_SENSOR_URLS[ + netatmo_device.device.device_category + ] + + super().__init__(netatmo_device, **kwargs) + self.entity_description = description + + # Legacy value retrieval for weather, climate, switch and meter sensors to prevent breaking changes, + # as they were the first ones implemented. + @callback + def async_update_callback(self) -> None: + """Update the entity's state (the legacy way).""" + # Keep the last known value for these legacy sensors when the device is + # unreachable to preserve the historical behavior expected by existing entities. + if not self.device.reachable: + if self.available: + self._attr_available = False + return + + if (state := getattr(self.device, self.entity_description.key)) is None: + return + + self._attr_available = True + self._attr_native_value = state + + self.async_write_ha_state() + + +class NetatmoWeatherSensor(NetatmoWeatherModuleEntity, NetatmoBaseSensor): """Implementation of a Netatmo weather/home coach sensor.""" entity_description: NetatmoSensorEntityDescription @@ -529,7 +684,7 @@ def __init__( description: NetatmoSensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(netatmo_device) + super().__init__(netatmo_device, description=description) self.entity_description = description self._attr_translation_key = description.netatmo_name self._attr_unique_id = f"{self.device.entity_id}-{description.key}" @@ -539,14 +694,22 @@ def available(self) -> bool: """Return True if entity is available.""" return ( self.device.reachable - or getattr(self.device, self.entity_description.netatmo_name) is not None + or getattr( + self.device, + self.entity_description.netatmo_name or self.entity_description.key, + ) + is not None ) @callback def async_update_callback(self) -> None: """Update the entity's state.""" value = cast( - StateType, getattr(self.device, self.entity_description.netatmo_name) + StateType, + getattr( + self.device, + self.entity_description.netatmo_name or self.entity_description.key, + ), ) if value is not None: value = self.entity_description.value_fn(value) @@ -554,28 +717,53 @@ def async_update_callback(self) -> None: self.async_write_ha_state() -class NetatmoClimateBatterySensor(NetatmoModuleEntity, SensorEntity): - """Implementation of a Netatmo sensor.""" +class NetatmoLegacySensor(NetatmoBaseSensor): + """Implementation of a Netatmo legacy sensor.""" + + # Legacy sensors are sensors that were implemented before the refactor (like climate, meter and switch) + # and that still use the old way (weather style) of retrieving values from the device, entity_description: NetatmoSensorEntityDescription - device: pyatmo.modules.NRV - _attr_configuration_url = CONF_URL_ENERGY - def __init__(self, netatmo_device: NetatmoDevice) -> None: + def __init__( + self, + netatmo_device: NetatmoDevice, + description: NetatmoSensorEntityDescription, + ) -> None: """Initialize the sensor.""" - super().__init__(netatmo_device) - self.entity_description = BATTERY_SENSOR_DESCRIPTION + super().__init__(netatmo_device, description=description) + + self.entity_description = description self._publishers.extend( [ { "name": HOME, - "home_id": netatmo_device.device.home.entity_id, + "home_id": self.home.entity_id, SIGNAL_NAME: netatmo_device.signal_name, }, ] ) + self._attr_unique_id = ( + f"{self.device.entity_id}-{self.device.entity_id}-{description.key}" + ) + + +class NetatmoClimateBatterySensor(NetatmoLegacySensor): + """Implementation of a Netatmo Climate Battery sensor.""" + + entity_description: NetatmoSensorEntityDescription + device: pyatmo.modules.NRV + + def __init__( + self, + netatmo_device: NetatmoDevice, + description: NetatmoSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(netatmo_device, description=description) + self._attr_unique_id = f"{netatmo_device.parent_id}-{self.device.entity_id}-{self.entity_description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, netatmo_device.parent_id)}, @@ -595,13 +783,13 @@ def async_update_callback(self) -> None: self._attr_available = True self._attr_native_value = self.device.battery + self.async_write_ha_state() -class NetatmoSensor(NetatmoModuleEntity, SensorEntity): - """Implementation of a Netatmo sensor.""" +class NetatmoSensor(NetatmoBaseSensor): + """Implementation of a Netatmo refactored sensor.""" entity_description: NetatmoSensorEntityDescription - _attr_configuration_url = CONF_URL_ENERGY def __init__( self, @@ -609,36 +797,47 @@ def __init__( description: NetatmoSensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(netatmo_device) + super().__init__(netatmo_device, description=description) self.entity_description = description + self._attr_translation_key = description.netatmo_name + self._attr_unique_id = f"{self.device.entity_id}-{description.key}" self._publishers.extend( [ { - "name": HOME, + "name": self.home.entity_id, "home_id": self.home.entity_id, SIGNAL_NAME: netatmo_device.signal_name, }, ] ) - self._attr_unique_id = ( - f"{self.device.entity_id}-{self.device.entity_id}-{description.key}" - ) - + # New sensor implementation optional netatmo_name to retrieve value from device, if not set key is used + # Value is set unavailable if device is not reachable except is_sticky, + # otherwise it is set to the processed value @callback def async_update_callback(self) -> None: """Update the entity's state.""" if not self.device.reachable: if self.available: self._attr_available = False - return + if not self.entity_description.is_sticky: + self._attr_native_value = None + else: + if self.entity_description.netatmo_name is None: + raw_value = getattr(self.device, self.entity_description.key, None) + else: + raw_value = getattr( + self.device, self.entity_description.netatmo_name, None + ) - if (state := getattr(self.device, self.entity_description.key)) is None: - return + if raw_value is not None: + value = self.entity_description.value_fn(raw_value) + else: + value = None - self._attr_available = True - self._attr_native_value = state + self._attr_available = True + self._attr_native_value = value self.async_write_ha_state() diff --git a/homeassistant/components/nfandroidtv/config_flow.py b/homeassistant/components/nfandroidtv/config_flow.py index 9a5e420bbdce23..34893702b5dfcb 100644 --- a/homeassistant/components/nfandroidtv/config_flow.py +++ b/homeassistant/components/nfandroidtv/config_flow.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_NAME from .const import DEFAULT_NAME, DOMAIN @@ -40,6 +40,31 @@ async def async_step_user( errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure flow for Notification for Android TV / Fire TV.""" + errors: dict[str, str] = {} + entry = self._get_reconfigure_entry() + + if user_input is not None: + self._async_abort_entries_match(user_input) + if not (error := await self._async_try_connect(user_input[CONF_HOST])): + return self.async_update_reload_and_abort( + entry, data_updates=user_input + ) + errors["base"] = error + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + suggested_values=user_input or entry.data, + ), + description_placeholders={CONF_NAME: entry.title}, + errors=errors, + ) + async def _async_try_connect(self, host: str) -> str | None: """Try connecting to Android TV / Fire TV.""" try: diff --git a/homeassistant/components/nfandroidtv/strings.json b/homeassistant/components/nfandroidtv/strings.json index 531a6af1617bba..79c9648942b7d8 100644 --- a/homeassistant/components/nfandroidtv/strings.json +++ b/homeassistant/components/nfandroidtv/strings.json @@ -1,13 +1,23 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::nfandroidtv::config::step::user::data_description::host%]" + }, + "description": "Reconfigure {name}" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index b418a1a6e15255..9415618de75133 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -134,8 +134,6 @@ async def async_set_temperature(self, **kwargs: Any) -> None: if ATTR_TARGET_TEMP_LOW in kwargs: low = round(kwargs[ATTR_TARGET_TEMP_LOW]) high = round(kwargs[ATTR_TARGET_TEMP_HIGH]) - low = min(low, high) - high = max(low, high) await self._nobo.async_update_zone( self._id, temp_comfort_c=high, temp_eco_c=low ) @@ -147,6 +145,11 @@ async def async_update(self) -> None: @callback def _read_state(self) -> None: """Read the current state from the hub. These are only local calls.""" + if self._id not in self._nobo.zones: + # Zone removed via the Nobø app; mark unavailable. + self._attr_available = False + return + self._attr_available = True state = self._nobo.get_current_zone_mode(self._id, dt_util.now()) self._attr_hvac_mode = HVACMode.AUTO self._attr_preset_mode = PRESET_NONE diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index 4512f95892b159..4e11f049b55c7b 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -94,6 +94,7 @@ async def async_update(self) -> None: @callback def _read_state(self) -> None: + """Read the current state from the hub. These are only local calls.""" for override in self._nobo.overrides.values(): if override["target_type"] == nobo.API.OVERRIDE_TARGET_GLOBAL: self._attr_current_option = self._modes[override["mode"]] @@ -136,6 +137,12 @@ async def async_update(self) -> None: @callback def _read_state(self) -> None: + """Read the current state from the hub. These are only local calls.""" + if self._id not in self._nobo.zones: + # Zone removed via the Nobø app; mark unavailable. + self._attr_available = False + return + self._attr_available = True self._profiles = { profile["week_profile_id"]: profile["name"].replace("\xa0", " ") for profile in self._nobo.week_profiles.values() diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index 642de720b8b674..0c8c9bc2b431fe 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -71,6 +71,11 @@ def __init__(self, serial: str, hub: nobo) -> None: @callback def _read_state(self) -> None: """Read the current state from the hub. This is a local call.""" + if self._id not in self._nobo.components: + # Component removed via the Nobø app; mark unavailable. + self._attr_available = False + return + self._attr_available = True value = self._nobo.get_current_component_temperature(self._id) if value is None: self._attr_native_value = None diff --git a/homeassistant/components/novy_cooker_hood/__init__.py b/homeassistant/components/novy_cooker_hood/__init__.py new file mode 100644 index 00000000000000..4e21a91fb91cf1 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/__init__.py @@ -0,0 +1,20 @@ +"""The Novy Cooker Hood integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.FAN, Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Novy Cooker Hood from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/novy_cooker_hood/commands.py b/homeassistant/components/novy_cooker_hood/commands.py new file mode 100644 index 00000000000000..976bb3ae0c9e43 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/commands.py @@ -0,0 +1,16 @@ +"""Helpers for loading Novy cooker-hood RF commands.""" + +from __future__ import annotations + +from typing import Final + +from rf_protocols import CodeCollection, get_codes + +COMMAND_LIGHT: Final = "light" +COMMAND_PLUS: Final = "plus" +COMMAND_MINUS: Final = "minus" + + +def get_codes_for_code(code: int) -> CodeCollection: + """Return the bundled `rf-protocols` collection for a Novy cooker-hood code.""" + return get_codes(f"novy/cooker_hood/code_{code}") diff --git a/homeassistant/components/novy_cooker_hood/config_flow.py b/homeassistant/components/novy_cooker_hood/config_flow.py new file mode 100644 index 00000000000000..d1dd5f0314823c --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/config_flow.py @@ -0,0 +1,136 @@ +"""Config flow for the Novy Cooker Hood integration.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import voluptuous as vol + +from homeassistant.components.radio_frequency import ( + async_get_transmitters, + async_send_command, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er, selector + +from .commands import COMMAND_LIGHT, get_codes_for_code +from .const import ( + CODE_MAX, + CODE_MIN, + CONF_CODE, + CONF_TRANSMITTER, + DEFAULT_CODE, + DOMAIN, + FREQUENCY, + MODULATION, +) + +_CODE_OPTIONS = [str(code) for code in range(CODE_MIN, CODE_MAX + 1)] +_TOGGLE_GAP = 1.5 + + +class NovyCookerHoodConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Novy Cooker Hood.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the flow.""" + self._transmitter_entity_id: str | None = None + self._transmitter_id: str | None = None + self._code: int = DEFAULT_CODE + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Pick a transmitter and code.""" + try: + transmitters = async_get_transmitters(self.hass, FREQUENCY, MODULATION) + except HomeAssistantError: + return self.async_abort(reason="no_transmitters") + + if not transmitters: + return self.async_abort(reason="no_compatible_transmitters") + + if user_input is not None: + registry = er.async_get(self.hass) + entity_entry = registry.async_get(user_input[CONF_TRANSMITTER]) + assert entity_entry is not None + code = int(user_input[CONF_CODE]) + await self.async_set_unique_id(f"{entity_entry.id}_{code}") + self._abort_if_unique_id_configured() + self._transmitter_entity_id = entity_entry.entity_id + self._transmitter_id = entity_entry.id + self._code = code + return await self.async_step_test_light() + + schema: dict[Any, Any] = { + vol.Required( + CONF_TRANSMITTER, + default=self._transmitter_entity_id or vol.UNDEFINED, + ): selector.EntitySelector( + selector.EntitySelectorConfig(include_entities=transmitters), + ), + vol.Required(CONF_CODE, default=str(self._code)): selector.SelectSelector( + selector.SelectSelectorConfig( + options=_CODE_OPTIONS, + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="code", + ) + ), + } + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(schema), + ) + + async def async_step_test_light( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Toggle the hood light on then off so it ends in its starting state.""" + assert self._transmitter_entity_id is not None + try: + command = await get_codes_for_code(self._code).async_load_command( + COMMAND_LIGHT + ) + await async_send_command(self.hass, self._transmitter_entity_id, command) + await asyncio.sleep(_TOGGLE_GAP) + await async_send_command(self.hass, self._transmitter_entity_id, command) + except HomeAssistantError: + return await self.async_step_test_failed() + return self.async_show_menu( + step_id="test_light", + menu_options=["finish", "retry"], + description_placeholders={"code": str(self._code)}, + ) + + async def async_step_test_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Re-show the failure menu (only Retry available).""" + return self.async_show_menu( + step_id="test_failed", + menu_options=["retry"], + description_placeholders={"code": str(self._code)}, + ) + + async def async_step_retry( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Return to the code selection step.""" + return await self.async_step_user() + + async def async_step_finish( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Create the config entry.""" + assert self._transmitter_id is not None + return self.async_create_entry( + title="Novy Cooker Hood", + data={ + CONF_TRANSMITTER: self._transmitter_id, + CONF_CODE: self._code, + }, + ) diff --git a/homeassistant/components/novy_cooker_hood/const.py b/homeassistant/components/novy_cooker_hood/const.py new file mode 100644 index 00000000000000..0d4c06154f2edd --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/const.py @@ -0,0 +1,21 @@ +"""Constants for the Novy Cooker Hood integration.""" + +from __future__ import annotations + +from typing import Final + +from rf_protocols import ModulationType + +DOMAIN: Final = "novy_cooker_hood" + +CONF_TRANSMITTER: Final = "transmitter" +CONF_CODE: Final = "code" + +CODE_MIN: Final = 1 +CODE_MAX: Final = 10 +DEFAULT_CODE: Final = 1 + +FREQUENCY: Final = 433_920_000 +MODULATION: Final = ModulationType.OOK + +SPEED_COUNT: Final = 4 diff --git a/homeassistant/components/novy_cooker_hood/entity.py b/homeassistant/components/novy_cooker_hood/entity.py new file mode 100644 index 00000000000000..8673eb4be074bd --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/entity.py @@ -0,0 +1,76 @@ +"""Common entity for the Novy Cooker Hood integration.""" + +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import Event, EventStateChangedData, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_state_change_event + +from .const import CONF_TRANSMITTER, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class NovyCookerHoodEntity(Entity): + """Novy Cooker Hood base entity.""" + + _attr_assumed_state = True + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the entity.""" + self._transmitter = entry.data[CONF_TRANSMITTER] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="Novy", + model="Cooker Hood", + ) + + async def async_added_to_hass(self) -> None: + """Subscribe to transmitter entity state changes.""" + await super().async_added_to_hass() + + transmitter_entity_id = er.async_validate_entity_id( + er.async_get(self.hass), self._transmitter + ) + + @callback + def _async_transmitter_state_changed( + event: Event[EventStateChangedData], + ) -> None: + """Handle transmitter entity state changes.""" + new_state = event.data["new_state"] + transmitter_available = ( + new_state is not None and new_state.state != STATE_UNAVAILABLE + ) + if transmitter_available != self.available: + _LOGGER.info( + "Transmitter %s used by %s is %s", + transmitter_entity_id, + self.entity_id, + "available" if transmitter_available else "unavailable", + ) + + self._attr_available = transmitter_available + self.async_write_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, + [transmitter_entity_id], + _async_transmitter_state_changed, + ) + ) + + transmitter_state = self.hass.states.get(transmitter_entity_id) + self._attr_available = ( + transmitter_state is not None + and transmitter_state.state != STATE_UNAVAILABLE + ) diff --git a/homeassistant/components/novy_cooker_hood/fan.py b/homeassistant/components/novy_cooker_hood/fan.py new file mode 100644 index 00000000000000..287ce19c88ddad --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/fan.py @@ -0,0 +1,143 @@ +"""Fan platform for the Novy Cooker Hood (calibrated speed control).""" + +from __future__ import annotations + +import math +from typing import Any + +from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature +from homeassistant.components.radio_frequency import async_send_command +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from .commands import COMMAND_MINUS, COMMAND_PLUS, get_codes_for_code +from .const import CONF_CODE, SPEED_COUNT +from .entity import NovyCookerHoodEntity + +PARALLEL_UPDATES = 1 + +_SPEED_RANGE = (1, SPEED_COUNT) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Novy Cooker Hood fan platform.""" + async_add_entities([NovyCookerHoodFan(config_entry)]) + + +class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): + """Calibration-based fan: each change resets to off then climbs to target.""" + + _attr_name = None + _attr_speed_count = SPEED_COUNT + _attr_supported_features = ( + FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + | FanEntityFeature.SET_SPEED + ) + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the fan.""" + super().__init__(entry) + self._codes = get_codes_for_code(entry.data[CONF_CODE]) + self._level = 0 + self._attr_unique_id = entry.entry_id + + @property + def is_on(self) -> bool: + """Return whether the fan is currently on.""" + return self._level > 0 + + @property + def percentage(self) -> int: + """Return the current speed as a percentage.""" + if self._level == 0: + return 0 + return ranged_value_to_percentage(_SPEED_RANGE, self._level) + + async def async_added_to_hass(self) -> None: + """Restore the last known speed level from the saved percentage.""" + await super().async_added_to_hass() + last = await self.async_get_last_state() + if last is None: + return + last_pct = last.attributes.get(ATTR_PERCENTAGE) + if isinstance(last_pct, (int, float)) and last_pct > 0: + self._level = math.ceil(percentage_to_ranged_value(_SPEED_RANGE, last_pct)) + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on at the requested level (default = 1).""" + if percentage is None or percentage <= 0: + level = 1 + else: + level = math.ceil(percentage_to_ranged_value(_SPEED_RANGE, percentage)) + await self._async_set_level(level) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off by sending the calibration sequence to level 0.""" + await self._async_set_level(0) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the fan speed via calibration.""" + if percentage <= 0: + await self._async_set_level(0) + return + level = math.ceil(percentage_to_ranged_value(_SPEED_RANGE, percentage)) + await self._async_set_level(level) + + async def async_increase_speed(self, percentage_step: int | None = None) -> None: + """Bump speed up by N hardware levels (no recalibration).""" + steps = self._steps_from_percentage(percentage_step) + plus = await self._codes.async_load_command(COMMAND_PLUS) + for _ in range(steps): + await self._async_send(plus) + self._level = min(SPEED_COUNT, self._level + steps) + self.async_write_ha_state() + + async def async_decrease_speed(self, percentage_step: int | None = None) -> None: + """Bump speed down by N hardware levels (no recalibration).""" + steps = self._steps_from_percentage(percentage_step) + minus = await self._codes.async_load_command(COMMAND_MINUS) + for _ in range(steps): + await self._async_send(minus) + self._level = max(0, self._level - steps) + self.async_write_ha_state() + + @staticmethod + def _steps_from_percentage(percentage_step: int | None) -> int: + """Convert a percentage step into a number of hardware level presses.""" + if percentage_step is None: + return 1 + return math.ceil(percentage_step * SPEED_COUNT / 100) + + async def _async_set_level(self, level: int) -> None: + """Reset to off with `SPEED_COUNT` minus presses, then climb to level.""" + minus = await self._codes.async_load_command(COMMAND_MINUS) + for _ in range(SPEED_COUNT): + await self._async_send(minus) + if level > 0: + plus = await self._codes.async_load_command(COMMAND_PLUS) + for _ in range(level): + await self._async_send(plus) + self._level = level + self.async_write_ha_state() + + async def _async_send(self, command: Any) -> None: + """Send a single RF command via the configured transmitter.""" + await async_send_command( + self.hass, self._transmitter, command, context=self._context + ) diff --git a/homeassistant/components/novy_cooker_hood/light.py b/homeassistant/components/novy_cooker_hood/light.py new file mode 100644 index 00000000000000..9061a275458066 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/light.py @@ -0,0 +1,67 @@ +"""Light platform for the Novy Cooker Hood.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.components.radio_frequency import async_send_command +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .commands import COMMAND_LIGHT, get_codes_for_code +from .const import CONF_CODE +from .entity import NovyCookerHoodEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Novy Cooker Hood light platform.""" + async_add_entities([NovyCookerHoodLight(config_entry)]) + + +class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity): + """Novy cooker hood light toggled via a single RF press.""" + + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_translation_key = "light" + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the light.""" + super().__init__(entry) + self._codes = get_codes_for_code(entry.data[CONF_CODE]) + self._attr_unique_id = entry.entry_id + + async def async_added_to_hass(self) -> None: + """Restore the last known on/off state.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) is not None: + self._attr_is_on = last_state.state == STATE_ON + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on by sending the toggle command.""" + await self._async_send_command(COMMAND_LIGHT) + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off by sending the toggle command.""" + await self._async_send_command(COMMAND_LIGHT) + self._attr_is_on = False + self.async_write_ha_state() + + async def _async_send_command(self, name: str) -> None: + """Load the named command and send it via the configured transmitter.""" + command = await self._codes.async_load_command(name) + await async_send_command( + self.hass, self._transmitter, command, context=self._context + ) diff --git a/homeassistant/components/novy_cooker_hood/manifest.json b/homeassistant/components/novy_cooker_hood/manifest.json new file mode 100644 index 00000000000000..92a53f4c2624af --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "novy_cooker_hood", + "name": "Novy Cooker Hood", + "codeowners": ["@piitaya"], + "config_flow": true, + "dependencies": ["radio_frequency"], + "documentation": "https://www.home-assistant.io/integrations/novy_cooker_hood", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "bronze", + "requirements": ["rf-protocols==2.2.0"] +} diff --git a/homeassistant/components/novy_cooker_hood/quality_scale.yaml b/homeassistant/components/novy_cooker_hood/quality_scale.yaml new file mode 100644 index 00000000000000..93a6fc2a244f29 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/quality_scale.yaml @@ -0,0 +1,109 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not register custom service actions. + appropriate-polling: + status: exempt + comment: | + This integration transmits RF commands and does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not register custom service actions. + 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: + status: exempt + comment: | + This integration does not use runtime data. + test-before-configure: done + test-before-setup: + status: exempt + comment: | + RF transmission is a one-way broadcast with no device to contact at setup. + unique-config-entry: done + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not authenticate. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration does not support discovery. + discovery: + status: exempt + comment: | + RF devices cannot be discovered. + docs-data-update: + status: exempt + comment: | + RF transmission is one-way; there is no data update. + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Each config entry represents a single static device. + entity-category: + status: exempt + comment: | + The light entity represents the primary device function. + entity-device-class: + status: exempt + comment: | + Light entities do not have device classes. + entity-disabled-by-default: + status: exempt + comment: | + The light entity represents the primary device function. + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: | + The light entity uses the default icon for its state. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + No known repairable issues. + stale-devices: + status: exempt + comment: | + Each config entry represents a single static device. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration does not use a web session. + strict-typing: todo diff --git a/homeassistant/components/novy_cooker_hood/strings.json b/homeassistant/components/novy_cooker_hood/strings.json new file mode 100644 index 00000000000000..1a546af6fb9fa0 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/strings.json @@ -0,0 +1,60 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_compatible_transmitters": "No radio frequency transmitter supports 433.92 MHz OOK transmissions. Please add a compatible transmitter first.", + "no_transmitters": "No radio frequency transmitters are available. Please set up a transmitter first." + }, + "step": { + "test_failed": { + "description": "Could not send the test command for code {code}. Check that your radio frequency transmitter is online, then press Retry.", + "menu_options": { + "retry": "Retry" + }, + "title": "Test failed" + }, + "test_light": { + "description": "Toggled the hood light on and off using code {code}. Did you see it react? Press Finish to save, or Retry to pick a different code.", + "menu_options": { + "finish": "Finish", + "retry": "Retry" + }, + "title": "Verify the code" + }, + "user": { + "data": { + "code": "Code", + "transmitter": "Radio frequency transmitter" + }, + "data_description": { + "code": "The code your hood is paired with (1-10). Code 1 is the factory default.", + "transmitter": "The radio frequency transmitter used to control the Novy cooker hood." + }, + "description": "After you submit, Home Assistant will toggle the hood light on and off to verify the code works." + } + } + }, + "entity": { + "light": { + "light": { + "name": "[%key:component::light::title%]" + } + } + }, + "selector": { + "code": { + "options": { + "1": "Code 1", + "2": "Code 2", + "3": "Code 3", + "4": "Code 4", + "5": "Code 5", + "6": "Code 6", + "7": "Code 7", + "8": "Code 8", + "9": "Code 9", + "10": "Code 10" + } + } + } +} diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json index f033f1e836961c..c59fac55a88118 100644 --- a/homeassistant/components/ntfy/manifest.json +++ b/homeassistant/components/ntfy/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aiontfy"], "quality_scale": "platinum", - "requirements": ["aiontfy==0.8.4"] + "requirements": ["aiontfy==0.8.5"] } diff --git a/homeassistant/components/onedrive/services.py b/homeassistant/components/onedrive/services.py index 1e579b82a0fc26..87539a032822fb 100644 --- a/homeassistant/components/onedrive/services.py +++ b/homeassistant/components/onedrive/services.py @@ -42,7 +42,7 @@ def _read_file_contents( hass: HomeAssistant, filenames: list[str] ) -> list[tuple[str, bytes]]: """Return the mime types and file contents for each file.""" - results = [] + missing: list[str] = [] for filename in filenames: if not hass.config.is_allowed_path(filename): raise HomeAssistantError( @@ -50,20 +50,27 @@ def _read_file_contents( translation_key="no_access_to_path", translation_placeholders={"filename": filename}, ) + if not Path(filename).exists(): + missing.append(filename) + if missing: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="filenames_do_not_exist", + translation_placeholders={ + "filenames": ", ".join(f"`{f}`" for f in missing) + }, + ) + results = [] + for filename in filenames: filename_path = Path(filename) - if not filename_path.exists(): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="filename_does_not_exist", - translation_placeholders={"filename": filename}, - ) - if filename_path.stat().st_size > CONTENT_SIZE_LIMIT: + file_size = filename_path.stat().st_size + if file_size > CONTENT_SIZE_LIMIT: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="file_too_large", translation_placeholders={ "filename": filename, - "size": str(filename_path.stat().st_size), + "size": str(file_size), "limit": str(CONTENT_SIZE_LIMIT), }, ) diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 028d41d8bcc6ef..1f1aa5ec38924f 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -102,8 +102,8 @@ "file_too_large": { "message": "`{filename}` is too large ({size} > {limit})" }, - "filename_does_not_exist": { - "message": "`{filename}` does not exist" + "filenames_do_not_exist": { + "message": "The following files do not exist: {filenames}" }, "no_access_to_path": { "message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 7c773ad1e2ce1f..1749a0abe4f3ef 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -1,5 +1,4 @@ """The ONVIF integration.""" -# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio from contextlib import AsyncExitStack, suppress @@ -13,7 +12,6 @@ from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS from homeassistant.components.stream import CONF_RTSP_TRANSPORT, RTSP_TRANSPORTS -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, HTTP_BASIC_AUTHENTICATION, @@ -29,18 +27,14 @@ CONF_SNAPSHOT_AUTH, DEFAULT_ARGUMENTS, DEFAULT_ENABLE_WEBHOOKS, - DOMAIN, ) -from .device import ONVIFDevice +from .device import ONVIFConfigEntry, ONVIFDevice LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ONVIFConfigEntry) -> bool: """Set up ONVIF from a config entry.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - if not entry.options: await async_populate_options(hass, entry) @@ -97,7 +91,7 @@ async def _cleanup(): # If we get here, setup was successful - prevent cleanup stack.pop_all() - hass.data[DOMAIN][entry.unique_id] = device + entry.runtime_data = device device.platforms = [Platform.BUTTON, Platform.CAMERA] @@ -128,9 +122,9 @@ async def _async_stop_device(hass: HomeAssistant, device: ONVIFDevice) -> None: await device.device.close() -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ONVIFConfigEntry) -> bool: """Unload a config entry.""" - device: ONVIFDevice = hass.data[DOMAIN][entry.unique_id] + device = entry.runtime_data await _async_stop_device(hass, device) return await hass.config_entries.async_unload_platforms(entry, device.platforms) @@ -150,7 +144,7 @@ async def _get_snapshot_auth(device: ONVIFDevice) -> str | None: async def async_populate_snapshot_auth( - hass: HomeAssistant, device: ONVIFDevice, entry: ConfigEntry + hass: HomeAssistant, device: ONVIFDevice, entry: ONVIFConfigEntry ) -> None: """Check if digest auth for snapshots is possible.""" if auth := await _get_snapshot_auth(device): @@ -159,7 +153,7 @@ async def async_populate_snapshot_auth( ) -async def async_populate_options(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_populate_options(hass: HomeAssistant, entry: ONVIFConfigEntry) -> None: """Populate default options for device.""" options = { CONF_EXTRA_ARGUMENTS: DEFAULT_ARGUMENTS, @@ -172,7 +166,7 @@ async def async_populate_options(hass: HomeAssistant, entry: ConfigEntry) -> Non @callback def _async_migrate_camera_entities_unique_ids( - hass: HomeAssistant, config_entry: ConfigEntry, device: ONVIFDevice + hass: HomeAssistant, config_entry: ONVIFConfigEntry, device: ONVIFDevice ) -> None: """Migrate unique ids of camera entities from profile index to profile token.""" entity_reg = er.async_get(hass) diff --git a/homeassistant/components/onvif/binary_sensor.py b/homeassistant/components/onvif/binary_sensor.py index 0a9ac13ef7fa5f..d4caa6683fb614 100644 --- a/homeassistant/components/onvif/binary_sensor.py +++ b/homeassistant/components/onvif/binary_sensor.py @@ -6,7 +6,6 @@ BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er @@ -14,21 +13,18 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.enum import try_parse_enum -from .const import DOMAIN -from .device import ONVIFDevice +from .device import ONVIFConfigEntry, ONVIFDevice from .entity import ONVIFBaseEntity from .util import build_event_entity_names async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ONVIFConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ONVIF binary sensor platform.""" - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - device: ONVIFDevice = hass.data[DOMAIN][config_entry.unique_id] + device = config_entry.runtime_data events = device.events.get_platform("binary_sensor") entity_names = build_event_entity_names(events) diff --git a/homeassistant/components/onvif/button.py b/homeassistant/components/onvif/button.py index d654b96c727e91..1551e089dfbda3 100644 --- a/homeassistant/components/onvif/button.py +++ b/homeassistant/components/onvif/button.py @@ -1,25 +1,21 @@ """ONVIF Buttons.""" from homeassistant.components.button import ButtonDeviceClass, ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .device import ONVIFDevice +from .device import ONVIFConfigEntry, ONVIFDevice from .entity import ONVIFBaseEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ONVIFConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ONVIF button based on a config entry.""" - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - device = hass.data[DOMAIN][config_entry.unique_id] + device = config_entry.runtime_data async_add_entities([RebootButton(device), SetSystemDateAndTimeButton(device)]) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 2e72868bd93dd0..e2335b3f2dcfbb 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -17,7 +17,6 @@ CONF_USE_WALLCLOCK_AS_TIMESTAMPS, RTSP_TRANSPORTS, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import HTTP_BASIC_AUTHENTICATION from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform @@ -40,7 +39,6 @@ DIR_LEFT, DIR_RIGHT, DIR_UP, - DOMAIN, GOTOPRESET_MOVE, LOGGER, RELATIVE_MOVE, @@ -49,14 +47,14 @@ ZOOM_IN, ZOOM_OUT, ) -from .device import ONVIFDevice +from .device import ONVIFConfigEntry, ONVIFDevice from .entity import ONVIFBaseEntity from .models import Profile async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ONVIFConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ONVIF camera video stream.""" @@ -86,9 +84,7 @@ async def async_setup_entry( "async_perform_ptz", ) - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - device = hass.data[DOMAIN][config_entry.unique_id] + device = config_entry.runtime_data async_add_entities( [ONVIFCameraEntity(device, profile) for profile in device.profiles] ) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 7bcdd33809b057..61c2dafdf336f2 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -44,6 +44,8 @@ from .event_manager import EventManager from .models import PTZ, Capabilities, DeviceInfo, Profile, Resolution, Video +type ONVIFConfigEntry = ConfigEntry[ONVIFDevice] + class ONVIFDevice: """Manages an ONVIF device.""" @@ -165,7 +167,7 @@ async def async_setup(self) -> None: # Bind the listener to the ONVIFDevice instance since # async_update_listener only creates a weak reference to the listener # and we need to make sure it doesn't get garbage collected since only - # the ONVIFDevice instance is stored in hass.data + # the ONVIFDevice instance is stored in config_entry.runtime_data self.config_entry.async_on_unload( self.config_entry.add_update_listener(self._async_update_listener) ) diff --git a/homeassistant/components/onvif/diagnostics.py b/homeassistant/components/onvif/diagnostics.py index aa2042f3321508..e7e49e8a3bf671 100644 --- a/homeassistant/components/onvif/diagnostics.py +++ b/homeassistant/components/onvif/diagnostics.py @@ -6,21 +6,19 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .device import ONVIFDevice +from .device import ONVIFConfigEntry REDACT_CONFIG = {CONF_HOST, CONF_PASSWORD, CONF_USERNAME} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: ONVIFConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - device: ONVIFDevice = hass.data[DOMAIN][entry.unique_id] + device = entry.runtime_data data: dict[str, Any] = {} data["config"] = async_redact_data(entry.as_dict(), REDACT_CONFIG) diff --git a/homeassistant/components/onvif/sensor.py b/homeassistant/components/onvif/sensor.py index b3b60ad02f3475..29e323b649ca79 100644 --- a/homeassistant/components/onvif/sensor.py +++ b/homeassistant/components/onvif/sensor.py @@ -6,28 +6,24 @@ from decimal import Decimal from homeassistant.components.sensor import RestoreSensor, SensorDeviceClass -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.enum import try_parse_enum -from .const import DOMAIN -from .device import ONVIFDevice +from .device import ONVIFConfigEntry, ONVIFDevice from .entity import ONVIFBaseEntity from .util import build_event_entity_names async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ONVIFConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ONVIF sensor platform.""" - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - device: ONVIFDevice = hass.data[DOMAIN][config_entry.unique_id] + device: ONVIFDevice = config_entry.runtime_data events = device.events.get_platform("sensor") entity_names = build_event_entity_names(events) diff --git a/homeassistant/components/onvif/switch.py b/homeassistant/components/onvif/switch.py index 1ae491761c2b60..51442cd2acd294 100644 --- a/homeassistant/components/onvif/switch.py +++ b/homeassistant/components/onvif/switch.py @@ -7,12 +7,10 @@ from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .device import ONVIFDevice +from .device import ONVIFConfigEntry, ONVIFDevice from .entity import ONVIFBaseEntity from .models import Profile @@ -65,13 +63,11 @@ class ONVIFSwitchEntityDescription(SwitchEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ONVIFConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a ONVIF switch platform.""" - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - device = hass.data[DOMAIN][config_entry.unique_id] + device = config_entry.runtime_data async_add_entities( ONVIFSwitch(device, description) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index ee64975f88eeef..8294cd5b51fcfb 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -579,8 +579,8 @@ def _get_reasoning_options(self, model: str) -> list[str]: return [] models_reasoning_map: dict[str | tuple[str, ...], list[str]] = { - ("gpt-5.2-pro", "gpt-5.4-pro"): ["medium", "high", "xhigh"], - ("gpt-5.2", "gpt-5.3", "gpt-5.4"): [ + ("gpt-5.2-pro", "gpt-5.4-pro", "gpt-5.5-pro"): ["medium", "high", "xhigh"], + ("gpt-5.2", "gpt-5.3", "gpt-5.4", "gpt-5.5"): [ "none", "low", "medium", diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index 0a33ca835e4e7e..1f10ce2456d2b8 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.9.0"] + "requirements": ["python-otbr-api==2.10.0"] } diff --git a/homeassistant/components/otp/manifest.json b/homeassistant/components/otp/manifest.json index f6adbb20427efa..74c1c9eb26495c 100644 --- a/homeassistant/components/otp/manifest.json +++ b/homeassistant/components/otp/manifest.json @@ -4,6 +4,7 @@ "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/otp", + "integration_type": "helper", "iot_class": "local_polling", "loggers": ["pyotp"], "quality_scale": "internal", diff --git a/homeassistant/components/pvoutput/config_flow.py b/homeassistant/components/pvoutput/config_flow.py index e1e544ef9ff082..2d9860c63e2a13 100644 --- a/homeassistant/components/pvoutput/config_flow.py +++ b/homeassistant/components/pvoutput/config_flow.py @@ -81,6 +81,45 @@ async def async_step_user( errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of a PVOutput entry.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + try: + await validate_input( + self.hass, + api_key=user_input[CONF_API_KEY], + system_id=reconfigure_entry.data[CONF_SYSTEM_ID], + ) + except PVOutputAuthenticationError: + errors["base"] = "invalid_auth" + except PVOutputError: + errors["base"] = "cannot_connect" + else: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={ + CONF_API_KEY: user_input[CONF_API_KEY], + }, + ) + + return self.async_show_form( + step_id="reconfigure", + description_placeholders={ + "account_url": "https://pvoutput.org/account.jsp" + }, + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + ) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/pvoutput/strings.json b/homeassistant/components/pvoutput/strings.json index 269f7c384edcb7..342ed952eb963e 100644 --- a/homeassistant/components/pvoutput/strings.json +++ b/homeassistant/components/pvoutput/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -15,6 +16,12 @@ }, "description": "To re-authenticate with PVOutput you'll need to get the API key at {account_url}." }, + "reconfigure": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "description": "Reconfigure your PVOutput integration. You can update your API key at {account_url}." + }, "user": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]", diff --git a/homeassistant/components/pyload/coordinator.py b/homeassistant/components/pyload/coordinator.py index a69ba0c67dd88d..a9d1ed2a3281c0 100644 --- a/homeassistant/components/pyload/coordinator.py +++ b/homeassistant/components/pyload/coordinator.py @@ -68,7 +68,9 @@ async def _async_update_data(self) -> PyLoadData: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="setup_authentication_exception", - translation_placeholders={CONF_USERNAME: self.pyload.username}, + translation_placeholders={ + CONF_USERNAME: self.config_entry.data[CONF_USERNAME] + }, ) from e except CannotConnect as e: raise UpdateFailed( diff --git a/homeassistant/components/pyload/manifest.json b/homeassistant/components/pyload/manifest.json index fe36327cc75487..2a008128f86e02 100644 --- a/homeassistant/components/pyload/manifest.json +++ b/homeassistant/components/pyload/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["pyloadapi"], "quality_scale": "platinum", - "requirements": ["PyLoadAPI==2.0.0"] + "requirements": ["PyLoadAPI==2.1.0"] } diff --git a/homeassistant/components/radio_frequency/manifest.json b/homeassistant/components/radio_frequency/manifest.json index 0c346c011b67e8..70797a9cb87641 100644 --- a/homeassistant/components/radio_frequency/manifest.json +++ b/homeassistant/components/radio_frequency/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/radio_frequency", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["rf-protocols==2.1.0"] + "requirements": ["rf-protocols==2.2.0"] } diff --git a/homeassistant/components/rdw/binary_sensor.py b/homeassistant/components/rdw/binary_sensor.py index c62f3122efc6c6..5db3c446d63da2 100644 --- a/homeassistant/components/rdw/binary_sensor.py +++ b/homeassistant/components/rdw/binary_sensor.py @@ -13,12 +13,10 @@ BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN from .coordinator import RDWConfigEntry, RDWDataUpdateCoordinator +from .entity import RDWEntity PARALLEL_UPDATES = 0 @@ -51,45 +49,28 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RDW binary sensors based on a config entry.""" - coordinator = entry.runtime_data async_add_entities( - RDWBinarySensorEntity( - coordinator=coordinator, - description=description, - ) + RDWBinarySensorEntity(entry.runtime_data, description) for description in BINARY_SENSORS - if description.is_on_fn(coordinator.data) is not None + if description.is_on_fn(entry.runtime_data.data) is not None ) -class RDWBinarySensorEntity( - CoordinatorEntity[RDWDataUpdateCoordinator], BinarySensorEntity -): +class RDWBinarySensorEntity(RDWEntity, BinarySensorEntity): """Defines an RDW binary sensor.""" entity_description: RDWBinarySensorEntityDescription - _attr_has_entity_name = True def __init__( self, - *, coordinator: RDWDataUpdateCoordinator, description: RDWBinarySensorEntityDescription, ) -> None: """Initialize RDW binary sensor.""" - super().__init__(coordinator=coordinator) + super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{coordinator.data.license_plate}_{description.key}" - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, coordinator.data.license_plate)}, - manufacturer=coordinator.data.brand, - name=f"{coordinator.data.brand} {coordinator.data.license_plate}", - model=coordinator.data.model, - configuration_url=f"https://ovi.rdw.nl/default.aspx?kenteken={coordinator.data.license_plate}", - ) - @property def is_on(self) -> bool: """Return the state of the sensor.""" diff --git a/homeassistant/components/rdw/entity.py b/homeassistant/components/rdw/entity.py new file mode 100644 index 00000000000000..df94f77b738550 --- /dev/null +++ b/homeassistant/components/rdw/entity.py @@ -0,0 +1,27 @@ +"""Base entity for the RDW integration.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import RDWDataUpdateCoordinator + + +class RDWEntity(CoordinatorEntity[RDWDataUpdateCoordinator]): + """Defines an RDW entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: RDWDataUpdateCoordinator) -> None: + """Initialize an RDW entity.""" + super().__init__(coordinator=coordinator) + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, coordinator.data.license_plate)}, + manufacturer=coordinator.data.brand, + name=f"{coordinator.data.brand} {coordinator.data.license_plate}", + model=coordinator.data.model, + configuration_url=f"https://ovi.rdw.nl/default.aspx?kenteken={coordinator.data.license_plate}", + ) diff --git a/homeassistant/components/rdw/manifest.json b/homeassistant/components/rdw/manifest.json index 2ab90e55ef0828..647b25ada6a5ff 100644 --- a/homeassistant/components/rdw/manifest.json +++ b/homeassistant/components/rdw/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/rdw", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["vehicle==2.2.2"] + "requirements": ["vehicle==3.0.0"] } diff --git a/homeassistant/components/rdw/sensor.py b/homeassistant/components/rdw/sensor.py index a4b8bf98659b8d..ad88d2eaabdf9b 100644 --- a/homeassistant/components/rdw/sensor.py +++ b/homeassistant/components/rdw/sensor.py @@ -14,12 +14,10 @@ SensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_LICENSE_PLATE, DOMAIN from .coordinator import RDWConfigEntry, RDWDataUpdateCoordinator +from .entity import RDWEntity PARALLEL_UPDATES = 0 @@ -53,43 +51,25 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RDW sensors based on a config entry.""" - coordinator = entry.runtime_data async_add_entities( - RDWSensorEntity( - coordinator=coordinator, - license_plate=entry.data[CONF_LICENSE_PLATE], - description=description, - ) - for description in SENSORS + RDWSensorEntity(entry.runtime_data, description) for description in SENSORS ) -class RDWSensorEntity(CoordinatorEntity[RDWDataUpdateCoordinator], SensorEntity): +class RDWSensorEntity(RDWEntity, SensorEntity): """Defines an RDW sensor.""" entity_description: RDWSensorEntityDescription - _attr_has_entity_name = True def __init__( self, - *, coordinator: RDWDataUpdateCoordinator, - license_plate: str, description: RDWSensorEntityDescription, ) -> None: """Initialize RDW sensor.""" - super().__init__(coordinator=coordinator) + super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{license_plate}_{description.key}" - - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, f"{license_plate}")}, - manufacturer=coordinator.data.brand, - name=f"{coordinator.data.brand} {coordinator.data.license_plate}", - model=coordinator.data.model, - configuration_url=f"https://ovi.rdw.nl/default.aspx?kenteken={coordinator.data.license_plate}", - ) + self._attr_unique_id = f"{coordinator.data.license_plate}_{description.key}" @property def native_value(self) -> date | str | float | None: diff --git a/homeassistant/components/recovery_mode/manifest.json b/homeassistant/components/recovery_mode/manifest.json index 5837a648ecbf24..4323b54ac55cd9 100644 --- a/homeassistant/components/recovery_mode/manifest.json +++ b/homeassistant/components/recovery_mode/manifest.json @@ -3,7 +3,6 @@ "name": "Recovery Mode", "codeowners": ["@home-assistant/core"], "config_flow": false, - "dependencies": ["persistent_notification"], "documentation": "https://www.home-assistant.io/integrations/recovery_mode", "integration_type": "system", "quality_scale": "internal" diff --git a/homeassistant/components/refoss/__init__.py b/homeassistant/components/refoss/__init__.py index 10a9fd45ad2213..310a8afd284f01 100644 --- a/homeassistant/components/refoss/__init__.py +++ b/homeassistant/components/refoss/__init__.py @@ -5,13 +5,12 @@ from datetime import timedelta from typing import Final -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.event import async_track_time_interval -from .bridge import DiscoveryService -from .const import COORDINATORS, DATA_DISCOVERY_SERVICE, DISCOVERY_SCAN_INTERVAL, DOMAIN +from .bridge import DiscoveryService, RefossConfigEntry +from .const import DISCOVERY_SCAN_INTERVAL from .util import refoss_discovery_server PLATFORMS: Final = [ @@ -20,14 +19,11 @@ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RefossConfigEntry) -> bool: """Set up Refoss from a config entry.""" - hass.data.setdefault(DOMAIN, {}) discover = await refoss_discovery_server(hass) refoss_discovery = DiscoveryService(hass, entry, discover) - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - hass.data[DOMAIN][DATA_DISCOVERY_SERVICE] = refoss_discovery + entry.runtime_data = refoss_discovery await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -45,16 +41,7 @@ async def _async_scan_update(_=None): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RefossConfigEntry) -> bool: """Unload a config entry.""" - if hass.data[DOMAIN].get(DATA_DISCOVERY_SERVICE) is not None: - refoss_discovery: DiscoveryService = hass.data[DOMAIN][DATA_DISCOVERY_SERVICE] - refoss_discovery.discovery.clean_up() - hass.data[DOMAIN].pop(DATA_DISCOVERY_SERVICE) - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok: - hass.data[DOMAIN].pop(COORDINATORS) - - return unload_ok + entry.runtime_data.discovery.clean_up() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/refoss/bridge.py b/homeassistant/components/refoss/bridge.py index 278b31d30a3016..ec5ae20deb9c8f 100644 --- a/homeassistant/components/refoss/bridge.py +++ b/homeassistant/components/refoss/bridge.py @@ -1,5 +1,4 @@ """Refoss integration.""" -# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations @@ -11,15 +10,17 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import _LOGGER, COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN +from .const import _LOGGER, DISPATCH_DEVICE_DISCOVERED from .coordinator import RefossDataUpdateCoordinator +type RefossConfigEntry = ConfigEntry[DiscoveryService] + class DiscoveryService(Listener): """Discovery event handler for refoss devices.""" def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, discovery: Discovery + self, hass: HomeAssistant, config_entry: RefossConfigEntry, discovery: Discovery ) -> None: """Init discovery service.""" self.hass = hass @@ -28,7 +29,7 @@ def __init__( self.discovery = discovery self.discovery.add_listener(self) - hass.data[DOMAIN].setdefault(COORDINATORS, []) + self.coordinators: list[RefossDataUpdateCoordinator] = [] async def device_found(self, device_info: DeviceInfo) -> None: """Handle new device found on the network.""" @@ -38,7 +39,7 @@ async def device_found(self, device_info: DeviceInfo) -> None: return coordo = RefossDataUpdateCoordinator(self.hass, self.config_entry, device) - self.hass.data[DOMAIN][COORDINATORS].append(coordo) + self.coordinators.append(coordo) await coordo.async_refresh() _LOGGER.debug( @@ -50,7 +51,7 @@ async def device_found(self, device_info: DeviceInfo) -> None: async def device_update(self, device_info: DeviceInfo) -> None: """Handle updates in device information, update if ip has changed.""" - for coordinator in self.hass.data[DOMAIN][COORDINATORS]: + for coordinator in self.coordinators: if coordinator.device.device_info.mac == device_info.mac: _LOGGER.debug( "Update device %s ip to %s", diff --git a/homeassistant/components/refoss/sensor.py b/homeassistant/components/refoss/sensor.py index 60219b24b44cf6..b7be46a649c683 100644 --- a/homeassistant/components/refoss/sensor.py +++ b/homeassistant/components/refoss/sensor.py @@ -13,7 +13,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, @@ -25,15 +24,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .bridge import RefossDataUpdateCoordinator -from .const import ( - _LOGGER, - CHANNEL_DISPLAY_NAME, - COORDINATORS, - DISPATCH_DEVICE_DISCOVERED, - DOMAIN, - SENSOR_EM, -) +from .bridge import RefossConfigEntry, RefossDataUpdateCoordinator +from .const import _LOGGER, CHANNEL_DISPLAY_NAME, DISPATCH_DEVICE_DISCOVERED, SENSOR_EM from .entity import RefossEntity @@ -116,7 +108,7 @@ class RefossSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RefossConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Refoss device from a config entry.""" @@ -146,9 +138,7 @@ def init_device(coordinator: RefossDataUpdateCoordinator) -> None: ) _LOGGER.debug("Device %s add sensor entity success", device.dev_name) - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - for coordinator in hass.data[DOMAIN][COORDINATORS]: + for coordinator in config_entry.runtime_data.coordinators: init_device(coordinator) config_entry.async_on_unload( diff --git a/homeassistant/components/refoss/switch.py b/homeassistant/components/refoss/switch.py index 73a26d51401ad1..348851b9cc0fd0 100644 --- a/homeassistant/components/refoss/switch.py +++ b/homeassistant/components/refoss/switch.py @@ -7,25 +7,24 @@ from refoss_ha.controller.toggle import ToggleXMix from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .bridge import RefossDataUpdateCoordinator -from .const import _LOGGER, COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN +from .bridge import RefossConfigEntry, RefossDataUpdateCoordinator +from .const import _LOGGER, DISPATCH_DEVICE_DISCOVERED from .entity import RefossEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RefossConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Refoss device from a config entry.""" @callback - def init_device(coordinator): + def init_device(coordinator: RefossDataUpdateCoordinator) -> None: """Register the device.""" device = coordinator.device if not isinstance(device, ToggleXMix): @@ -39,9 +38,7 @@ def init_device(coordinator): async_add_entities(new_entities) _LOGGER.debug("Device %s add switch entity success", device.dev_name) - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - for coordinator in hass.data[DOMAIN][COORDINATORS]: + for coordinator in config_entry.runtime_data.coordinators: init_device(coordinator) config_entry.async_on_unload( diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index dfeaba0026cc72..9e9c1c60669ea1 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -3,11 +3,13 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from dataclasses import dataclass import itertools import logging from typing import Any +from roborock.device_features import is_wash_n_fill_dock from roborock.devices.traits.v1.consumeable import ConsumableAttribute from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockZeoProtocol @@ -43,6 +45,13 @@ class RoborockButtonDescription(ButtonEntityDescription): """Describes a Roborock button entity.""" attribute: ConsumableAttribute + is_dock_entity: bool = False + is_supported: Callable[[RoborockDataUpdateCoordinator], bool] = lambda _: True + + +def _supports_dock_consumables(coordinator: RoborockDataUpdateCoordinator) -> bool: + dock_type = coordinator.properties_api.status.dock_type + return dock_type is not None and is_wash_n_fill_dock(dock_type) CONSUMABLE_BUTTON_DESCRIPTIONS = [ @@ -74,6 +83,24 @@ class RoborockButtonDescription(ButtonEntityDescription): entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, ), + RoborockButtonDescription( + key="reset_dock_strainer_consumable", + translation_key="reset_dock_strainer_consumable", + attribute=ConsumableAttribute.STRAINER_WORK_TIME, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + is_dock_entity=True, + is_supported=_supports_dock_consumables, + ), + RoborockButtonDescription( + key="reset_dock_cleaning_brush_consumable", + translation_key="reset_dock_cleaning_brush_consumable", + attribute=ConsumableAttribute.CLEANING_BRUSH_WORK_TIME, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + is_dock_entity=True, + is_supported=_supports_dock_consumables, + ), ] @@ -128,8 +155,9 @@ async def async_setup_entry( description, ) for coordinator in config_entry.runtime_data.v1 - for description in CONSUMABLE_BUTTON_DESCRIPTIONS if isinstance(coordinator, RoborockDataUpdateCoordinator) + for description in CONSUMABLE_BUTTON_DESCRIPTIONS + if description.is_supported(coordinator) ), ( RoborockRoutineButtonEntity( @@ -176,9 +204,14 @@ def __init__( entity_description: RoborockButtonDescription, ) -> None: """Create a button entity.""" + device_info = ( + coordinator.dock_device_info + if entity_description.is_dock_entity + else coordinator.device_info + ) super().__init__( f"{entity_description.key}_{coordinator.duid_slug}", - coordinator.device_info, + device_info, api=coordinator.properties_api.command, ) self.entity_description = entity_description diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 645cbbea0c39ac..ac8e9a3fe4a870 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -550,6 +550,7 @@ def __init__( RoborockB01Props.WIND, RoborockB01Props.WATER, RoborockB01Props.MODE, + RoborockB01Props.CLEAN_PATH_PREFERENCE, RoborockB01Props.QUANTITY, ] diff --git a/homeassistant/components/roborock/icons.json b/homeassistant/components/roborock/icons.json index e5c1c6e208184d..71018ee9e14e31 100644 --- a/homeassistant/components/roborock/icons.json +++ b/homeassistant/components/roborock/icons.json @@ -24,6 +24,12 @@ "reset_air_filter_consumable": { "default": "mdi:air-filter" }, + "reset_dock_cleaning_brush_consumable": { + "default": "mdi:brush" + }, + "reset_dock_strainer_consumable": { + "default": "mdi:filter" + }, "reset_main_brush_consumable": { "default": "mdi:brush" }, diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 27305b7db2c794..2ba3d611bb353b 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -8,6 +8,7 @@ from roborock import B01Props, CleanTypeMapping from roborock.data import ( + CleanPathPreferenceMapping, RoborockDockDustCollectionModeCode, RoborockEnum, WaterLevelMapping, @@ -118,6 +119,16 @@ class RoborockSelectDescriptionA01(SelectEntityDescription): options_lambda=lambda _: list(CleanTypeMapping.keys()), entity_category=EntityCategory.CONFIG, ), + RoborockB01SelectDescription( + key="cleaning_route", + translation_key="cleaning_route", + api_fn=lambda api, value: api.set_clean_path_preference( + CleanPathPreferenceMapping.from_value(value) + ), + value_fn=lambda data: data.clean_path_preference_name, + options_lambda=lambda _: list(CleanPathPreferenceMapping.keys()), + entity_category=EntityCategory.CONFIG, + ), ] diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 2e688c64064eb0..dcd09fe973fa91 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -93,6 +93,12 @@ "reset_air_filter_consumable": { "name": "Reset air filter consumable" }, + "reset_dock_cleaning_brush_consumable": { + "name": "Reset cleaning brush consumable" + }, + "reset_dock_strainer_consumable": { + "name": "Reset strainer consumable" + }, "reset_main_brush_consumable": { "name": "Reset main brush consumable" }, @@ -123,6 +129,13 @@ "vacuum": "Vacuum only" } }, + "cleaning_route": { + "name": "Cleaning route", + "state": { + "balanced": "[%key:component::roborock::entity::vacuum::roborock::state_attributes::fan_speed::state::balanced%]", + "deep": "[%key:component::roborock::entity::select::mop_mode::state::deep%]" + } + }, "detergent_type": { "name": "Detergent type", "state": { diff --git a/homeassistant/components/satel_integra/manifest.json b/homeassistant/components/satel_integra/manifest.json index 79a56a68ce5670..ab5ed8e2b2b468 100644 --- a/homeassistant/components/satel_integra/manifest.json +++ b/homeassistant/components/satel_integra/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["satel_integra"], "quality_scale": "bronze", - "requirements": ["satel-integra==1.2.1"] + "requirements": ["satel-integra==1.2.2"] } diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 61f5255f8d26b1..e9cf34775d41fa 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -769,6 +769,7 @@ async def async_added_to_hass(self) -> None: async def async_will_remove_from_hass(self) -> None: """Stop script and remove service when it will be removed from HA.""" await self.script.async_stop() + self.script.async_unload() # remove service self.hass.services.async_remove(DOMAIN, self._attr_unique_id) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 3148b0d13c2acd..73ea6db5036536 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Mapping +from collections.abc import Callable, Mapping from contextlib import suppress from dataclasses import dataclass from datetime import UTC, date, datetime, timedelta @@ -32,6 +32,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum from homeassistant.util.hass_dict import HassKey +from homeassistant.util.variance import ignore_variance from .const import ( # noqa: F401 AMBIGUOUS_UNITS, @@ -63,6 +64,8 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL: Final = timedelta(seconds=30) +UPTIME_DEFAULT_TOLERANCE_SECONDS: Final = 60 +UPTIME_MIN_TOLERANCE_SECONDS: Final = 5 __all__ = [ "ATTR_LAST_RESET", @@ -180,6 +183,9 @@ def _calculate_precision_from_ratio( class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for sensor entities.""" + # Allow per-entity override of drift tolerance + _attr_uptime_drift_tolerance: int = UPTIME_DEFAULT_TOLERANCE_SECONDS + _entity_component_unrecorded_attributes = frozenset({ATTR_OPTIONS}) entity_description: SensorEntityDescription @@ -201,6 +207,19 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _sensor_option_display_precision: int | None = None _sensor_option_unit_of_measurement: str | None | UndefinedType = UNDEFINED _invalid_suggested_unit_of_measurement_reported = False + _get_uptime: Callable[[datetime], datetime] | None = None + + def _normalize_uptime(self, current_uptime: datetime) -> datetime: + """Normalize uptime to suppress small drift between updates.""" + if self._get_uptime is None: + drift_tolerance = max( + self._attr_uptime_drift_tolerance, UPTIME_MIN_TOLERANCE_SECONDS + ) + self._get_uptime = ignore_variance( + func=lambda value: value, + ignored_variance=timedelta(seconds=drift_tolerance), + ) + return self._get_uptime(current_uptime) @callback def add_to_platform_start( @@ -610,10 +629,14 @@ def state(self) -> Any: # Checks below only apply if there is a value if value is None: + if device_class is SensorDeviceClass.UPTIME: + # Reset baseline so the first uptime after unavailable is not + # compared against a stale value. + self._get_uptime = None return None # Received a datetime - if device_class is SensorDeviceClass.TIMESTAMP: + if device_class in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME): try: # We cast the value, to avoid using isinstance, but satisfy # typechecking. The errors are guarded in this try. @@ -627,10 +650,13 @@ def state(self) -> Any: if value.tzinfo != UTC: value = value.astimezone(UTC) + if device_class is SensorDeviceClass.UPTIME: + value = self._normalize_uptime(value) + return value.isoformat(timespec="seconds") except (AttributeError, OverflowError, TypeError) as err: raise ValueError( - f"Invalid datetime: {self.entity_id} has timestamp device class " + f"Invalid datetime: {self.entity_id} has {device_class.value} device class " f"but provides state {value}:{type(value)} resulting in '{err}'" ) from err diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 26fde240596991..85dd700e5ef1ad 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -117,6 +117,20 @@ class SensorDeviceClass(StrEnum): ISO8601 format: https://en.wikipedia.org/wiki/ISO_8601 """ + UPTIME = "uptime" + """Uptime. + + Represents the point in time when a device or service last restarted. + + Small drift between updates is automatically suppressed in + `SensorEntity.state` to avoid unnecessary state changes caused by clock + jitter. + + Unit of measurement: `None` + + ISO8601 format: https://en.wikipedia.org/wiki/ISO_8601 + """ + # Numerical device classes, these should be aligned with NumberDeviceClass ABSOLUTE_HUMIDITY = "absolute_humidity" """Absolute humidity. @@ -516,6 +530,7 @@ class SensorDeviceClass(StrEnum): SensorDeviceClass.DATE, SensorDeviceClass.ENUM, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, } DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorDeviceClass)) @@ -816,6 +831,7 @@ class SensorStateClass(StrEnum): SensorDeviceClass.TEMPERATURE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.TEMPERATURE_DELTA: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.TIMESTAMP: set(), + SensorDeviceClass.UPTIME: set(), SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.VOLTAGE: {SensorStateClass.MEASUREMENT}, diff --git a/homeassistant/components/sensor/helpers.py b/homeassistant/components/sensor/helpers.py index 12a5dcefdf8d0b..c404c697da003f 100644 --- a/homeassistant/components/sensor/helpers.py +++ b/homeassistant/components/sensor/helpers.py @@ -18,7 +18,7 @@ def async_parse_date_datetime( value: str, entity_id: str, device_class: SensorDeviceClass | str | None ) -> datetime | date | None: """Parse datetime string to a data or datetime.""" - if device_class == SensorDeviceClass.TIMESTAMP: + if device_class in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME): if (parsed_timestamp := dt_util.parse_datetime(value)) is None: _LOGGER.warning("%s rendered invalid timestamp: %s", entity_id, value) return None diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index 59d57da2803461..966e19439e38b8 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -163,6 +163,9 @@ "timestamp": { "default": "mdi:clock" }, + "uptime": { + "default": "mdi:clock-start" + }, "volatile_organic_compounds": { "default": "mdi:molecule" }, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 33b56f1b0f1df1..e51c139e8dea87 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -297,6 +297,9 @@ "timestamp": { "name": "Timestamp" }, + "uptime": { + "name": "Uptime" + }, "volatile_organic_compounds": { "name": "Volatile organic compounds" }, diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 32e733265a81b2..b47aa78cb08359 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -84,6 +84,7 @@ Platform.COVER, Platform.EVENT, Platform.LIGHT, + Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 9f0b7d5dd888fe..6c755959faf377 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -219,8 +219,6 @@ BLOCK_WRONG_SLEEP_PERIOD = 21600 BLOCK_EXPECTED_SLEEP_PERIOD = 43200 -UPTIME_DEVIATION: Final = 60 - # Time to wait before reloading entry upon device config change ENTRY_RELOAD_COOLDOWN = 60 diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index fef263efb39df1..b6be2e04fff161 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==13.24.0"], + "requirements": ["aioshelly==13.24.1"], "zeroconf": [ { "name": "shelly*", diff --git a/homeassistant/components/shelly/media_player.py b/homeassistant/components/shelly/media_player.py new file mode 100644 index 00000000000000..8479f2e72e779c --- /dev/null +++ b/homeassistant/components/shelly/media_player.py @@ -0,0 +1,430 @@ +"""Media player for Shelly.""" + +from __future__ import annotations + +import base64 +import binascii +from dataclasses import dataclass +import datetime +import hashlib +from typing import Any, Final, cast + +from aioshelly.const import RPC_GENERATIONS +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError + +from homeassistant.components.media_player import ( + BrowseMedia, + MediaClass, + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityDescription, + MediaPlayerEntityFeature, + MediaPlayerState, + MediaType, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +import homeassistant.util.dt as dt_util + +from .const import DOMAIN +from .coordinator import ShellyConfigEntry, ShellyRpcCoordinator +from .entity import ( + RpcEntityDescription, + ShellyRpcAttributeEntity, + async_setup_entry_rpc, + rpc_call, +) +from .utils import get_device_entry_gen + +CONTENT_TYPE_AUDIO = "audio" +CONTENT_TYPE_RADIO = "radio" + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class RpcMediaPlayerDescription(RpcEntityDescription, MediaPlayerEntityDescription): + """Class to describe a Shelly RPC media player entity.""" + + +RPC_MEDIA_PLAYER_ENTITIES: Final = { + "media": RpcMediaPlayerDescription( + key="media", + device_class=MediaPlayerDeviceClass.SPEAKER, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ShellyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up media player for Shelly devices.""" + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: + return _async_setup_rpc_entry(hass, config_entry, async_add_entities) + + return None + + +@callback +def _async_setup_rpc_entry( + hass: HomeAssistant, + config_entry: ShellyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up entities for RPC device.""" + async_setup_entry_rpc( + hass, + config_entry, + async_add_entities, + RPC_MEDIA_PLAYER_ENTITIES, + ShellyRpcMediaPlayer, + ) + + +class ShellyRpcMediaPlayer(ShellyRpcAttributeEntity, MediaPlayerEntity): + """Representation of a Shelly RPC media player entity.""" + + _attr_name = None + _attr_supported_features = ( + MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.PLAY_MEDIA + ) + _attr_media_content_type = MediaType.MUSIC + entity_description: RpcMediaPlayerDescription + + _last_media_position: int | None = None + _last_media_position_updated_at: datetime.datetime | None = None + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcMediaPlayerDescription, + ) -> None: + """Initialize Shelly RPC media player.""" + super().__init__(coordinator, key, attribute, description) + + @property + def _media_meta(self) -> dict[str, Any]: + """Return the media metadata.""" + return cast(dict[str, Any], self.status["playback"].get("media_meta", {})) + + @property + def state(self) -> MediaPlayerState: + """Return the state of the media player.""" + if self.status["playback"]["buffering"]: + return MediaPlayerState.BUFFERING + + if self.status["playback"]["enable"]: + return MediaPlayerState.PLAYING + + return MediaPlayerState.IDLE + + @property + def volume_level(self) -> float | None: + """Return the volume level of the media player (0..1).""" + volume = self.status["playback"]["volume"] + + return cast(float, volume) / 10 + + @property + def media_title(self) -> str | None: + """Return the title of current playing media.""" + if title := self._media_meta.get("title"): + return cast(str, title) + + return None + + @property + def media_artist(self) -> str | None: + """Return the artist of current playing media.""" + if self.status["playback"].get("media_type") == "RADIO": + return None + + if artist := self._media_meta.get("artist"): + return cast(str, artist) + + return None + + @property + def media_album_name(self) -> str | None: + """Return the album name of current playing media.""" + if self.status["playback"].get("media_type") == "RADIO": + return None + + if album := self._media_meta.get("album"): + return cast(str, album) + + return None + + @property + def media_duration(self) -> int | None: + """Return the duration of current playing media in seconds.""" + if self.status["playback"].get("media_type") == "RADIO": + return None + + if (duration := self._media_meta.get("duration")) is not None: + return cast(int, duration) // 1000 + + return None + + @property + def media_position(self) -> int | None: + """Return the current playback position in seconds.""" + if (position := self._get_updated_media_position()) is not None: + return position // 1000 + + return None + + @property + def media_position_updated_at(self) -> datetime.datetime | None: + """Return when the position was last updated.""" + self._get_updated_media_position() + + return self._last_media_position_updated_at + + @property + def media_image_url(self) -> str | None: + """Return the image URL of current playing media.""" + if (thumb := self._media_meta.get("thumb")) and thumb.startswith("http"): + return cast(str, thumb) + + return None + + @property + def media_image_remotely_accessible(self) -> bool: + """Return True if the image URL is remotely accessible.""" + return self.media_image_url is not None + + @property + def media_image_hash(self) -> str | None: + """Hash value for media image.""" + if (thumb := self._media_meta.get("thumb")) and thumb.startswith("data"): + return hashlib.sha256(thumb.encode("utf-8")).hexdigest()[:16] + return super().media_image_hash + + def _get_updated_media_position(self) -> int | None: + """Return the current playback position and update its timestamp.""" + if (position := self._media_meta.get("position")) is None: + self._last_media_position = None + self._last_media_position_updated_at = None + return None + + current_position = cast(int, position) + if current_position != self._last_media_position: + self._last_media_position = current_position + self._last_media_position_updated_at = dt_util.utcnow() + + return current_position + + async def async_get_media_image(self) -> tuple[bytes | None, str | None]: + """Fetch media image of current playing track.""" + thumb = self._media_meta["thumb"] + try: + prefix, image_data = thumb.split(",", 1) + image = base64.b64decode(image_data, validate=True) + mime = prefix.split(";", 1)[0].rsplit(":", 1)[-1] + except binascii.Error, ValueError: + return await super().async_get_media_image() + + return image, mime + + @rpc_call + async def async_media_play(self) -> None: + """Send play command.""" + if self.state != MediaPlayerState.PLAYING: + await self.coordinator.device.media_play_or_pause() + + @rpc_call + async def async_media_pause(self) -> None: + """Send pause command.""" + if self.state == MediaPlayerState.PLAYING: + await self.coordinator.device.media_play_or_pause() + + @rpc_call + async def async_media_stop(self) -> None: + """Send stop command.""" + await self.coordinator.device.media_stop() + + @rpc_call + async def async_media_next_track(self) -> None: + """Send next track command.""" + await self.coordinator.device.media_next() + + @rpc_call + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + await self.coordinator.device.media_previous() + + @rpc_call + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + await self.coordinator.device.media_set_volume(round(volume * 10)) + + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Browse radio stations and audio files.""" + if not media_content_type: + return await self._async_browse_media_root() + + try: + if media_content_type == CONTENT_TYPE_RADIO: + return await self._async_browse_radio_stations(expanded=True) + if media_content_type == CONTENT_TYPE_AUDIO: + return await self._async_browse_audio_files(expanded=True) + except DeviceConnectionError as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, + ) from err + except RpcCallError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="rpc_call_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, + ) from err + except InvalidAuthError as err: + await self.coordinator.async_shutdown_device_and_start_reauth() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={ + "device": self.coordinator.name, + }, + ) from err + + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_media_content_type", + translation_placeholders={"media_content_type": str(media_content_type)}, + ) + + async def _async_browse_media_root(self) -> BrowseMedia: + """Return root BrowseMedia tree.""" + return BrowseMedia( + title="Shelly", + media_class=MediaClass.DIRECTORY, + media_content_type="", + media_content_id="", + children=[ + await self._async_browse_radio_stations(), + await self._async_browse_audio_files(), + ], + can_play=False, + can_expand=True, + ) + + async def _async_browse_audio_files(self, expanded: bool = False) -> BrowseMedia: + """Return BrowseMedia tree for audio files.""" + if expanded: + result: list[ + dict[str, Any] + ] = await self.coordinator.device.media_list_media() + children: list[BrowseMedia] | None = [ + BrowseMedia( + title=item["title"], + media_class=MediaClass.MUSIC, + media_content_type=CONTENT_TYPE_AUDIO, + media_content_id=str(item["id"]), + thumbnail=item["preview"], + can_play=True, + can_expand=False, + ) + for item in result + if item["type"] == "AUDIO" + ] + else: + children = None + + return BrowseMedia( + title="Audio files", + media_class=MediaClass.DIRECTORY, + media_content_type=CONTENT_TYPE_AUDIO, + media_content_id=CONTENT_TYPE_AUDIO, + children_media_class=MediaClass.MUSIC, + children=children, + can_play=False, + can_expand=True, + ) + + async def _async_browse_radio_stations(self, expanded: bool = False) -> BrowseMedia: + """Return BrowseMedia tree for radio stations.""" + if expanded: + result: list[ + dict[str, Any] + ] = await self.coordinator.device.media_list_radio_stations() + children: list[BrowseMedia] | None = [ + BrowseMedia( + title=station["name"], + media_class=MediaClass.MUSIC, + media_content_type=CONTENT_TYPE_RADIO, + media_content_id=str(station["id"]), + thumbnail=station["icon"], + can_play=True, + can_expand=False, + ) + for station in result + ] + else: + children = None + + return BrowseMedia( + title="Radio stations", + media_class=MediaClass.DIRECTORY, + media_content_type=CONTENT_TYPE_RADIO, + media_content_id=CONTENT_TYPE_RADIO, + children_media_class=MediaClass.MUSIC, + children=children, + can_play=False, + can_expand=True, + ) + + @rpc_call + async def async_play_media( + self, + media_type: MediaType | str, + media_id: str, + **kwargs: Any, + ) -> None: + """Play media by type and id.""" + if media_id.isdecimal() is False: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_media_id", + translation_placeholders={"media_id": media_id}, + ) + + if media_type == CONTENT_TYPE_RADIO: + await self.coordinator.device.media_play_radio_station(int(media_id)) + return + + if media_type == CONTENT_TYPE_AUDIO: + await self.coordinator.device.media_play_media(int(media_id)) + return + + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_media_type", + translation_placeholders={"media_type": str(media_type)}, + ) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 5eeb818c59a56d..9e0ddf49a9e7f6 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import timedelta from typing import Final, cast from aioshelly.block_device import Block @@ -41,6 +42,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType +from homeassistant.util.dt import utcnow from .const import CONF_SLEEP_PERIOD, ROLE_GENERIC from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator @@ -62,7 +64,6 @@ async_remove_orphaned_entities, get_blu_trv_device_info, get_device_entry_gen, - get_device_uptime, get_shelly_air_lamp_life, get_virtual_component_unit, is_rpc_wifi_stations_disabled, @@ -466,9 +467,8 @@ def __init__( ), "uptime": RestSensorDescription( key="uptime", - translation_key="last_restart", - value=lambda status, last: get_device_uptime(status["uptime"], last), - device_class=SensorDeviceClass.TIMESTAMP, + value=lambda status, _: utcnow() - timedelta(seconds=status["uptime"]), + device_class=SensorDeviceClass.UPTIME, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -1242,9 +1242,8 @@ def __init__( "uptime": RpcSensorDescription( key="sys", sub_key="uptime", - translation_key="last_restart", - value=get_device_uptime, - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, + value=lambda status, _: utcnow() - timedelta(seconds=status), entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, use_polling_coordinator=True, diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 7312bf14a50341..143c4827943a75 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -424,9 +424,6 @@ "lamp_life": { "name": "Lamp life" }, - "last_restart": { - "name": "Last restart" - }, "left_slot_level": { "name": "Left slot level" }, @@ -657,6 +654,15 @@ "rpc_call_error": { "message": "RPC call error occurred for {device}" }, + "unsupported_media_content_type": { + "message": "Unsupported media content type for Shelly device: {media_content_type}" + }, + "unsupported_media_id": { + "message": "Unsupported media ID for Shelly device: {media_id}" + }, + "unsupported_media_type": { + "message": "Unsupported media type for Shelly device: {media_type}" + }, "update_error": { "message": "An error occurred while retrieving data from {device}" }, diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 69d4d719569683..7d26eee7c74b8f 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Iterable, Mapping -from datetime import datetime, timedelta from ipaddress import IPv4Address, IPv6Address, ip_address from typing import TYPE_CHECKING, Any, cast @@ -51,7 +50,6 @@ DeviceInfo, ) from homeassistant.helpers.network import NoURLAvailableError, get_url -from homeassistant.util.dt import utcnow from .const import ( API_WS_URL, @@ -78,7 +76,6 @@ SHELLY_EMIT_EVENT_PATTERN, SHELLY_WALL_DISPLAY_MODELS, SHIX3_1_INPUTS_EVENTS_TYPES, - UPTIME_DEVIATION, VIRTUAL_COMPONENTS, VIRTUAL_COMPONENTS_MAP, WALL_DISPLAY_RELEASE_URL, @@ -194,29 +191,6 @@ def is_block_exclude_from_relay(settings: dict[str, Any], block: Block) -> bool: return is_block_channel_type_light(settings, block) -def get_device_uptime(uptime: float, last_uptime: datetime | None) -> datetime: - """Return device uptime string, tolerate up to 5 seconds deviation.""" - delta_uptime = utcnow() - timedelta(seconds=uptime) - - if ( - not last_uptime - or (diff := abs((delta_uptime - last_uptime).total_seconds())) - > UPTIME_DEVIATION - ): - if last_uptime: - LOGGER.debug( - "Time deviation %s > %s: uptime=%s, last_uptime=%s, delta_uptime=%s", - diff, - UPTIME_DEVIATION, - uptime, - last_uptime, - delta_uptime, - ) - return delta_uptime - - return last_uptime - - def get_block_input_triggers( device: BlockDevice, block: Block ) -> list[tuple[str, str]]: diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index ccb66f3cc9607d..cf5900e2096773 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -13,8 +13,13 @@ from homeassistant.components import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.const import ATTR_NAME, Platform -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + HomeAssistant, + ServiceCall, + callback, +) +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType from .common import ( @@ -56,15 +61,31 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if DOMAIN not in config: return True - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - ) + hass.async_create_task(_async_setup(hass)) return True +async def _async_setup(hass: HomeAssistant) -> None: + await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + ) + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2026.11.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Shopping List", + }, + ) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ShoppingListConfigEntry ) -> bool: diff --git a/homeassistant/components/solaredge/const.py b/homeassistant/components/solaredge/const.py index 35a14091e68b0f..e2e141402fbeb6 100644 --- a/homeassistant/components/solaredge/const.py +++ b/homeassistant/components/solaredge/const.py @@ -22,6 +22,7 @@ INVENTORY_UPDATE_DELAY = timedelta(hours=12) POWER_FLOW_UPDATE_DELAY = timedelta(minutes=15) ENERGY_DETAILS_DELAY = timedelta(minutes=15) +STORAGE_DATA_UPDATE_DELAY = timedelta(hours=4) MODULE_STATISTICS_UPDATE_DELAY = timedelta(hours=12) SCAN_INTERVAL = timedelta(minutes=15) diff --git a/homeassistant/components/solaredge/coordinator.py b/homeassistant/components/solaredge/coordinator.py index ed3bff8cea2e37..9fb33a755f3810 100644 --- a/homeassistant/components/solaredge/coordinator.py +++ b/homeassistant/components/solaredge/coordinator.py @@ -38,6 +38,7 @@ MODULE_STATISTICS_UPDATE_DELAY, OVERVIEW_UPDATE_DELAY, POWER_FLOW_UPDATE_DELAY, + STORAGE_DATA_UPDATE_DELAY, ) if TYPE_CHECKING: @@ -334,6 +335,86 @@ async def async_update_data(self) -> None: LOGGER.debug("Updated SolarEdge power flow: %s, %s", self.data, self.attributes) +class SolarEdgeStorageDataService(SolarEdgeDataService): + """Get and update the latest storage data.""" + + @property + def update_interval(self) -> timedelta: + """Update interval.""" + return STORAGE_DATA_UPDATE_DELAY + + async def async_update_data(self) -> None: + """Update the data from the SolarEdge Monitoring API.""" + now = dt_util.now() + start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0) + data = await self.api.get_storage_data( + self.site_id, + start_of_day, + now, + ) + storage_data = data.get("storageData") + if storage_data is None: + raise UpdateFailed("Storage data not available from API") + + batteries = storage_data.get("batteries") + if batteries is None: + raise UpdateFailed("Battery data not available from API") + + self.data = {} + self.attributes = {} + + if not batteries: + LOGGER.debug("No batteries found in storage data") + return + + # Aggregate totals across all batteries + total_charge_energy = 0.0 + total_discharge_energy = 0.0 + + for battery in batteries: + serial = battery.get("serialNumber") + if not serial: + LOGGER.debug("Skipping battery without serialNumber") + continue + + telemetries = battery.get("telemetries", []) + + if not telemetries: + continue + + latest = telemetries[-1] + + # Per-battery current values + self.data[f"{serial}_state_of_charge"] = latest.get( + "batteryPercentageState" + ) + self.data[f"{serial}_power"] = latest.get("power") + + # Compute daily charge/discharge delta from lifetime counters + if len(telemetries) >= 2: + first = telemetries[0] + charge_energy = latest.get("lifeTimeEnergyCharged", 0.0) - first.get( + "lifeTimeEnergyCharged", 0.0 + ) + discharge_energy = latest.get( + "lifeTimeEnergyDischarged", 0.0 + ) - first.get("lifeTimeEnergyDischarged", 0.0) + else: + charge_energy = 0.0 + discharge_energy = 0.0 + + total_charge_energy += charge_energy + total_discharge_energy += discharge_energy + + self.data[f"{serial}_charge_energy"] = charge_energy + self.data[f"{serial}_discharge_energy"] = discharge_energy + + self.data["charge_energy"] = total_charge_energy + self.data["discharge_energy"] = total_discharge_energy + + LOGGER.debug("Updated SolarEdge storage data: %s", self.data) + + class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]): """Handle fetching SolarEdge Modules data and inserting statistics.""" diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index b56c35be16023d..096b1eed70dc69 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -22,7 +22,7 @@ DataUpdateCoordinator, ) -from .const import CONF_SITE_ID, DATA_API_CLIENT, DOMAIN +from .const import CONF_SITE_ID, DATA_API_CLIENT, DOMAIN, LOGGER from .coordinator import ( SolarEdgeDataService, SolarEdgeDetailsDataService, @@ -30,6 +30,7 @@ SolarEdgeInventoryDataService, SolarEdgeOverviewDataService, SolarEdgePowerFlowDataService, + SolarEdgeStorageDataService, ) from .types import SolarEdgeConfigEntry @@ -207,6 +208,64 @@ class SolarEdgeSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, ), + SolarEdgeSensorEntityDescription( + key="storage_charge_energy", + json_key="charge_energy", + translation_key="storage_charge_energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="storage_discharge_energy", + json_key="discharge_energy", + translation_key="storage_discharge_energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), +] + +# Per-battery sensor descriptions, created dynamically per serial number +BATTERY_SENSOR_TYPES = [ + SolarEdgeSensorEntityDescription( + key="battery_charge_energy", + json_key="charge_energy", + translation_key="battery_charge_energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="battery_discharge_energy", + json_key="discharge_energy", + translation_key="battery_discharge_energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="battery_state_of_charge", + json_key="state_of_charge", + translation_key="battery_state_of_charge", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), + SolarEdgeSensorEntityDescription( + key="battery_power", + json_key="power", + translation_key="battery_power", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + ), ] @@ -222,15 +281,43 @@ async def async_setup_entry( api = entry.runtime_data[DATA_API_CLIENT] sensor_factory = SolarEdgeSensorFactory(hass, entry, entry.data[CONF_SITE_ID], api) + + # Set up and refresh base services first for service in sensor_factory.all_services: service.async_setup() await service.coordinator.async_refresh() - entities = [] + entities: list[SolarEdgeSensorEntity] = [] + + # Set up storage sensors only if inventory shows batteries are present + storage_result = sensor_factory.setup_storage_sensors() + if storage_result is not None: + if storage_result: + await sensor_factory.storage_service.coordinator.async_refresh() + entities.extend(storage_result) + else: + # Inventory fetch failed, register listener to retry when data arrives + def on_inventory_update() -> None: + """Handle inventory update to set up storage sensors.""" + result = sensor_factory.setup_storage_sensors() + if result is not None: + if result: + hass.async_create_task( + sensor_factory.storage_service.coordinator.async_refresh() + ) + async_add_entities(result) + # Success or confirmed no batteries - stop listening + unsub() + + unsub = sensor_factory.inventory_service.coordinator.async_add_listener( + on_inventory_update + ) + entry.async_on_unload(unsub) + for sensor_type in SENSOR_TYPES: - sensor = sensor_factory.create_sensor(sensor_type) - if sensor is not None: - entities.append(sensor) + if sensor_type.key in ("storage_charge_energy", "storage_discharge_energy"): + continue + entities.append(sensor_factory.create_sensor(sensor_type)) async_add_entities(entities) @@ -251,8 +338,17 @@ def __init__( inventory = SolarEdgeInventoryDataService(hass, config_entry, api, site_id) flow = SolarEdgePowerFlowDataService(hass, config_entry, api, site_id) energy = SolarEdgeEnergyDetailsService(hass, config_entry, api, site_id) - - self.all_services = (details, overview, inventory, flow, energy) + storage = SolarEdgeStorageDataService(hass, config_entry, api, site_id) + + self.all_services: list[SolarEdgeDataService] = [ + details, + overview, + inventory, + flow, + energy, + ] + self.inventory_service = inventory + self.storage_service = storage self.services: dict[ str, @@ -289,6 +385,56 @@ def __init__( ): self.services[key] = (SolarEdgeEnergyDetailsSensor, energy) + def setup_storage_sensors( + self, + ) -> list[SolarEdgeSensorEntity] | None: + """Set up storage sensors if batteries are available. + + Returns: + list: Storage sensor entities to add (empty if no batteries) + None: Inventory fetch failed, should retry later + """ + # Check if inventory data was successfully fetched + if not self.inventory_service.coordinator.last_update_success: + LOGGER.debug("Inventory data not available, will retry later") + return None + + battery_attr = self.inventory_service.attributes.get("batteries", {}) + inventory_batteries = battery_attr.get("batteries", []) + if not inventory_batteries: + LOGGER.debug("No batteries found in inventory, skipping storage sensors") + return [] + + # Set up storage service and add to services + self.storage_service.async_setup() + self.all_services.append(self.storage_service) + + for key in ("storage_charge_energy", "storage_discharge_energy"): + self.services[key] = (SolarEdgeStorageDataSensor, self.storage_service) + + # Create aggregate storage sensors + storage_entities: list[SolarEdgeSensorEntity] = [ + self.create_sensor(sensor_type) + for sensor_type in SENSOR_TYPES + if sensor_type.key in ("storage_charge_energy", "storage_discharge_energy") + ] + + # Create per-battery entities + for battery in inventory_batteries: + serial = battery.get("SN") or battery.get("serialNumber") + if not serial: + LOGGER.debug("Skipping battery without serial number in inventory") + continue + storage_entities.extend( + SolarEdgeBatterySensor(sensor_type, self.storage_service, serial) + for sensor_type in BATTERY_SENSOR_TYPES + ) + + LOGGER.debug( + "Storage sensors enabled, found %d batteries", len(inventory_batteries) + ) + return storage_entities + def create_sensor( self, sensor_type: SolarEdgeSensorEntityDescription ) -> SolarEdgeSensorEntity: @@ -316,17 +462,11 @@ def __init__( super().__init__(data_service.coordinator) self.entity_description = description self.data_service = data_service + self._attr_unique_id = f"{data_service.site_id}_{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, data_service.site_id)}, manufacturer="SolarEdge" ) - @property - def unique_id(self) -> str | None: - """Return a unique ID.""" - if not self.data_service.site_id: - return None - return f"{self.data_service.site_id}_{self.entity_description.key}" - class SolarEdgeOverviewSensor(SolarEdgeSensorEntity): """Representation of an SolarEdge Monitoring API overview sensor.""" @@ -434,3 +574,41 @@ def native_value(self) -> str | None: if attr and "soc" in attr: return attr["soc"] return None + + +class SolarEdgeStorageDataSensor(SolarEdgeSensorEntity): + """Representation of an SolarEdge aggregate storage data sensor.""" + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.data_service.data.get(self.entity_description.json_key) + + +class SolarEdgeBatterySensor(SolarEdgeSensorEntity): + """Representation of a per-battery SolarEdge sensor.""" + + def __init__( + self, + description: SolarEdgeSensorEntityDescription, + data_service: SolarEdgeStorageDataService, + serial: str, + ) -> None: + """Initialize the per-battery sensor.""" + super().__init__(description, data_service) + self._serial = serial + self._attr_unique_id = f"{data_service.site_id}_{serial}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{data_service.site_id}_{serial}")}, + manufacturer="SolarEdge", + name=f"Battery {serial}", + serial_number=serial, + via_device=(DOMAIN, data_service.site_id), + ) + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.data_service.data.get( + f"{self._serial}_{self.entity_description.json_key}" + ) diff --git a/homeassistant/components/solaredge/strings.json b/homeassistant/components/solaredge/strings.json index 2dd02f70ade838..0225262e9735d0 100644 --- a/homeassistant/components/solaredge/strings.json +++ b/homeassistant/components/solaredge/strings.json @@ -85,6 +85,18 @@ "batteries": { "name": "Batteries" }, + "battery_charge_energy": { + "name": "Charge energy today" + }, + "battery_discharge_energy": { + "name": "Discharge energy today" + }, + "battery_power": { + "name": "Power" + }, + "battery_state_of_charge": { + "name": "State of charge" + }, "consumption_energy": { "name": "Consumed energy" }, @@ -139,6 +151,12 @@ "solar_power": { "name": "Solar power" }, + "storage_charge_energy": { + "name": "Storage charge energy today" + }, + "storage_discharge_energy": { + "name": "Storage discharge energy today" + }, "storage_level": { "name": "Storage level" }, diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 098e30d6d2d117..d346058b1dfc05 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -42,5 +42,5 @@ "iot_class": "local_push", "loggers": ["switchbot"], "quality_scale": "gold", - "requirements": ["PySwitchbot==2.1.0"] + "requirements": ["PySwitchbot==2.2.0"] } diff --git a/homeassistant/components/synology_dsm/entity.py b/homeassistant/components/synology_dsm/entity.py index 3ffbcce54665a2..b40a53560484f8 100644 --- a/homeassistant/components/synology_dsm/entity.py +++ b/homeassistant/components/synology_dsm/entity.py @@ -5,7 +5,11 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -56,6 +60,9 @@ def __init__( ) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, information.serial)}, + connections={ + (CONNECTION_NETWORK_MAC, format_mac(mac)) for mac in network.macs + }, name=network.hostname, manufacturer="Synology", model=information.model, diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index dd46fa33c3a221..e304f3b624437f 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime from typing import TYPE_CHECKING, cast from synology_dsm.api.core.external_usb import ( @@ -32,7 +32,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.util.dt import utcnow from . import SynoApi from .const import CONF_VOLUMES, ENTITY_UNIT_LOAD @@ -327,8 +326,7 @@ class SynologyDSMSensorEntityDescription( SynologyDSMSensorEntityDescription( api_key=SynoDSMInformation.API_KEY, key="uptime", - translation_key="uptime", - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -545,17 +543,6 @@ def available(self) -> bool: class SynoDSMInfoSensor(SynoDSMSensor): """Representation a Synology information sensor.""" - def __init__( - self, - api: SynoApi, - coordinator: SynologyDSMCentralUpdateCoordinator, - description: SynologyDSMSensorEntityDescription, - ) -> None: - """Initialize the Synology SynoDSMInfoSensor entity.""" - super().__init__(api, coordinator, description) - self._previous_uptime: str | None = None - self._last_boot: datetime | None = None - @property def native_value(self) -> StateType | datetime: """Return the state.""" @@ -563,11 +550,4 @@ def native_value(self) -> StateType | datetime: if attr is None: return None - if self.entity_description.key == "uptime": - # reboot happened or entity creation - if self._previous_uptime is None or self._previous_uptime > attr: - self._last_boot = utcnow() - timedelta(seconds=attr) - - self._previous_uptime = attr - return self._last_boot return attr # type: ignore[no-any-return] diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index 1ccd549be79e71..aedd23f57729e7 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -9,7 +9,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "missing_data": "Missing data: please retry later or an other configuration", + "missing_data": "Missing data: please retry later or try a different configuration", "otp_failed": "Two-step authentication failed, retry with a new passcode", "unknown": "[%key:common::config_flow::error::unknown%]" }, @@ -157,9 +157,6 @@ "temperature": { "name": "[%key:component::sensor::entity_component::temperature::name%]" }, - "uptime": { - "name": "Last boot" - }, "volume_disk_temp_avg": { "name": "Average disk temp" }, diff --git a/homeassistant/components/tailwind/manifest.json b/homeassistant/components/tailwind/manifest.json index 136492d884f0d7..1f4f4dde0d85b2 100644 --- a/homeassistant/components/tailwind/manifest.json +++ b/homeassistant/components/tailwind/manifest.json @@ -11,7 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/tailwind", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["gotailwind==0.3.0"], + "requirements": ["gotailwind==0.4.0"], "zeroconf": [ { "properties": { diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 0a38f802e4b4a6..184f35b267c89f 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -13,6 +13,7 @@ from homeassistant.components.button import ButtonDeviceClass from homeassistant.components.cover import CoverDeviceClass from homeassistant.components.event import EventDeviceClass +from homeassistant.components.number import NumberDeviceClass from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASS_STATE_CLASSES, @@ -286,6 +287,14 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: if domain == Platform.NUMBER: schema |= { + vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[cls.value for cls in NumberDeviceClass], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="number_device_class", + sort=True, + ), + ), vol.Required(CONF_STATE): selector.TemplateSelector(), vol.Required(CONF_MIN, default=DEFAULT_MIN_VALUE): selector.NumberSelector( selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX), diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 9dd62100917639..a9da3f00960bf4 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -11,12 +11,18 @@ DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, DEFAULT_STEP, + DEVICE_CLASSES_SCHEMA, DOMAIN as NUMBER_DOMAIN, ENTITY_ID_FORMAT, NumberEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_STATE, CONF_UNIT_OF_MEASUREMENT +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_STATE, + CONF_UNIT_OF_MEASUREMENT, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( @@ -50,6 +56,7 @@ NUMBER_COMMON_SCHEMA = vol.Schema( { + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): cv.template, vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template, vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, @@ -124,6 +131,7 @@ class AbstractTemplateNumber(AbstractTemplateEntity, NumberEntity): # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" + self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_native_step = DEFAULT_STEP self._attr_native_min_value = DEFAULT_MIN_VALUE diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 027470fafa63bb..d7778fe03ea335 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -292,6 +292,7 @@ }, "number": { "data": { + "device_class": "[%key:component::template::common::device_class%]", "device_id": "[%key:common::config_flow::data::device%]", "max": "Maximum value", "min": "Minimum value", @@ -836,6 +837,7 @@ }, "number": { "data": { + "device_class": "[%key:component::template::common::device_class%]", "device_id": "[%key:common::config_flow::data::device%]", "max": "[%key:component::template::config::step::number::data::max%]", "min": "[%key:component::template::config::step::number::data::min%]", @@ -1128,6 +1130,62 @@ "motion": "[%key:component::event::entity_component::motion::name%]" } }, + "number_device_class": { + "options": { + "absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]", + "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", + "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "area": "[%key:component::sensor::entity_component::area::name%]", + "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", + "battery": "[%key:component::sensor::entity_component::battery::name%]", + "blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]", + "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", + "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "conductivity": "[%key:component::sensor::entity_component::conductivity::name%]", + "current": "[%key:component::sensor::entity_component::current::name%]", + "data_rate": "[%key:component::sensor::entity_component::data_rate::name%]", + "distance": "[%key:component::sensor::entity_component::distance::name%]", + "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", + "frequency": "[%key:component::sensor::entity_component::frequency::name%]", + "gas": "[%key:component::sensor::entity_component::gas::name%]", + "humidity": "[%key:component::sensor::entity_component::humidity::name%]", + "illuminance": "[%key:component::sensor::entity_component::illuminance::name%]", + "irradiance": "[%key:component::sensor::entity_component::irradiance::name%]", + "moisture": "[%key:component::sensor::entity_component::moisture::name%]", + "nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]", + "nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]", + "ozone": "[%key:component::sensor::entity_component::ozone::name%]", + "ph": "[%key:component::sensor::entity_component::ph::name%]", + "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "pm4": "[%key:component::sensor::entity_component::pm4::name%]", + "power": "[%key:component::sensor::entity_component::power::name%]", + "power_factor": "[%key:component::sensor::entity_component::power_factor::name%]", + "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", + "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", + "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_energy": "[%key:component::sensor::entity_component::reactive_energy::name%]", + "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", + "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", + "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", + "speed": "[%key:component::sensor::entity_component::speed::name%]", + "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", + "temperature": "[%key:component::sensor::entity_component::temperature::name%]", + "temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]", + "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "voltage": "[%key:component::sensor::entity_component::voltage::name%]", + "volume": "[%key:component::sensor::entity_component::volume::name%]", + "volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]", + "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", + "water": "[%key:component::sensor::entity_component::water::name%]", + "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]", + "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" + } + }, "sensor_device_class": { "options": { "absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]", @@ -1142,12 +1200,8 @@ "conductivity": "[%key:component::sensor::entity_component::conductivity::name%]", "current": "[%key:component::sensor::entity_component::current::name%]", "data_rate": "[%key:component::sensor::entity_component::data_rate::name%]", - "data_size": "[%key:component::sensor::entity_component::data_size::name%]", - "date": "[%key:component::sensor::entity_component::date::name%]", "distance": "[%key:component::sensor::entity_component::distance::name%]", - "duration": "[%key:component::sensor::entity_component::duration::name%]", "energy": "[%key:component::sensor::entity_component::energy::name%]", - "energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]", "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", "frequency": "[%key:component::sensor::entity_component::frequency::name%]", "gas": "[%key:component::sensor::entity_component::gas::name%]", @@ -1155,7 +1209,6 @@ "illuminance": "[%key:component::sensor::entity_component::illuminance::name%]", "irradiance": "[%key:component::sensor::entity_component::irradiance::name%]", "moisture": "[%key:component::sensor::entity_component::moisture::name%]", - "monetary": "[%key:component::sensor::entity_component::monetary::name%]", "nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", "nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]", "nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]", @@ -1178,7 +1231,6 @@ "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", "temperature": "[%key:component::sensor::entity_component::temperature::name%]", "temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]", - "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", "voltage": "[%key:component::sensor::entity_component::voltage::name%]", diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index f06ae13141b324..d5f141b0c90b11 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable import logging from typing import TYPE_CHECKING, Any @@ -10,6 +11,7 @@ from homeassistant.components.vacuum import ( ATTR_FAN_SPEED, DOMAIN as VACUUM_DOMAIN, + SERVICE_CLEAN_AREA, SERVICE_CLEAN_SPOT, SERVICE_LOCATE, SERVICE_PAUSE, @@ -17,6 +19,7 @@ SERVICE_SET_FAN_SPEED, SERVICE_START, SERVICE_STOP, + Segment, StateVacuumEntity, VacuumActivity, VacuumEntityFeature, @@ -65,6 +68,7 @@ CONF_FAN_SPEED_LIST = "fan_speeds" CONF_FAN_SPEED = "fan_speed" CONF_FAN_SPEED_TEMPLATE = "fan_speed_template" +CONF_SEGMENTS_TEMPLATE = "segments_template" DEFAULT_NAME = "Template Vacuum" @@ -77,6 +81,7 @@ } SCRIPT_FIELDS = ( + SERVICE_CLEAN_AREA, SERVICE_CLEAN_SPOT, SERVICE_LOCATE, SERVICE_PAUSE, @@ -86,12 +91,19 @@ SERVICE_STOP, ) +CLEAN_AREA_GROUP = "clean_area_group" + VACUUM_COMMON_SCHEMA = vol.Schema( { vol.Optional(CONF_BATTERY_LEVEL): cv.template, vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, vol.Optional(CONF_FAN_SPEED): cv.template, vol.Optional(CONF_STATE): cv.template, + vol.Inclusive( + CONF_SEGMENTS_TEMPLATE, + CLEAN_AREA_GROUP, + f"Options `{CONF_SEGMENTS_TEMPLATE}` and `{SERVICE_CLEAN_AREA}` must both exist", + ): cv.template, vol.Optional(SERVICE_CLEAN_SPOT): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_LOCATE): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA, @@ -99,15 +111,23 @@ vol.Optional(SERVICE_SET_FAN_SPEED): cv.SCRIPT_SCHEMA, vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA, + vol.Inclusive( + SERVICE_CLEAN_AREA, + CLEAN_AREA_GROUP, + f"Options `{CONF_SEGMENTS_TEMPLATE}` and `{SERVICE_CLEAN_AREA}` must both exist", + ): cv.SCRIPT_SCHEMA, } ) -VACUUM_YAML_SCHEMA = VACUUM_COMMON_SCHEMA.extend( - TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA -).extend( - make_template_entity_common_modern_attributes_schema( - VACUUM_DOMAIN, DEFAULT_NAME - ).schema + +VACUUM_YAML_SCHEMA = vol.All( + VACUUM_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA).extend( + make_template_entity_common_modern_attributes_schema( + VACUUM_DOMAIN, DEFAULT_NAME + ).schema + ), + cv.key_dependency(CONF_SEGMENTS_TEMPLATE, CONF_UNIQUE_ID), + cv.key_dependency(SERVICE_CLEAN_AREA, CONF_UNIQUE_ID), ) VACUUM_LEGACY_YAML_SCHEMA = vol.All( @@ -214,6 +234,59 @@ def create_issue( ) +def validate_segments( + entity: AbstractTemplateVacuum, + option: str, +) -> Callable[[Any], list[Segment] | None]: + """Parse segment template to list of segments.""" + + def parse(result: Any) -> list[Segment] | None: + if template_validators.check_result_for_none(result): + return None + + segments: list[Segment] = [] + + if not isinstance(result, list): + template_validators.log_validation_result_error( + entity, + option, + result, + "expected a list of dictionaries", + ) + return None + + for item in result: + if not isinstance(item, dict): + template_validators.log_validation_result_error( + entity, + option, + item, + "expected dictionary with keys id, name and optional group" + " and string values", + ) + return None + + if ( + not isinstance(item.get("id"), str) + or not isinstance(item.get("name"), str) + or ("group" in item and not isinstance(item["group"], str)) + or not set(item).issubset({"id", "name", "group"}) + ): + template_validators.log_validation_result_error( + entity, + option, + item, + "expected dictionary with keys id, name and optional group" + " and string values", + ) + return None + + segments.append(Segment(**item)) + return segments + + return parse + + class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): """Representation of a template vacuum features.""" @@ -228,6 +301,7 @@ def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disabl # List of valid fan speeds self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST] + self._segments: list[Segment] = [] self.setup_state_template( "_attr_activity", template_validators.strenum(self, CONF_STATE, VacuumActivity), @@ -245,6 +319,13 @@ def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disabl template_validators.number(self, CONF_BATTERY_LEVEL, 0.0, 100.0), ) + self.setup_template( + CONF_SEGMENTS_TEMPLATE, + "_segments", + validate_segments(self, CONF_SEGMENTS_TEMPLATE), + self._update_segments, + ) + self._attr_supported_features = ( VacuumEntityFeature.START | VacuumEntityFeature.STATE ) @@ -260,11 +341,41 @@ def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disabl (SERVICE_CLEAN_SPOT, VacuumEntityFeature.CLEAN_SPOT), (SERVICE_LOCATE, VacuumEntityFeature.LOCATE), (SERVICE_SET_FAN_SPEED, VacuumEntityFeature.FAN_SPEED), + (SERVICE_CLEAN_AREA, VacuumEntityFeature.CLEAN_AREA), ): if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature + @callback + def _update_segments(self, result: list[Segment] | None) -> None: + """Save segment templates and create issue when segments changed.""" + if result is None: + return + + self._segments = result + + if (last_seen := self.last_seen_segments) is not None and { + s.id: s for s in last_seen + } != {s.id: s for s in self._segments}: + self.async_create_segments_issue() + + async def async_get_segments(self) -> list[Segment]: + """Return the available segments.""" + return self._segments + + async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: + """Perform an area clean.""" + if self._attr_assumed_state: + self._attr_activity = VacuumActivity.CLEANING + self.async_write_ha_state() + if script := self._action_scripts.get(SERVICE_CLEAN_AREA): + await self.async_run_script( + script, + run_variables={"segment_ids": segment_ids}, + context=self._context, + ) + async def async_start(self) -> None: """Start or resume the cleaning task.""" if self._attr_assumed_state: diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 4b4ff818ffc2c9..dfab47d2f69474 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.4.5"] + "requirements": ["tesla-fleet-api==1.4.7"] } diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 2c00094b40b0d5..eb99d2bb2bd135 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -262,7 +262,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - device = DeviceInfo( identifiers={(DOMAIN, vin)}, manufacturer="Tesla", - configuration_url="https://teslemetry.com/console", + configuration_url=f"https://teslemetry.com/console/vehicle/{vin}", name=product["display_name"], model=vehicle.model, model_id=vin[3], @@ -324,7 +324,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - device = DeviceInfo( identifiers={(DOMAIN, str(site_id))}, manufacturer="Tesla", - configuration_url="https://teslemetry.com/console", + configuration_url=f"https://teslemetry.com/console/energy/{site_id}", name=product.get("site_name", "Energy Site"), serial_number=str(site_id), ) @@ -514,7 +514,7 @@ def async_setup_energy_device( *data.get("components_gateways", []), *data.get("components_batteries", []), ): - if part_name := component.get("part_name"): + if (part_name := component.get("part_name")) and part_name != "Unknown": models.add(part_name) if models: energysite.device["model"] = ", ".join(sorted(models)) diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index ca7b1c8335433a..c2397d30741d69 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], "quality_scale": "platinum", - "requirements": ["tesla-fleet-api==1.4.5", "teslemetry-stream==0.9.0"] + "requirements": ["tesla-fleet-api==1.4.7", "teslemetry-stream==0.9.0"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 312a5f03e74df9..53b259ea03aa00 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], "quality_scale": "silver", - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.4.5"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.4.7"] } diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index a00f7480ede3be..aa4d90c9e1a810 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.9.0", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==2.10.0", "pyroute2==0.7.5"], "single_config_entry": true, "zeroconf": ["_meshcop._udp.local."] } diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 2fa987782a9a0e..2b4335ea91469d 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -26,6 +26,7 @@ from .coordinator import ( TibberDataAPICoordinator, TibberDataCoordinator, + TibberFetchPriceCoordinator, TibberPriceCoordinator, ) from .services import async_setup_services @@ -44,6 +45,7 @@ class TibberRuntimeData: session: OAuth2Session data_api_coordinator: TibberDataAPICoordinator | None = field(default=None) data_coordinator: TibberDataCoordinator | None = field(default=None) + fetch_price_coordinator: TibberFetchPriceCoordinator | None = field(default=None) price_coordinator: TibberPriceCoordinator | None = field(default=None) _client: tibber.Tibber | None = None @@ -131,7 +133,11 @@ async def _close(event: Event) -> None: raise ConfigEntryNotReady("Fatal HTTP error from Tibber API") from err if tibber_connection.get_homes(only_active=True): - price_coordinator = TibberPriceCoordinator(hass, entry) + fetch_price_coordinator = TibberFetchPriceCoordinator(hass, entry) + await fetch_price_coordinator.async_config_entry_first_refresh() + entry.runtime_data.fetch_price_coordinator = fetch_price_coordinator + + price_coordinator = TibberPriceCoordinator(hass, entry, fetch_price_coordinator) await price_coordinator.async_config_entry_first_refresh() entry.runtime_data.price_coordinator = price_coordinator diff --git a/homeassistant/components/tibber/coordinator.py b/homeassistant/components/tibber/coordinator.py index 1110f81f621d8e..bfe3cdb145afd5 100644 --- a/homeassistant/components/tibber/coordinator.py +++ b/homeassistant/components/tibber/coordinator.py @@ -24,7 +24,7 @@ statistics_during_period, ) from homeassistant.const import UnitOfEnergy -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import EnergyConverter @@ -102,7 +102,7 @@ def __init__( config_entry: TibberConfigEntry, *, name: str, - update_interval: timedelta, + update_interval: timedelta | None = None, ) -> None: """Initialize the coordinator.""" super().__init__( @@ -278,21 +278,54 @@ async def _insert_statistics(self) -> None: class TibberPriceCoordinator(TibberCoordinator[dict[str, TibberHomeData]]): - """Handle Tibber price data and insert statistics.""" + """Handle Tibber price data.""" def __init__( self, hass: HomeAssistant, config_entry: TibberConfigEntry, + price_fetch_coordinator: TibberFetchPriceCoordinator, ) -> None: """Initialize the price coordinator.""" super().__init__( hass, config_entry, name=f"{DOMAIN} price", - update_interval=timedelta(minutes=1), ) - self._tomorrow_price_poll_threshold_seconds = random.uniform(0, 3600 * 10) + self._price_fetch_coordinator = price_fetch_coordinator + self._unsub_price_fetch_listener: CALLBACK_TYPE | None = None + + @callback + def _build_price_data(self) -> dict[str, TibberHomeData]: + """Build derived price data from the fetched Tibber homes.""" + return { + home_id: _build_home_data(home) + for home_id, home in (self._price_fetch_coordinator.data or {}).items() + } + + @callback + def _async_handle_price_fetch_update(self) -> None: + """Update derived price data when fetched prices change.""" + self.update_interval = self._time_until_next_15_minute() + self.async_set_updated_data(self._build_price_data()) + + @callback + def _schedule_refresh(self) -> None: + """Start listening to fetched price data when entities subscribe.""" + super()._schedule_refresh() + if self._unsub_price_fetch_listener is None: + self._unsub_price_fetch_listener = ( + self._price_fetch_coordinator.async_add_listener( + self._async_handle_price_fetch_update + ) + ) + + def _unschedule_refresh(self) -> None: + """Stop listening to fetched price data when unused.""" + super()._unschedule_refresh() + if self._unsub_price_fetch_listener is not None: + self._unsub_price_fetch_listener() + self._unsub_price_fetch_listener = None def _time_until_next_15_minute(self) -> timedelta: """Return time until the next 15-minute boundary (0, 15, 30, 45) in UTC.""" @@ -309,7 +342,30 @@ def _time_until_next_15_minute(self) -> timedelta: return next_run - now async def _async_update_data(self) -> dict[str, TibberHomeData]: - """Update data via API and return per-home data for sensors.""" + self.update_interval = self._time_until_next_15_minute() + return self._build_price_data() + + +class TibberFetchPriceCoordinator(TibberCoordinator[dict[str, tibber.TibberHome]]): + """Fetch Tibber price data from the API.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: TibberConfigEntry, + ) -> None: + """Initialize the price coordinator.""" + super().__init__( + hass, + config_entry, + name=f"{DOMAIN} price fetch", + ) + self._tomorrow_price_poll_threshold_seconds = random.uniform( + 3600 * 14, 3600 * 22 + ) + + async def _async_update_data(self) -> dict[str, tibber.TibberHome]: + """Fetch latest price data via API and return per-home data.""" tibber_connection = await self._async_get_client() active_homes = tibber_connection.get_homes(only_active=True) @@ -341,28 +397,31 @@ def _needs_update(home: tibber.TibberHome) -> bool: return True if _has_prices_tomorrow(home): return False - if (today_end - now).total_seconds() < ( - self._tomorrow_price_poll_threshold_seconds + if now >= today_start + timedelta( + seconds=self._tomorrow_price_poll_threshold_seconds ): return True return False - homes_to_update = [home for home in active_homes if _needs_update(home)] + self.update_interval = timedelta(seconds=random.uniform(60, 60 * 10)) try: - if homes_to_update: - await asyncio.gather( - *(home.update_info_and_price_info() for home in homes_to_update) + await asyncio.gather( + *( + home.update_info_and_price_info() + for home in active_homes + if _needs_update(home) ) - except tibber.RetryableHttpExceptionError as err: - raise UpdateFailed(f"Error communicating with API ({err.status})") from err - except tibber.FatalHttpExceptionError as err: - raise UpdateFailed(f"Error communicating with API ({err.status})") from err - - result = {home.home_id: _build_home_data(home) for home in active_homes} + ) + except tibber.exceptions.RateLimitExceededError as err: + raise UpdateFailed( + f"Rate limit exceeded, retry after {err.retry_after} seconds", + retry_after=err.retry_after, + ) from err + except tibber.exceptions.HttpExceptionError as err: + raise UpdateFailed(f"Error communicating with API ({err})") from err - self.update_interval = self._time_until_next_15_minute() - return result + return {home.home_id: home for home in active_homes} class TibberDataAPICoordinator(TibberCoordinator[dict[str, TibberDevice]]): diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 0ac0114a1c88ac..cd96d8000b0c5c 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -750,7 +750,7 @@ def __init__( ) -> None: """Initialize the sensor.""" super().__init__(coordinator=coordinator, tibber_home=tibber_home) - self._attr_available = False + self._price_data_available = False self._attr_native_unit_of_measurement = tibber_home.price_unit self._attr_extra_state_attributes = { "app_nickname": None, @@ -771,6 +771,11 @@ def __init__( self._device_name = self._home_name self._update_attributes() + @property + def available(self) -> bool: + """Return if the sensor is available.""" + return super().available and self._price_data_available + @callback def _handle_coordinator_update(self) -> None: self._update_attributes() @@ -784,7 +789,8 @@ def _update_attributes(self) -> None: (home_data := data.get(self._tibber_home.home_id)) is None or (current_price := home_data.get("current_price")) is None ): - self._attr_available = False + self._price_data_available = False + self._attr_native_value = None return self._attr_native_unit_of_measurement = home_data.get( @@ -805,7 +811,7 @@ def _update_attributes(self) -> None: self._attr_extra_state_attributes["estimated_annual_consumption"] = home_data[ "estimated_annual_consumption" ] - self._attr_available = True + self._price_data_available = True class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]): diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 85745aea8e427b..da4d0ee5975793 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -41,6 +41,7 @@ ATTR_FINISHES_AT = "finishes_at" ATTR_RESTORE = "restore" ATTR_FINISHED_AT = "finished_at" +ATTR_LAST_TRANSITION = "last_transition" CONF_DURATION = "duration" CONF_RESTORE = "restore" @@ -202,6 +203,7 @@ class Timer(collection.CollectionEntity, RestoreEntity): def __init__(self, config: ConfigType) -> None: """Initialize a timer.""" self._config: dict = config + self._last_transition: str | None = None self._state: str = STATUS_IDLE self._configured_duration = cv.time_period_str(config[CONF_DURATION]) self._running_duration: timedelta = self._configured_duration @@ -249,6 +251,7 @@ def extra_state_attributes(self) -> dict[str, Any]: attrs: dict[str, Any] = { ATTR_DURATION: _format_timedelta(self._running_duration), ATTR_EDITABLE: self.editable, + ATTR_LAST_TRANSITION: self._last_transition, } if self._end is not None: attrs[ATTR_FINISHES_AT] = self._end.isoformat() @@ -274,6 +277,7 @@ async def async_added_to_hass(self) -> None: # Begin restoring state self._state = state.state + self._last_transition = state.attributes.get(ATTR_LAST_TRANSITION) # Nothing more to do if the timer is idle if self._state == STATUS_IDLE: @@ -321,8 +325,7 @@ def async_start(self, duration: timedelta | None = None) -> None: self._end = start + self._remaining - self.async_write_ha_state() - self.hass.bus.async_fire(event, {ATTR_ENTITY_ID: self.entity_id}) + self._fire_event_and_write_state(event) self._listener = async_track_point_in_utc_time( self.hass, self._async_finished, self._end @@ -349,6 +352,8 @@ def async_change(self, duration: timedelta) -> None: self._listener() self._end += duration self._remaining = new_remaining + # We don't use _fire_event_and_write_state here because we don't want to + # update last_transition self.async_write_ha_state() self.hass.bus.async_fire(EVENT_TIMER_CHANGED, {ATTR_ENTITY_ID: self.entity_id}) self._listener = async_track_point_in_utc_time( @@ -366,8 +371,7 @@ def async_pause(self) -> None: self._remaining = self._end - dt_util.utcnow().replace(microsecond=0) self._state = STATUS_PAUSED self._end = None - self.async_write_ha_state() - self.hass.bus.async_fire(EVENT_TIMER_PAUSED, {ATTR_ENTITY_ID: self.entity_id}) + self._fire_event_and_write_state(EVENT_TIMER_PAUSED) @callback def async_cancel(self) -> None: @@ -382,10 +386,7 @@ def async_cancel(self) -> None: self._end = None self._remaining = None self._running_duration = self._configured_duration - self.async_write_ha_state() - self.hass.bus.async_fire( - EVENT_TIMER_CANCELLED, {ATTR_ENTITY_ID: self.entity_id} - ) + self._fire_event_and_write_state(EVENT_TIMER_CANCELLED) @callback def async_finish(self) -> None: @@ -403,10 +404,8 @@ def async_finish(self) -> None: self._end = None self._remaining = None self._running_duration = self._configured_duration - self.async_write_ha_state() - self.hass.bus.async_fire( - EVENT_TIMER_FINISHED, - {ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()}, + self._fire_event_and_write_state( + EVENT_TIMER_FINISHED, extra_attrs={ATTR_FINISHED_AT: end.isoformat()} ) @callback @@ -421,10 +420,8 @@ def _async_finished(self, time: datetime) -> None: self._end = None self._remaining = None self._running_duration = self._configured_duration - self.async_write_ha_state() - self.hass.bus.async_fire( - EVENT_TIMER_FINISHED, - {ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()}, + self._fire_event_and_write_state( + EVENT_TIMER_FINISHED, extra_attrs={ATTR_FINISHED_AT: end.isoformat()} ) async def async_update_config(self, config: ConfigType) -> None: @@ -435,3 +432,14 @@ async def async_update_config(self, config: ConfigType) -> None: self._running_duration = self._configured_duration self._restore = config.get(CONF_RESTORE, DEFAULT_RESTORE) self.async_write_ha_state() + + def _fire_event_and_write_state( + self, event: str, *, extra_attrs: dict[str, Any] | None = None + ) -> None: + """Fire the event and write state.""" + self._last_transition = event.partition(".")[2] + self.async_write_ha_state() + event_data = {ATTR_ENTITY_ID: self.entity_id} + if extra_attrs: + event_data.update(extra_attrs) + self.hass.bus.async_fire(event, event_data) diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index 0bfda6442601a6..c1f4b918c231f7 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -63,6 +63,16 @@ "finishes_at": { "name": "Finishes at" }, + "last_transition": { + "name": "Last transition", + "state": { + "cancelled": "Cancelled", + "finished": "Finished", + "paused": "Paused", + "restarted": "Restarted", + "started": "Started" + } + }, "remaining": { "name": "Remaining" }, diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 87e408b2a5849e..921913413b73ec 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -102,13 +102,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: TractiveConfigEntry) -> tractive = TractiveClient(hass, client, creds["user_id"], entry) + trackables = [] try: - trackable_objects = await client.trackable_objects() - trackables = await asyncio.gather( - *(_generate_trackables(client, item) for item in trackable_objects) - ) + for obj in await client.trackable_objects(): + # To avoid hitting Tractive API rate limits, we add a small + # delay between requests to fetch trackable details. + await asyncio.sleep(2) + trackables.append(await _generate_trackables(client, obj)) except aiotractive.exceptions.TractiveError as error: + await client.close() raise ConfigEntryNotReady from error + except ConfigEntryNotReady: + await client.close() + raise # When the pet defined in Tractive has no tracker linked we get None as `trackable`. # So we have to remove None values from trackables list. @@ -164,12 +170,11 @@ async def _generate_trackables( tracker = client.tracker(trackable_data["device_id"]) trackable_pet = client.trackable_object(trackable_data["_id"]) - tracker_details, hw_info, pos_report, health_overview = await asyncio.gather( - tracker.details(), - tracker.hw_info(), - tracker.pos_report(), - trackable_pet.health_overview(), - ) + # Sequential fetching to prevent HTTP 429 Rate Limits + tracker_details = await tracker.details() + hw_info = await tracker.hw_info() + pos_report = await tracker.pos_report() + health_overview = await trackable_pet.health_overview() if not tracker_details.get("_id"): raise ConfigEntryNotReady( diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 0e6b195eb27798..0518f732218857 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -59,17 +59,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool device.function, device.status_range, ) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, device.id)}, - manufacturer="Tuya", - name=device.name, - # Note: the model is overridden via entity.device_info property - # when the entity is created. If no entities are generated, it will - # stay as unsupported - model=f"{device.product_name} (unsupported)", - model_id=device.product_id, - ) + # Register quirk, and add device to the device registry + listener.async_register_device(device_registry, device) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # If the device does not register any entities, the device does not need to subscribe diff --git a/homeassistant/components/tuya/coordinator.py b/homeassistant/components/tuya/coordinator.py index bb9edb6b5b13f3..31cc158a3ea9a9 100644 --- a/homeassistant/components/tuya/coordinator.py +++ b/homeassistant/components/tuya/coordinator.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any -from tuya_device_handlers.devices import register_tuya_quirks +from tuya_device_handlers.devices import TUYA_QUIRKS_REGISTRY, register_tuya_quirks from tuya_sharing import ( CustomerDevice, Manager, @@ -121,15 +121,39 @@ def add_device(self, device: CustomerDevice) -> None: device.function, device.status_range, ) - self.hass.add_job(self.async_add_device, device.id) + self.hass.add_job(self.async_add_device, device) @callback - def async_add_device(self, device_id: str) -> None: + def async_add_device(self, device: CustomerDevice) -> None: """Add device to Home Assistant.""" # Ensure the (stale) device isn't present in the device registry - self.async_remove_device(device_id) + self.async_remove_device(device.id) - async_dispatcher_send(self.hass, TUYA_DISCOVERY_NEW, [device_id]) + # Register quirk, and add device to the device registry + device_registry = dr.async_get(self.hass) + self.async_register_device(device_registry, device) + + # Notify platforms of new device so entities can be created + async_dispatcher_send(self.hass, TUYA_DISCOVERY_NEW, [device.id]) + + @callback + def async_register_device( + self, device_registry: dr.DeviceRegistry, device: CustomerDevice + ) -> None: + """Register device with Home Assistant.""" + TUYA_QUIRKS_REGISTRY.initialise_device_quirk(device) + + device_registry.async_get_or_create( + config_entry_id=self._entry.entry_id, + identifiers={(DOMAIN, device.id)}, + manufacturer="Tuya", + name=device.name, + # Note: the model is overridden via entity.device_info property + # when the entity is created. If no entities are generated, it will + # stay as unsupported + model=f"{device.product_name} (unsupported)", + model_id=device.product_id, + ) def remove_device(self, device_id: str) -> None: """Handle device removal event.""" diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json index f100bf6753cadb..9b25aae6e8253d 100644 --- a/homeassistant/components/twentemilieu/manifest.json +++ b/homeassistant/components/twentemilieu/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["twentemilieu"], - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["twentemilieu==3.0.0"] } diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 15b0fbafead2ec..042da2b61c1558 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -6,7 +6,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -51,6 +51,19 @@ async def async_setup_entry( hub = config_entry.runtime_data = UnifiHub(hass, config_entry, api) await hub.initialize() + # Pre-populate device registry with UniFi devices before forwarding to + # platforms. Without this, device_tracker entities may be registered as + # disabled-by-default if their platform is set up before another platform + # creates the device entry, since their default enabled state depends on + # the matching device existing in the registry. Other fields are populated + # when entities with DeviceInfo are added by their respective platforms. + device_registry = dr.async_get(hass) + for device in hub.api.devices.values(): + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, device.mac)}, + ) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) hub.async_update_device_registry() hub.entity_loader.load_entities() diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index c8c6a54f9fe035..1450f0638d7c54 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -1,8 +1,8 @@ """Config flow for UniFi Network integration. Provides user initiated configuration flow. -Discovery of UniFi Network instances hosted on UDM and UDM Pro devices -through SSDP. Reauthentication when issue with credentials are reported. +Discovery of UniFi Network instances through unifi_discovery. +Reauthentication when issue with credentials are reported. Configuration of options through options flow. """ @@ -13,7 +13,6 @@ import socket from types import MappingProxyType from typing import Any -from urllib.parse import urlparse from aiounifi.interfaces.sites import Sites import voluptuous as vol @@ -35,11 +34,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.service_info.ssdp import ( - ATTR_UPNP_MODEL_DESCRIPTION, - ATTR_UPNP_SERIAL, - SsdpServiceInfo, -) +from homeassistant.helpers.typing import DiscoveryInfoType from . import UnifiConfigEntry from .const import ( @@ -66,12 +61,6 @@ DEFAULT_VERIFY_SSL = False -MODEL_PORTS = { - "UniFi Dream Machine": 443, - "UniFi Dream Machine Pro": 443, -} - - class UnifiFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a UniFi Network config flow.""" @@ -144,7 +133,10 @@ async def async_step_user( vol.Optional( CONF_PORT, default=self.config.get(CONF_PORT, DEFAULT_PORT) ): int, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, + vol.Optional( + CONF_VERIFY_SSL, + default=self.config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), + ): bool, } return self.async_show_form( @@ -215,33 +207,34 @@ async def async_step_reauth( return await self.async_step_user() - async def async_step_ssdp( - self, discovery_info: SsdpServiceInfo + async def async_step_integration_discovery( + self, discovery_info: DiscoveryInfoType ) -> ConfigFlowResult: - """Handle a discovered UniFi device.""" - parsed_url = urlparse(discovery_info.ssdp_location) - model_description = discovery_info.upnp[ATTR_UPNP_MODEL_DESCRIPTION] - mac_address = format_mac(discovery_info.upnp[ATTR_UPNP_SERIAL]) + """Handle discovery via unifi_discovery.""" + source_ip = discovery_info["source_ip"] + if not source_ip: + return self.async_abort(reason="cannot_connect") + mac_address = format_mac(discovery_info["hw_addr"]) + direct_connect_domain = discovery_info.get("direct_connect_domain") + host = direct_connect_domain or source_ip self.config = { - CONF_HOST: parsed_url.hostname, + CONF_HOST: host, + CONF_VERIFY_SSL: bool(direct_connect_domain), } - self._async_abort_entries_match({CONF_HOST: self.config[CONF_HOST]}) + for entry in self._async_current_entries(include_ignore=False): + if entry.data.get(CONF_HOST) in (source_ip, direct_connect_domain): + return self.async_abort(reason="already_configured") await self.async_set_unique_id(mac_address) self._abort_if_unique_id_configured(updates=self.config) self.context["title_placeholders"] = { - CONF_HOST: self.config[CONF_HOST], + CONF_HOST: host, CONF_SITE_ID: DEFAULT_SITE_ID, } - - if (port := MODEL_PORTS.get(model_description)) is not None: - self.config[CONF_PORT] = port - self.context["configuration_url"] = ( - f"https://{self.config[CONF_HOST]}:{port}" - ) + self.context["configuration_url"] = f"https://{host}" return await self.async_step_user() diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 4e7d4f41b20000..86d97ad7647d0b 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -3,28 +3,11 @@ "name": "UniFi Network", "codeowners": ["@Kane610"], "config_flow": true, + "dependencies": ["unifi_discovery"], "documentation": "https://www.home-assistant.io/integrations/unifi", "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiounifi"], - "quality_scale": "bronze", - "requirements": ["aiounifi==90"], - "ssdp": [ - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine" - }, - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine Pro" - }, - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine SE" - }, - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine Pro Max" - } - ] + "quality_scale": "silver", + "requirements": ["aiounifi==90"] } diff --git a/homeassistant/components/unifi/quality_scale.yaml b/homeassistant/components/unifi/quality_scale.yaml index 9636dce2df4050..637c1caad3bbab 100644 --- a/homeassistant/components/unifi/quality_scale.yaml +++ b/homeassistant/components/unifi/quality_scale.yaml @@ -22,8 +22,8 @@ rules: # Silver action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done @@ -35,14 +35,16 @@ rules: devices: done diagnostics: done discovery-update-info: done - discovery: done - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo + discovery: + status: exempt + comment: Discovery is handled via unifi_discovery dependency using SOURCE_INTEGRATION_DISCOVERY. + docs-data-update: done + docs-examples: done + docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done docs-troubleshooting: done - docs-use-cases: todo + docs-use-cases: done dynamic-devices: done entity-category: done entity-device-class: done diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 1715dc87d94b02..80c70ef736aa3e 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "UniFi Network site is already configured", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "configuration_updated": "Configuration updated", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, diff --git a/homeassistant/components/unifi_access/manifest.json b/homeassistant/components/unifi_access/manifest.json index e58627afee6a0d..07095919d5c19c 100644 --- a/homeassistant/components/unifi_access/manifest.json +++ b/homeassistant/components/unifi_access/manifest.json @@ -8,6 +8,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["unifi_access_api"], - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["py-unifi-access==1.3.0"] } diff --git a/homeassistant/components/unifi_discovery/const.py b/homeassistant/components/unifi_discovery/const.py index 1f6d2610461faa..ebd5f2866d77a6 100644 --- a/homeassistant/components/unifi_discovery/const.py +++ b/homeassistant/components/unifi_discovery/const.py @@ -8,6 +8,7 @@ # This must be static (not a runtime registry) because consumers may not be loaded # when initial discovery runs — the same pattern DHCP/SSDP use with manifest matchers. CONSUMER_MAPPING: dict[UnifiService, str] = { - UnifiService.Protect: "unifiprotect", UnifiService.Access: "unifi_access", + UnifiService.Network: "unifi", + UnifiService.Protect: "unifiprotect", } diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 2d6273dc551d3e..aae41d2052fca7 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -158,6 +158,13 @@ async def _async_setup_entry( await async_migrate_data(hass, entry, data_service.api, bootstrap) data_service.async_setup() + # Prime the public bootstrap. The devices websocket subscription was already + # registered in async_setup() per library docs (subscribe first, then prime). + try: + await data_service.api.update_public() + except Exception: # noqa: BLE001 + _LOGGER.debug("Public API bootstrap update failed", exc_info=True) + # Load PTZ patrol data before loading platforms await data_service.async_load_ptz_patrols() diff --git a/homeassistant/components/unifiprotect/alarm_control_panel.py b/homeassistant/components/unifiprotect/alarm_control_panel.py new file mode 100644 index 00000000000000..c1ecb6e23ea2cd --- /dev/null +++ b/homeassistant/components/unifiprotect/alarm_control_panel.py @@ -0,0 +1,118 @@ +"""Support for UniFi Protect NVR alarm control panel.""" + +from __future__ import annotations + +from uiprotect.data import NVR, NvrArmModeStatus +from uiprotect.exceptions import GlobalAlarmManagerError + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, + AlarmControlPanelState, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .data import ProtectData, ProtectDeviceType, UFPConfigEntry +from .entity import ProtectNVREntity +from .utils import async_ufp_instance_command + +PARALLEL_UPDATES = 0 + +_UIPROTECT_TO_HA: dict[NvrArmModeStatus, AlarmControlPanelState] = { + NvrArmModeStatus.DISABLED: AlarmControlPanelState.DISARMED, + NvrArmModeStatus.ARMING: AlarmControlPanelState.ARMING, + NvrArmModeStatus.ARMED: AlarmControlPanelState.ARMED_AWAY, + NvrArmModeStatus.BREACH: AlarmControlPanelState.TRIGGERED, + NvrArmModeStatus.UNKNOWN: AlarmControlPanelState.DISARMED, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: UFPConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up alarm control panel for UniFi Protect NVR.""" + data = entry.runtime_data + api = data.api + + # No public Integration API available (e.g. older NVR firmware that does + # not expose the Alarm Manager endpoint, or no API key configured). + # Skip entity creation entirely; we cannot represent the alarm state. + if not api.has_public_bootstrap: + return + + # ``arm_mode`` is ``None`` on NVR firmware that predates the Alarm Manager + # public API. Skip entity creation so the user does not see a permanently + # unavailable entity. + if api.public_bootstrap.arm_mode is None: + return + + nvr = api.bootstrap.nvr + async_add_entities([ProtectNVRAlarmControlPanel(data, device=nvr)]) + + +class ProtectNVRAlarmControlPanel(ProtectNVREntity, AlarmControlPanelEntity): + """UniFi Protect NVR Alarm Control Panel.""" + + _attr_code_arm_required = False + _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY + _attr_translation_key = "nvr_alarm" + _state_attrs = ("_attr_available", "_attr_alarm_state") + + def __init__(self, data: ProtectData, device: NVR) -> None: + """Initialize the alarm control panel.""" + super().__init__(data, device, EntityDescription(key="alarm")) + self._refresh_alarm_state() + + @callback + def _refresh_alarm_state(self) -> None: + """Update _attr_alarm_state from the public bootstrap cache.""" + api = self.data.api + arm_mode = api.public_bootstrap.arm_mode if api.has_public_bootstrap else None + if arm_mode is None: + # No alarm data available — force unavailable regardless of the + # private WebSocket state managed by the base class. + self._attr_available = False + self._attr_alarm_state = None + return + # Do NOT set _attr_available = True here. Availability when alarm data + # is present is determined exclusively by the base class via + # last_update_success (private WebSocket health). Only force it to + # False as an additional condition when alarm data is missing. + # Fall back to DISARMED for unknown future status values rather than + # rendering the entity as ``unknown``. + self._attr_alarm_state = _UIPROTECT_TO_HA.get( + arm_mode.status, AlarmControlPanelState.DISARMED + ) + + @callback + def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: + super()._async_update_device_from_protect(device) + self._refresh_alarm_state() + + @async_ufp_instance_command + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Send disarm command.""" + try: + await self.data.api.disable_arm_alarm_public() + except GlobalAlarmManagerError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="global_alarm_manager", + ) from err + + @async_ufp_instance_command + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command (arms with the currently selected profile).""" + try: + await self.data.api.enable_arm_alarm_public() + except GlobalAlarmManagerError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="global_alarm_manager", + ) from err diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index a1b1510ee140d8..4fc9d85793e8aa 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -52,6 +52,10 @@ DEVICES_WITH_ENTITIES = DEVICES_THAT_ADOPT | {ModelType.NVR} DEVICES_FOR_SUBSCRIBE = DEVICES_WITH_ENTITIES | {ModelType.EVENT} +# Public API devices WebSocket: NVR (for arm_mode updates), Relay +# (for relay output state updates), and Siren (for siren active-state updates). +DEVICES_WS_SUBSCRIBED_MODELS = {ModelType.NVR, ModelType.RELAY, ModelType.SIREN} + MIN_REQUIRED_PROTECT_V = Version("6.0.0") OUTDATED_LOG_MESSAGE = ( "You are running v%s of UniFi Protect. Minimum required version is v%s. Please" @@ -61,6 +65,7 @@ TYPE_EMPTY_VALUE = "" PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CAMERA, @@ -71,6 +76,7 @@ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SIREN, Platform.SWITCH, Platform.TEXT, ] diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 8d3abdc768c3df..76672ce59be273 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -19,6 +19,8 @@ ModelType, ProtectAdoptableDeviceModel, PTZPatrol, + Relay, + Siren, WSSubscriptionMessage, ) from uiprotect.exceptions import ClientError, NotAuthorized @@ -83,6 +85,12 @@ def __init__( self._subscriptions: defaultdict[ str, set[Callable[[ProtectDeviceType], None]] ] = defaultdict(set) + self._relay_subscriptions: defaultdict[str, set[Callable[[Relay], None]]] = ( + defaultdict(set) + ) + self._siren_subscriptions: defaultdict[str, set[Callable[[Siren], None]]] = ( + defaultdict(set) + ) self._pending_camera_ids: set[str] = set() self._unsubs: list[CALLBACK_TYPE] = [] self._auth_failures = 0 @@ -164,8 +172,48 @@ def async_setup(self) -> None: async_track_time_interval( self._hass, self._async_poll, self._update_interval ), + # Subscribe to the public devices websocket unconditionally so that + # it is active before update_public() primes the cache. + # Per library docs: subscribe first, then call update_public(). + api.subscribe_devices_websocket( + self._async_process_public_devices_ws_message + ), ] + @callback + def _async_process_public_devices_ws_message( + self, message: WSSubscriptionMessage + ) -> None: + """Process a message from the public devices websocket. + + The API client pre-filters messages to the model types listed in + DEVICES_WS_SUBSCRIBED_MODELS. NVR messages signal the private NVR so + alarm entities pick up the new arm state. Relay messages dispatch + the merged Relay object by mac so relay-output entities can refresh. + Siren messages dispatch the merged Siren object by mac so siren entities + can refresh. + """ + new_obj = message.new_obj + if new_obj is None: + # Delete event: notify subscribers so entities can be marked unavailable. + old_obj = message.old_obj + if old_obj is not None and old_obj.model is ModelType.SIREN: + self._async_signal_siren_update(cast(Siren, old_obj)) + return + if new_obj.model is ModelType.NVR: + self._async_signal_device_update(self.api.bootstrap.nvr) + return + if new_obj.model is ModelType.RELAY: + relay = cast(Relay, new_obj) + mac = relay.mac + if subscriptions := self._relay_subscriptions.get(mac): + _LOGGER.debug("Updating relay: %s (%s)", relay.name, mac) + for update_callback in subscriptions: + update_callback(relay) + return + if new_obj.model is ModelType.SIREN: + self._async_signal_siren_update(cast(Siren, new_obj)) + @callback def _async_websocket_state_changed(self, state: WebsocketState) -> None: """Handle a change in the websocket state.""" @@ -337,6 +385,13 @@ def _async_process_updates(self) -> None: self._async_signal_device_update(self.api.bootstrap.nvr) for device in self.get_by_types(DEVICES_THAT_ADOPT): self._async_signal_device_update(device) + if self.api.has_public_bootstrap: + for relay in self.api.public_bootstrap.relays.values(): + if subscriptions := self._relay_subscriptions.get(relay.mac): + for subscription_callback in subscriptions: + subscription_callback(relay) + for siren in self.api.public_bootstrap.sirens.values(): + self._async_signal_siren_update(siren) @callback def _async_poll(self, now: datetime) -> None: @@ -365,6 +420,40 @@ def _async_unsubscribe( if not self._subscriptions[mac]: del self._subscriptions[mac] + @callback + def async_subscribe_relay( + self, mac: str, update_callback: Callable[[Relay], None] + ) -> CALLBACK_TYPE: + """Add a callback subscriber for relay updates.""" + self._relay_subscriptions[mac].add(update_callback) + return partial(self._async_unsubscribe_relay, mac, update_callback) + + @callback + def _async_unsubscribe_relay( + self, mac: str, update_callback: Callable[[Relay], None] + ) -> None: + """Remove a relay callback subscriber.""" + self._relay_subscriptions[mac].remove(update_callback) + if not self._relay_subscriptions[mac]: + del self._relay_subscriptions[mac] + + @callback + def async_subscribe_siren( + self, mac: str, update_callback: Callable[[Siren], None] + ) -> CALLBACK_TYPE: + """Add a callback subscriber for siren updates.""" + self._siren_subscriptions[mac].add(update_callback) + return partial(self._async_unsubscribe_siren, mac, update_callback) + + @callback + def _async_unsubscribe_siren( + self, mac: str, update_callback: Callable[[Siren], None] + ) -> None: + """Remove a siren callback subscriber.""" + self._siren_subscriptions[mac].remove(update_callback) + if not self._siren_subscriptions[mac]: + del self._siren_subscriptions[mac] + @callback def _async_signal_device_update(self, device: ProtectDeviceType) -> None: """Call the callbacks for a device_id.""" @@ -375,6 +464,16 @@ def _async_signal_device_update(self, device: ProtectDeviceType) -> None: for update_callback in subscriptions: update_callback(device) + @callback + def _async_signal_siren_update(self, siren: Siren) -> None: + """Call the callbacks for a siren mac.""" + mac = siren.mac + if not (subscriptions := self._siren_subscriptions.get(mac)): + return + _LOGGER.debug("Updating siren: %s (%s)", siren.name, mac) + for update_callback in subscriptions: + update_callback(siren) + @callback def async_ufp_instance_for_config_entry_ids( diff --git a/homeassistant/components/unifiprotect/icons.json b/homeassistant/components/unifiprotect/icons.json index f66a963da4e39b..9c0b7a2732e2fe 100644 --- a/homeassistant/components/unifiprotect/icons.json +++ b/homeassistant/components/unifiprotect/icons.json @@ -1,5 +1,10 @@ { "entity": { + "alarm_control_panel": { + "nvr_alarm": { + "default": "mdi:shield-home" + } + }, "binary_sensor": { "alarm_sound_detection": { "default": "mdi:alarm-bell" diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 8a79f2e0d54871..215381a8fe15a1 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==10.3.1"] + "requirements": ["uiprotect==10.4.1"] } diff --git a/homeassistant/components/unifiprotect/siren.py b/homeassistant/components/unifiprotect/siren.py new file mode 100644 index 00000000000000..1bf278f47d0656 --- /dev/null +++ b/homeassistant/components/unifiprotect/siren.py @@ -0,0 +1,223 @@ +"""UniFi Protect siren platform (Public API).""" + +from __future__ import annotations + +from datetime import datetime +import logging +from typing import Any + +from uiprotect.data import Siren, SirenDuration + +from homeassistant.components.siren import ( + ATTR_DURATION, + ATTR_VOLUME_LEVEL, + SirenEntity, + SirenEntityFeature, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_call_later +from homeassistant.util import dt as dt_util + +from .const import DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN +from .data import ProtectData, UFPConfigEntry +from .utils import async_ufp_instance_command + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + +# Durations (in seconds) accepted by the UniFi Protect siren public API. +VALID_DURATIONS: tuple[int, ...] = tuple(d.value for d in SirenDuration) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: UFPConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up UniFi Protect siren entities from a config entry.""" + data: ProtectData = entry.runtime_data + + api = data.api + if not api.has_public_bootstrap: + return + + async_add_entities( + ProtectSiren(data, siren) for siren in api.public_bootstrap.sirens.values() + ) + + +class ProtectSiren(SirenEntity): + """Siren entity for a UniFi Protect siren device (Public API).""" + + _attr_has_entity_name = True + _attr_attribution = DEFAULT_ATTRIBUTION + _attr_name = None # device name is the entity name + _attr_should_poll = False + _attr_supported_features = ( + SirenEntityFeature.TURN_ON + | SirenEntityFeature.TURN_OFF + | SirenEntityFeature.DURATION + | SirenEntityFeature.VOLUME_SET + ) + + def __init__(self, data: ProtectData, siren: Siren) -> None: + """Initialise the siren entity.""" + self.data = data + self._siren_id = siren.id + self._attr_unique_id = f"{siren.mac}_siren" + nvr = data.api.bootstrap.nvr + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, siren.mac)}, + identifiers={(DOMAIN, siren.mac)}, + manufacturer=DEFAULT_BRAND, + name=siren.name, + model="Siren", + via_device=(DOMAIN, nvr.mac), + ) + self._siren_mac = siren.mac + self._cancel_scheduled_off: CALLBACK_TYPE | None = None + self._update_from_siren(siren) + + @property + def _siren(self) -> Siren | None: + api = self.data.api + if not api.has_public_bootstrap: + return None + return api.public_bootstrap.sirens.get(self._siren_id) + + @callback + def _update_from_siren(self, siren: Siren) -> None: + """Refresh cached attributes from the siren object.""" + self._attr_available = self.data.last_update_success + self._attr_is_on = siren.is_active + + @callback + def _async_updated(self, siren: Siren) -> None: + """Handle a public devices WS update for this siren.""" + # Cancel any previous auto-off timer before scheduling a new one. + self._cancel_off_timer() + + prev_state = (self._attr_available, self._attr_is_on) + + # If the siren is no longer in the public bootstrap (delete event), + # mark it unavailable and off, then bail out. + if self._siren is None: + self._attr_available = False + self._attr_is_on = False + if (self._attr_available, self._attr_is_on) != prev_state: + self.async_write_ha_state() + return + + self._update_from_siren(siren) + + # The server never emits a WS message when a timed run expires, so we + # must schedule our own callback. Both activated_at and duration are + # in milliseconds in the WS payload. + status = siren.siren_status + if ( + status.is_active + and status.activated_at is not None + and status.duration is not None + ): + delay = ( + status.activated_at + status.duration + ) / 1000 - dt_util.utcnow().timestamp() + if delay <= 0: + # Already expired (e.g. stale bootstrap after a reconnect): + # override the is_active=True from the payload immediately so + # we never briefly write ON into the state machine. + self._attr_is_on = False + else: + self._cancel_scheduled_off = async_call_later( + self.hass, delay, self._async_scheduled_off + ) + + if (self._attr_available, self._attr_is_on) != prev_state: + self.async_write_ha_state() + + @callback + def _async_scheduled_off(self, _now: datetime) -> None: + """Timed siren run has expired — push state to OFF.""" + self._cancel_scheduled_off = None + self._attr_is_on = False + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Subscribe to public WS updates dispatched by ProtectData.""" + await super().async_added_to_hass() + self.async_on_remove( + self.data.async_subscribe_siren(self._siren_mac, self._async_updated) + ) + self.async_on_remove(self._cancel_off_timer) + # Schedule the auto-off timer for any already-active timed run so + # a siren that was running when HA started does not remain stuck ON. + if (siren := self._siren) is not None: + self._async_updated(siren) + + @callback + def _cancel_off_timer(self) -> None: + """Cancel the pending auto-off timer if any.""" + if self._cancel_scheduled_off is not None: + self._cancel_scheduled_off() + self._cancel_scheduled_off = None + + @async_ufp_instance_command + async def async_turn_on(self, **kwargs: Any) -> None: + """Activate the siren, optionally for a given duration and/or volume.""" + if (siren := self._siren) is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="siren_not_available", + ) + + duration: int | None = kwargs.get(ATTR_DURATION) + volume_level: float | None = kwargs.get(ATTR_VOLUME_LEVEL) + + # Validate duration first (synchronous) before making any API calls. + norm_duration: SirenDuration | None = None + if duration is not None: + try: + norm_duration = SirenDuration(duration) + except ValueError: + valid = ", ".join(str(v) for v in VALID_DURATIONS) + _LOGGER.debug( + "Rejected invalid siren duration %ds for %s (valid: %s s)", + duration, + siren.name, + valid, + ) + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="siren_invalid_duration", + translation_placeholders={ + "duration": str(duration), + "valid": valid, + }, + ) from None + + # Set volume if requested (separate API call). + if volume_level is not None: + # HA passes volume as 0.0–1.0; UFP expects 0–100. + await siren.set_volume(round(volume_level * 100)) + + await siren.play(duration=norm_duration) + + @async_ufp_instance_command + async def async_turn_off(self, **kwargs: Any) -> None: + """Stop the siren.""" + if (siren := self._siren) is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="siren_not_available", + ) + await siren.stop() + # The server does not emit a WS event after a manual stop, so we set + # the state optimistically and cancel any pending auto-off timer. + self._cancel_off_timer() + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 69ac175ae39aa1..44165067ed77f1 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -90,6 +90,11 @@ } }, "entity": { + "alarm_control_panel": { + "nvr_alarm": { + "name": "Alarm Manager" + } + }, "binary_sensor": { "alarm_sound_detection": { "name": "Alarm sound detection" @@ -636,6 +641,9 @@ "privacy_mode": { "name": "Privacy mode" }, + "relay_output": { + "name": "Output {output_name}" + }, "ssh_enabled": { "name": "[%key:component::unifiprotect::entity::binary_sensor::ssh_enabled::name%]" }, @@ -668,6 +676,9 @@ "device_not_found": { "message": "No device found for device id: {device_id}" }, + "global_alarm_manager": { + "message": "The alarm manager on this UniFi Protect NVR is set to Global mode and cannot be controlled locally." + }, "no_users_found": { "message": "No users found, please check Protect permissions" }, @@ -689,9 +700,18 @@ "ptz_preset_not_found": { "message": "Could not find PTZ preset with name {preset_name} on camera {camera_name}" }, + "relay_not_available": { + "message": "Relay is no longer available" + }, "service_error": { "message": "Error calling UniFi Protect service, check the logs for more details" }, + "siren_invalid_duration": { + "message": "Invalid siren duration {duration}s. Valid values are: {valid} seconds" + }, + "siren_not_available": { + "message": "Siren is no longer available" + }, "stream_error": { "message": "Error playing audio, check the logs for more details" } diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index a5b399ef8c41a7..137d4a9e41f719 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -5,22 +5,29 @@ from collections.abc import Sequence from dataclasses import dataclass from functools import partial -from typing import Any +from typing import Any, Literal from uiprotect.data import ( Camera, ModelType, ProtectAdoptableDeviceModel, + PublicRelayOutput, RecordingMode, + Relay, + RelayOutputState, VideoMode, ) from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from .const import DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN from .data import ProtectData, ProtectDeviceType, UFPConfigEntry from .entity import ( BaseProtectEntity, @@ -421,6 +428,12 @@ async def _set_highfps(obj: Camera, value: bool) -> None: ), ) +_RELAY_STATE_MAP: dict[RelayOutputState, bool] = { + RelayOutputState.ON: True, + RelayOutputState.OFF: False, + RelayOutputState.OFF_OTP: False, +} + _MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = { ModelType.CAMERA: CAMERA_SWITCHES, ModelType.LIGHT: LIGHT_SWITCHES, @@ -562,3 +575,119 @@ def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: for switch in NVR_SWITCHES ) async_add_entities(entities) + + # Public API: relay output switches. Only available when the public + # bootstrap has been primed (requires API key + supported NVR firmware). + api = data.api + if api.has_public_bootstrap: + relay_entities: list[ProtectRelayOutputSwitch] = [ + ProtectRelayOutputSwitch(data, relay, output) + for relay in api.public_bootstrap.relays.values() + for output in relay.outputs + ] + if relay_entities: + async_add_entities(relay_entities) + + +class ProtectRelayOutputSwitch(SwitchEntity): + """Switch entity for a single relay output channel (Public API). + + The relay device and its outputs are exposed through UniFi Protect's + public integration API and cached in :attr:`ProtectApiClient.public_bootstrap`. + Each output channel is represented as its own switch entity; turning it + on/off goes through :meth:`Relay.activate_output`. + """ + + _attr_has_entity_name = True + _attr_attribution = DEFAULT_ATTRIBUTION + _attr_should_poll = False + _attr_translation_key = "relay_output" + + def __init__( + self, + data: ProtectData, + relay: Relay, + output: PublicRelayOutput, + ) -> None: + """Initialize the relay output switch.""" + self.data = data + self._relay_id = relay.id + self._relay_mac = relay.mac + self._output_id = output.id + self._attr_unique_id = f"{relay.mac}_relay_output_{output.id}" + self._attr_translation_placeholders = { + "output_name": output.name or str(output.id), + } + nvr = data.api.bootstrap.nvr + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, relay.mac)}, + identifiers={(DOMAIN, relay.mac)}, + manufacturer=DEFAULT_BRAND, + name=relay.name, + model="Relay", + via_device=(DOMAIN, nvr.mac), + ) + self._update_from_relay(relay) + + @property + def _relay(self) -> Relay | None: + api = self.data.api + if not api.has_public_bootstrap: + return None + return api.public_bootstrap.relays.get(self._relay_id) + + @callback + def _update_from_relay(self, relay: Relay) -> None: + """Refresh ``_attr_is_on`` and availability from the cached relay.""" + output = relay.get_output(self._output_id) + if output is None: + self._attr_available = False + self._attr_is_on = None + return + self._attr_available = self.data.last_update_success + self._attr_is_on = ( + _RELAY_STATE_MAP.get(output.state) if output.state is not None else None + ) + + @callback + def _async_updated(self, relay: Relay) -> None: + """Handle a public relay WS update for this relay.""" + prev_state = (self._attr_available, self._attr_is_on) + self._update_from_relay(relay) + # If the relay was removed from the bootstrap while the WS update + # was in flight, mark unavailable so commands cannot succeed. + if self._relay is None: + self._attr_available = False + if (self._attr_available, self._attr_is_on) != prev_state: + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Subscribe to public relay WS updates dispatched by ProtectData.""" + await super().async_added_to_hass() + self.async_on_remove( + self.data.async_subscribe_relay(self._relay_mac, self._async_updated) + ) + + async def _activate_output(self, state: Literal["on", "off"]) -> None: + """Send activate_output to the relay, raising if unavailable.""" + if (relay := self._relay) is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="relay_not_available", + ) + if relay.get_output(self._output_id) is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="relay_not_available", + ) + await relay.activate_output(self._output_id, state=state) + + @async_ufp_instance_command + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the relay output on.""" + await self._activate_output("on") + + @async_ufp_instance_command + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the relay output off.""" + await self._activate_output("off") diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index b520e83a592874..4bcc0ae29124c4 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -37,13 +37,13 @@ CONF_ALL_UPDATES, CONF_OVERRIDE_CHOST, DEVICES_FOR_SUBSCRIBE, + DEVICES_WS_SUBSCRIBED_MODELS, DOMAIN, ModelType, ) if TYPE_CHECKING: from .data import UFPConfigEntry - from .entity import BaseProtectEntity @callback @@ -126,6 +126,7 @@ def async_create_api_client( session=session, public_api_session=public_api_session, subscribed_models=DEVICES_FOR_SUBSCRIBE, + devices_ws_subscribed_models=DEVICES_WS_SUBSCRIBED_MODELS, override_connection_host=entry.options.get(CONF_OVERRIDE_CHOST, False), ignore_stats=not entry.options.get(CONF_ALL_UPDATES, False), ignore_unadopted=False, @@ -145,7 +146,7 @@ def get_camera_base_name(channel: CameraChannel) -> str: return camera_name -def async_ufp_instance_command[_EntityT: "BaseProtectEntity", **_P]( +def async_ufp_instance_command[_EntityT, **_P]( func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: """Decorate UniFi Protect entity instance commands to handle exceptions. diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 2abedf2b7dae30..25152a433822d9 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -35,6 +35,7 @@ from .const import DOMAIN from .models import SerialDevice, USBDevice +from .serial_proxy_stub import register_serialx_transport from .utils import ( scan_serial_ports, usb_device_from_path, @@ -187,6 +188,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websocket_api.async_register_command(hass, websocket_usb_scan) websocket_api.async_register_command(hass, websocket_usb_list_serial_ports) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, register_serialx_transport()) + return True @@ -552,7 +555,19 @@ async def websocket_usb_list_serial_ports( except OSError as err: connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err)) return - connection.send_result( - msg["id"], - [dataclasses.asdict(port) for port in ports], - ) + + result = [] + for port in ports: + entry = dataclasses.asdict(port) + + if isinstance(port, USBDevice): + matchers = async_get_usb_matchers_for_device(hass, port) + entry["matching_integrations"] = list( + dict.fromkeys(matcher["domain"] for matcher in matchers) + ) + else: + entry["matching_integrations"] = [] + + result.append(entry) + + connection.send_result(msg["id"], result) diff --git a/homeassistant/components/usb/models.py b/homeassistant/components/usb/models.py index 149b86627eae90..75764f75cf9151 100644 --- a/homeassistant/components/usb/models.py +++ b/homeassistant/components/usb/models.py @@ -13,6 +13,8 @@ class SerialDevice: serial_number: str | None manufacturer: str | None description: str | None + interface_description: str | None = None + interface_num: int | None = None @dataclass(slots=True, frozen=True, kw_only=True) @@ -21,3 +23,6 @@ class USBDevice(SerialDevice): vid: str pid: str + + # bcdDevice descriptor, often the firmware revision + bcd_device: int | None = None diff --git a/homeassistant/components/usb/serial_proxy_stub.py b/homeassistant/components/usb/serial_proxy_stub.py new file mode 100644 index 00000000000000..24b6c33f524415 --- /dev/null +++ b/homeassistant/components/usb/serial_proxy_stub.py @@ -0,0 +1,43 @@ +"""ESPHome serial proxy URI handler stub for serialx.""" + +from __future__ import annotations + +from collections.abc import Callable + +from serialx import register_uri_handler +from serialx.platforms.serial_esphome import ESPHomeSerial, ESPHomeSerialTransport + +from homeassistant.core import Event, callback +from homeassistant.exceptions import ConfigEntryNotReady + + +class HassESPHomeSerialStub(ESPHomeSerial): + """ESPHomeSerial that throws `ConfigEntryNotReady` until ESPHome itself loads.""" + + async def _async_open(self) -> None: + """Open a connection.""" + raise ConfigEntryNotReady("ESPHome has not loaded yet") + + +class HassESPHomeSerialStubTransport(ESPHomeSerialTransport): + """Transport variant that constructs `HassESPHomeSerialStub`.""" + + transport_name = "esphome-hass" + _serial_cls = HassESPHomeSerialStub + + +def register_serialx_transport() -> Callable[[Event], None]: + """Register the stub URI handler.""" + unregister = register_uri_handler( + scheme="esphome-hass://", + unique_scheme="esphome-hass-usb://", + sync_cls=HassESPHomeSerialStub, + async_transport_cls=HassESPHomeSerialStubTransport, + weight=-1, # We want the ESPHome integration transport to take precedence + ) + + @callback + def _unregister(event: Event) -> None: + unregister() + + return _unregister diff --git a/homeassistant/components/usb/utils.py b/homeassistant/components/usb/utils.py index 661b5d562de02c..f8be47db8d28ae 100644 --- a/homeassistant/components/usb/utils.py +++ b/homeassistant/components/usb/utils.py @@ -26,6 +26,9 @@ def usb_device_from_port(port: SerialPortInfo) -> USBDevice: serial_number=port.serial_number, manufacturer=port.manufacturer, description=port.product, + bcd_device=port.bcd_device, + interface_description=port.interface_description, + interface_num=port.interface_num, ) @@ -36,6 +39,8 @@ def serial_device_from_port(port: SerialPortInfo) -> SerialDevice: serial_number=port.serial_number, manufacturer=port.manufacturer, description=port.product, + interface_description=port.interface_description, + interface_num=port.interface_num, ) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 8e98b61c838faf..73184020d317d3 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -22,9 +22,13 @@ SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers import ( + config_validation as cv, + issue_registry as ir, + service as service_helper, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform @@ -109,12 +113,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_clean_spot", [VacuumEntityFeature.CLEAN_SPOT], ) - component.async_register_entity_service( + component.async_register_batched_entity_service( SERVICE_CLEAN_AREA, { vol.Required("cleaning_area_id"): vol.All(cv.ensure_list, [str]), }, - "async_internal_clean_area", + StateVacuumEntity.async_internal_clean_area, [VacuumEntityFeature.CLEAN_AREA], ) component.async_register_entity_service( @@ -422,44 +426,67 @@ def last_seen_segments(self) -> list[Segment] | None: return [Segment(**segment) for segment in last_seen_segments] @final + @staticmethod async def async_internal_clean_area( - self, cleaning_area_id: list[str], **kwargs: Any + entities: list[StateVacuumEntity], call: ServiceCall ) -> None: """Perform an area clean. - Calls async_clean_segments. + Calls async_clean_segments for each entity. """ - if self.registry_entry is None: - raise RuntimeError( - "Cannot perform area clean, registry entry is not set for" - f" {self.entity_id}" + data = dict(call.data) + cleaning_area_id: list[str] = data.pop("cleaning_area_id") + + entity_data: list[tuple[StateVacuumEntity, dict[str, Any]]] = [] + handled_areas: set[str] = set() + for entity in entities: + if entity.registry_entry is None: + raise RuntimeError( + "Cannot perform area clean, registry entry is not set for" + f" {entity.entity_id}" + ) + + options: Mapping[str, Any] = entity.registry_entry.options.get(DOMAIN, {}) + area_mapping: dict[str, list[str]] | None = options.get("area_mapping") + + if area_mapping is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="area_mapping_not_configured", + translation_placeholders={"entity_id": entity.entity_id}, + ) + + # We use a dict to preserve the order of segments. + segment_ids: dict[str, None] = {} + for area_id in cleaning_area_id: + if (segments := area_mapping.get(area_id)) is None: + continue + handled_areas.add(area_id) + for segment_id in segments: + segment_ids[segment_id] = None + + if not segment_ids: + _LOGGER.debug( + "No segments found for cleaning_area_id %s on vacuum %s", + cleaning_area_id, + entity.entity_id, + ) + continue + + entity_data.append((entity, {"segment_ids": list(segment_ids), **data})) + + if entity_data: + await service_helper.async_handle_entity_calls( + "async_clean_segments", entity_data, context=call.context ) - options: Mapping[str, Any] = self.registry_entry.options.get(DOMAIN, {}) - area_mapping: dict[str, list[str]] | None = options.get("area_mapping") - - if area_mapping is None: + unhandled_areas = set(cleaning_area_id) - handled_areas + if unhandled_areas: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="area_mapping_not_configured", - translation_placeholders={"entity_id": self.entity_id}, - ) - - # We use a dict to preserve the order of segments. - segment_ids: dict[str, None] = {} - for area_id in cleaning_area_id: - for segment_id in area_mapping.get(area_id, []): - segment_ids[segment_id] = None - - if not segment_ids: - _LOGGER.debug( - "No segments found for cleaning_area_id %s on vacuum %s", - cleaning_area_id, - self.entity_id, + translation_key="areas_not_mapped", + translation_placeholders={"areas": ", ".join(sorted(unhandled_areas))}, ) - return - - await self.async_clean_segments(list(segment_ids), **kwargs) def clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: """Perform an area clean.""" diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index dc75659ec3a54b..b64986b294e6ed 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -102,6 +102,9 @@ "exceptions": { "area_mapping_not_configured": { "message": "Area mapping is not configured for `{entity_id}`. Configure the segment-to-area mapping before using this action." + }, + "areas_not_mapped": { + "message": "The following areas are not mapped to any segments of targeted vacuums: {areas}" } }, "issues": { diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 334dab34cea739..685768f96a0fe2 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -97,13 +97,17 @@ def __init__(self, node: OpeningDevice, config_entry_id: str) -> None: self._attr_device_class = CoverDeviceClass.SHUTTER @property - def current_cover_position(self) -> int: + def current_cover_position(self) -> int | None: """Return the current position of the cover.""" + if not self.node.position.known: + return None return 100 - self.node.position.position_percent @property - def is_closed(self) -> bool: + def is_closed(self) -> bool | None: """Return if the cover is closed.""" + if not self.node.position.known: + return None return self.node.position.closed @property @@ -168,22 +172,29 @@ def __init__( self.part = part @property - def current_cover_position(self) -> int: - """Return the current position of the cover.""" + def _part_position(self) -> Position: + """Return the pyvlx Position for this part of the shutter.""" if self.part == VeluxDualRollerPart.UPPER: - return 100 - self.node.position_upper_curtain.position_percent + return self.node.position_upper_curtain if self.part == VeluxDualRollerPart.LOWER: - return 100 - self.node.position_lower_curtain.position_percent - return 100 - self.node.position.position_percent + return self.node.position_lower_curtain + return self.node.position + + @property + def current_cover_position(self) -> int | None: + """Return the current position of the cover.""" + position = self._part_position + if not position.known: + return None + return 100 - position.position_percent @property - def is_closed(self) -> bool: + def is_closed(self) -> bool | None: """Return if the cover is closed.""" - if self.part == VeluxDualRollerPart.UPPER: - return self.node.position_upper_curtain.closed - if self.part == VeluxDualRollerPart.LOWER: - return self.node.position_lower_curtain.closed - return self.node.position.closed + position = self._part_position + if not position.known: + return None + return position.closed @wrap_pyvlx_call_exceptions async def async_close_cover(self, **kwargs: Any) -> None: @@ -227,6 +238,8 @@ def __init__(self, node: Blind, config_entry_id: str) -> None: @property def current_cover_tilt_position(self) -> int | None: """Return the current tilt position of the cover.""" + if not self.node.orientation.known: + return None return 100 - self.node.orientation.position_percent @wrap_pyvlx_call_exceptions diff --git a/homeassistant/components/velux/entity.py b/homeassistant/components/velux/entity.py index a43eba6cb7b3e0..3da1d1038d156f 100644 --- a/homeassistant/components/velux/entity.py +++ b/homeassistant/components/velux/entity.py @@ -56,7 +56,6 @@ def __init__(self, node: Node, config_entry_id: str) -> None: self.node = node unique_id = node.serial_number or f"{config_entry_id}_{node.node_id}" self._attr_unique_id = unique_id - self.unsubscribe = None self._attr_device_info = DeviceInfo( identifiers={ diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 4bdd8b58f9ab55..d2ee577f34c27c 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.59.0"] + "requirements": ["PyViCare==2.60.1"] } diff --git a/homeassistant/components/victron_gx/manifest.json b/homeassistant/components/victron_gx/manifest.json index 57ced23996efe5..c78fa8cd29eafd 100644 --- a/homeassistant/components/victron_gx/manifest.json +++ b/homeassistant/components/victron_gx/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/victron_gx", "integration_type": "hub", "iot_class": "local_push", - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["victron-mqtt==2026.4.17"], "ssdp": [ { diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index 2573864330d8a5..98955512460a31 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -24,7 +24,6 @@ PARALLEL_UPDATES = 0 NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] -UPTIME_DEVIATION = 60 @dataclass(frozen=True, kw_only=True) @@ -38,24 +37,6 @@ class VodafoneStationEntityDescription(SensorEntityDescription): is_suitable: Callable[[dict], bool] = lambda val: True -def _calculate_uptime( - coordinator: VodafoneStationRouter, - last_value: str | datetime | float | None, - key: str, -) -> datetime: - """Calculate device uptime.""" - - delta_uptime = coordinator.api.convert_uptime(coordinator.data.sensors[key]) - - if ( - not isinstance(last_value, datetime) - or abs((delta_uptime - last_value).total_seconds()) > UPTIME_DEVIATION - ): - return delta_uptime - - return last_value - - def _line_connection( coordinator: VodafoneStationRouter, last_value: str | datetime | float | None, @@ -135,10 +116,11 @@ def _line_connection( ), VodafoneStationEntityDescription( key="sys_uptime", - translation_key="sys_uptime", - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, entity_category=EntityCategory.DIAGNOSTIC, - value=_calculate_uptime, + value=lambda coordinator, last_value, key: coordinator.api.convert_uptime( + coordinator.data.sensors[key] + ), ), VodafoneStationEntityDescription( key="sys_cpu_usage", diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index 5a32f7ecc47999..16186a36173e83 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -113,9 +113,6 @@ "sys_reboot_cause": { "name": "Reboot cause" }, - "sys_uptime": { - "name": "Uptime" - }, "up_stream": { "name": "WAN upload rate" } diff --git a/homeassistant/components/wake_on_lan/switch.py b/homeassistant/components/wake_on_lan/switch.py index 16df34c1d1b268..d07221495bddd1 100644 --- a/homeassistant/components/wake_on_lan/switch.py +++ b/homeassistant/components/wake_on_lan/switch.py @@ -125,6 +125,12 @@ def turn_on(self, **kwargs: Any) -> None: self._state = True self.schedule_update_ha_state() + async def async_will_remove_from_hass(self) -> None: + """Clean up script when removing from Home Assistant.""" + if self._off_script is not None: + await self._off_script.async_stop() + self._off_script.async_unload() + def turn_off(self, **kwargs: Any) -> None: """Turn the device off if an off action is present.""" if self._off_script is not None: diff --git a/homeassistant/components/waterfurnace/__init__.py b/homeassistant/components/waterfurnace/__init__.py index 03bc7727963675..e2f874a4e9888a 100644 --- a/homeassistant/components/waterfurnace/__init__.py +++ b/homeassistant/components/waterfurnace/__init__.py @@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] CONFIG_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/waterfurnace/climate.py b/homeassistant/components/waterfurnace/climate.py new file mode 100644 index 00000000000000..e765dd130d8306 --- /dev/null +++ b/homeassistant/components/waterfurnace/climate.py @@ -0,0 +1,212 @@ +"""Support for WaterFurnace climate entity.""" + +from __future__ import annotations + +from typing import Any + +from waterfurnace.waterfurnace import WFException + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WaterFurnaceConfigEntry +from .coordinator import WaterFurnaceCoordinator +from .entity import WaterFurnaceEntity + +PARALLEL_UPDATES = 0 + +# Maps ActiveSettings.mode string to HVACMode +ACTIVE_MODE_TO_HVAC: dict[str, HVACMode] = { + "Off": HVACMode.OFF, + "Auto": HVACMode.HEAT_COOL, + "Cool": HVACMode.COOL, + "Heat": HVACMode.HEAT, + "E-Heat": HVACMode.HEAT, +} + +# Maps HVACMode to library's integer mode +HVAC_TO_WF_MODE: dict[HVACMode, int] = { + HVACMode.OFF: 0, + HVACMode.HEAT_COOL: 1, + HVACMode.COOL: 2, + HVACMode.HEAT: 3, +} + +# Maps WFReading.mode string to HVACAction +FURNACE_MODE_TO_ACTION: dict[str, HVACAction] = { + "Standby": HVACAction.IDLE, + "Fan Only": HVACAction.FAN, + "Cooling 1": HVACAction.COOLING, + "Cooling 2": HVACAction.COOLING, + "Reheat": HVACAction.HEATING, + "Heating 1": HVACAction.HEATING, + "Heating 2": HVACAction.HEATING, + "E-Heat": HVACAction.HEATING, + "Aux Heat": HVACAction.HEATING, + "Lockout": HVACAction.OFF, +} + +# Library temperature limits (Fahrenheit) +HEATING_MIN = 40 +HEATING_MAX = 80 +COOLING_MIN = 60 +COOLING_MAX = 90 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WaterFurnaceConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up WaterFurnace climate from a config entry.""" + async_add_entities( + WaterFurnaceClimate(device_data.realtime) + for device_data in config_entry.runtime_data.values() + ) + + +class WaterFurnaceClimate(WaterFurnaceEntity, ClimateEntity): + """Climate entity for WaterFurnace geothermal systems.""" + + _attr_name = None + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TARGET_HUMIDITY + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL] + _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _attr_min_humidity = 15 + _attr_max_humidity = 95 + + def __init__(self, coordinator: WaterFurnaceCoordinator) -> None: + """Initialize the climate entity.""" + super().__init__(coordinator) + self._attr_unique_id = coordinator.unit + + @property + def min_temp(self) -> float: + """Return the minimum temperature based on current mode.""" + if self.hvac_mode == HVACMode.COOL: + return COOLING_MIN + return HEATING_MIN + + @property + def max_temp(self) -> float: + """Return the maximum temperature based on current mode.""" + if self.hvac_mode == HVACMode.HEAT: + return HEATING_MAX + return COOLING_MAX + + @property + def current_temperature(self) -> float | None: + """Return the current room temperature.""" + return self.coordinator.data.tstatroomtemp + + @property + def current_humidity(self) -> float | None: + """Return the current humidity.""" + return self.coordinator.data.tstatrelativehumidity + + @property + def hvac_mode(self) -> HVACMode | None: + """Return the current HVAC mode.""" + return ACTIVE_MODE_TO_HVAC.get(self.coordinator.data.activesettings.mode) + + @property + def hvac_action(self) -> HVACAction | None: + """Return the current HVAC action.""" + return FURNACE_MODE_TO_ACTION.get(self.coordinator.data.mode) + + @property + def target_temperature(self) -> float | None: + """Return the target temperature (single setpoint modes).""" + if self.hvac_mode == HVACMode.COOL: + return self.coordinator.data.tstatcoolingsetpoint + if self.hvac_mode == HVACMode.HEAT: + return self.coordinator.data.tstatheatingsetpoint + return None + + @property + def target_temperature_high(self) -> float | None: + """Return the upper bound target temperature (Heat/Cool mode).""" + if self.hvac_mode == HVACMode.HEAT_COOL: + return self.coordinator.data.tstatcoolingsetpoint + return None + + @property + def target_temperature_low(self) -> float | None: + """Return the lower bound target temperature (Heat/Cool mode).""" + if self.hvac_mode == HVACMode.HEAT_COOL: + return self.coordinator.data.tstatheatingsetpoint + return None + + @property + def target_humidity(self) -> float | None: + """Return the target humidity.""" + return self.coordinator.data.tstathumidsetpoint + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the HVAC mode.""" + try: + await self.hass.async_add_executor_job( + self.coordinator.client.set_mode, HVAC_TO_WF_MODE[hvac_mode] + ) + except (WFException, ValueError) as err: + raise HomeAssistantError(f"Failed to set HVAC mode: {err}") from err + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set target temperature(s).""" + if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: + await self.async_set_hvac_mode(hvac_mode) + + low = kwargs.get(ATTR_TARGET_TEMP_LOW) + high = kwargs.get(ATTR_TARGET_TEMP_HIGH) + temp = kwargs.get(ATTR_TEMPERATURE) + current_mode = hvac_mode if hvac_mode is not None else self.hvac_mode + try: + await self.hass.async_add_executor_job( + self._set_temperature, low, high, temp, current_mode + ) + except (WFException, ValueError) as err: + raise HomeAssistantError(f"Failed to set temperature: {err}") from err + + def _set_temperature( + self, + low: float | None, + high: float | None, + temp: float | None, + current_mode: HVACMode | None, + ) -> None: + """Send temperature setpoint(s) to the device.""" + client = self.coordinator.client + if low is not None and high is not None: + client.set_heating_setpoint(low) + client.set_cooling_setpoint(high) + elif temp is not None: + if current_mode == HVACMode.COOL: + client.set_cooling_setpoint(temp) + else: + client.set_heating_setpoint(temp) + + async def async_set_humidity(self, humidity: int) -> None: + """Set the target humidity.""" + try: + await self.hass.async_add_executor_job( + self.coordinator.client.set_humidity, humidity + ) + except (WFException, ValueError) as err: + raise HomeAssistantError(f"Failed to set humidity: {err}") from err diff --git a/homeassistant/components/waterfurnace/coordinator.py b/homeassistant/components/waterfurnace/coordinator.py index bbea161457fe59..daac61974df695 100644 --- a/homeassistant/components/waterfurnace/coordinator.py +++ b/homeassistant/components/waterfurnace/coordinator.py @@ -90,7 +90,7 @@ def __init__( (device for device in client.devices if device.gwid == self.unit), None ) - async def _async_update_data(self): + async def _async_update_data(self) -> WFReading: """Fetch data from WaterFurnace API with built-in retry logic.""" try: return await self.hass.async_add_executor_job(self.client.read_with_retry) diff --git a/homeassistant/components/waterfurnace/entity.py b/homeassistant/components/waterfurnace/entity.py new file mode 100644 index 00000000000000..176351dca07623 --- /dev/null +++ b/homeassistant/components/waterfurnace/entity.py @@ -0,0 +1,33 @@ +"""Base entity for WaterFurnace.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import WaterFurnaceCoordinator + + +class WaterFurnaceEntity(CoordinatorEntity[WaterFurnaceCoordinator]): + """Base entity for WaterFurnace.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: WaterFurnaceCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.unit)}, + manufacturer="WaterFurnace", + name="WaterFurnace System", + ) + + if coordinator.device_metadata: + if coordinator.device_metadata.description: + device_info["model"] = coordinator.device_metadata.description + if coordinator.device_metadata.awlabctypedesc: + device_info["name"] = coordinator.device_metadata.awlabctypedesc + + self._attr_device_info = device_info diff --git a/homeassistant/components/waterfurnace/sensor.py b/homeassistant/components/waterfurnace/sensor.py index 9634baabb51a8e..be0a73ee09eb94 100644 --- a/homeassistant/components/waterfurnace/sensor.py +++ b/homeassistant/components/waterfurnace/sensor.py @@ -15,12 +15,11 @@ UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN, WaterFurnaceConfigEntry +from . import WaterFurnaceConfigEntry from .coordinator import WaterFurnaceCoordinator +from .entity import WaterFurnaceEntity SENSORS = [ SensorEntityDescription( @@ -162,12 +161,11 @@ async def async_setup_entry( ) -class WaterFurnaceSensor(CoordinatorEntity[WaterFurnaceCoordinator], SensorEntity): +class WaterFurnaceSensor(WaterFurnaceEntity, SensorEntity): """Implementing the Waterfurnace sensor.""" entity_description: SensorEntityDescription _attr_should_poll = False - _attr_has_entity_name = True def __init__( self, coordinator: WaterFurnaceCoordinator, description: SensorEntityDescription @@ -175,25 +173,8 @@ def __init__( """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.unit}_{description.key}" - device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.unit)}, - manufacturer="WaterFurnace", - name="WaterFurnace System", - ) - - if coordinator.device_metadata: - if coordinator.device_metadata.description: - # Eg. Series 7 - device_info["model"] = coordinator.device_metadata.description - if coordinator.device_metadata.awlabctypedesc: - # Eg. Series 7, 5 Ton - device_info["name"] = coordinator.device_metadata.awlabctypedesc - - self._attr_device_info = device_info - @property def native_value(self): """Return the native value of the sensor.""" diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index c24853eb52c74d..02eeb7188735ed 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -192,10 +192,17 @@ def __init__( ) def _handle_hub_update(self) -> None: - """Handle updates from hub coordinator.""" + """Handle updates from hub coordinator. + + Update data and notify listeners without rescheduling the refresh + interval, so an in-flight fast-polling cycle is not interrupted. + """ if self.hub_coordinator.data and self.device_id in self.hub_coordinator.data: - device = self.hub_coordinator.data[self.device_id] - self.async_set_updated_data(WattsVisionDeviceData(device=device)) + self.data = WattsVisionDeviceData( + device=self.hub_coordinator.data[self.device_id] + ) + self.last_update_success = True + self.async_update_listeners() async def _async_update_data(self) -> WattsVisionDeviceData: """Refresh specific device.""" diff --git a/homeassistant/components/waze_travel_time/__init__.py b/homeassistant/components/waze_travel_time/__init__.py index fa36af1a13c21e..83b7262bae580f 100644 --- a/homeassistant/components/waze_travel_time/__init__.py +++ b/homeassistant/components/waze_travel_time/__init__.py @@ -49,7 +49,7 @@ DOMAIN, METRIC_UNITS, REGIONS, - SEMAPHORE, + SEMAPHORE_KEY, UNITS, VEHICLE_TYPES, ) @@ -115,8 +115,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Load the saved entities.""" - if SEMAPHORE not in hass.data.setdefault(DOMAIN, {}): - hass.data.setdefault(DOMAIN, {})[SEMAPHORE] = asyncio.Semaphore(1) + if SEMAPHORE_KEY not in hass.data: + hass.data[SEMAPHORE_KEY] = asyncio.Semaphore(1) httpx_client = get_async_client(hass) client = WazeRouteCalculator( diff --git a/homeassistant/components/waze_travel_time/const.py b/homeassistant/components/waze_travel_time/const.py index fca801d054d7d5..590cd3d4b63943 100644 --- a/homeassistant/components/waze_travel_time/const.py +++ b/homeassistant/components/waze_travel_time/const.py @@ -2,8 +2,12 @@ from __future__ import annotations +import asyncio + +from homeassistant.util.hass_dict import HassKey + DOMAIN = "waze_travel_time" -SEMAPHORE = "semaphore" +SEMAPHORE_KEY: HassKey[asyncio.Semaphore] = HassKey(DOMAIN) CONF_BASE_COORDINATES = "base_coordinates" CONF_DESTINATION = "destination" diff --git a/homeassistant/components/waze_travel_time/coordinator.py b/homeassistant/components/waze_travel_time/coordinator.py index f300d443a2e52c..1a6d4f3ab0c2a3 100644 --- a/homeassistant/components/waze_travel_time/coordinator.py +++ b/homeassistant/components/waze_travel_time/coordinator.py @@ -1,5 +1,4 @@ """The Waze Travel Time data coordinator.""" -# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio from collections.abc import Collection @@ -32,7 +31,7 @@ CONF_VEHICLE_TYPE, DOMAIN, IMPERIAL_UNITS, - SEMAPHORE, + SEMAPHORE_KEY, ) from .helpers import base_coordinates_to_tuple @@ -197,7 +196,7 @@ async def _async_update_data(self) -> WazeTravelTimeData: self._origin, self._destination, ) - await self.hass.data[DOMAIN][SEMAPHORE].acquire() + await self.hass.data[SEMAPHORE_KEY].acquire() try: if origin_coordinates is None or destination_coordinates is None: raise UpdateFailed("Unable to determine origin or destination") @@ -258,6 +257,6 @@ async def _async_update_data(self) -> WazeTravelTimeData: await asyncio.sleep(SECONDS_BETWEEN_API_CALLS) finally: - self.hass.data[DOMAIN][SEMAPHORE].release() + self.hass.data[SEMAPHORE_KEY].release() return travel_data diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 5778658b1286c0..0b636367fac2ea 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -42,7 +42,7 @@ def async_register( webhook_id: str, handler: Callable[[HomeAssistant, str, Request], Awaitable[Response | None]], *, - local_only: bool | None = False, + local_only: bool = False, allowed_methods: Iterable[str] | None = None, ) -> None: """Register a webhook.""" @@ -60,6 +60,13 @@ def async_register( f"Unexpected method: {allowed_methods.difference(SUPPORTED_METHODS)}" ) + if not isinstance(local_only, bool): + # Previously it was valid to pass None for local_only and it was treated as False + # with a deprecation warning. In case a custom component is still passing None, + # we want to raise an error instead of silently treating it as False as the + # deprecation period has ended and the message was removed. + raise TypeError("local_only must be a boolean") + handlers[webhook_id] = { "domain": domain, "name": name, @@ -120,8 +127,11 @@ async def async_handle_webhook( handlers: dict[str, dict[str, Any]] = hass.data.setdefault(DOMAIN, {}) content_stream: StreamReader | MockStreamReader + received_from: str | None if isinstance(request, MockRequest): received_from = request.mock_source + if request.remote is not None: + received_from += f" ({request.remote})" content_stream = request.content method_name = request.method else: @@ -156,11 +166,11 @@ async def async_handle_webhook( ) return Response(status=HTTPStatus.METHOD_NOT_ALLOWED) - if webhook["local_only"] in (True, None) and not isinstance(request, MockRequest): - is_local = not is_cloud_connection(hass) + if webhook["local_only"]: + is_local = not (is_cloud_connection(hass) or request.remote is None) + if is_local: if TYPE_CHECKING: - assert isinstance(request, Request) assert request.remote is not None try: @@ -173,17 +183,7 @@ async def async_handle_webhook( if not is_local: _LOGGER.warning("Received remote request for local webhook %s", webhook_id) - if webhook["local_only"]: - return Response(status=HTTPStatus.OK) - if not webhook.get("warned_about_deprecation"): - webhook["warned_about_deprecation"] = True - _LOGGER.warning( - "Deprecation warning: " - "Webhook '%s' does not provide a value for local_only. " - "This webhook will be blocked after the 2023.11.0 release. " - "Use `local_only: false` to keep this webhook operating as-is", - webhook_id, - ) + return Response(status=HTTPStatus.OK) try: response: Response | None = await webhook["handler"](hass, webhook_id, request) @@ -273,6 +273,7 @@ async def websocket_handle( method=msg["method"], query_string=msg["query"], mock_source=f"{DOMAIN}/ws", + remote=connection.remote, ) response = await async_handle_webhook(hass, msg["webhook_id"], request) diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py index ae4844cd69a007..dfb16e16e95a17 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -78,6 +78,7 @@ async def async_handle_supervisor_unix_socket(self) -> ActiveConnection: self._send_message, self._request[KEY_HASS_USER], refresh_token=None, + remote=self._request.remote, ) await self._send_bytes_text(AUTH_OK_MESSAGE) self._logger.debug("Auth OK (unix socket)") @@ -111,6 +112,7 @@ async def async_handle(self, msg: JsonValueType) -> ActiveConnection: self._send_message, refresh_token.user, refresh_token, + remote=self._request.remote, ) conn.subscriptions["auth"] = ( self._hass.auth.async_register_revoke_token_callback( diff --git a/homeassistant/components/websocket_api/automation.py b/homeassistant/components/websocket_api/automation.py index 5efd6de792a584..b2ddd83f5fae3e 100644 --- a/homeassistant/components/websocket_api/automation.py +++ b/homeassistant/components/websocket_api/automation.py @@ -10,7 +10,7 @@ from homeassistant.const import CONF_TARGET from homeassistant.core import HomeAssistant -from homeassistant.helpers import target as target_helpers +from homeassistant.helpers import entity_registry as er, target as target_helpers from homeassistant.helpers.condition import ( async_get_all_descriptions as async_get_all_condition_descriptions, ) @@ -92,12 +92,14 @@ class _AutomationComponentLookupData: component: str filters: list[_EntityFilter] + primary_entities_only: bool = True @classmethod def create(cls, component: str, target_description: dict[str, Any]) -> Self: """Build automation component lookup data from target description.""" filters: list[_EntityFilter] = [] + primary_entities_only = target_description.get("primary_entities_only", True) entity_filters_config = target_description.get("entity", []) for entity_filter_config in entity_filters_config: entity_filter = _EntityFilter( @@ -110,14 +112,28 @@ def create(cls, component: str, target_description: dict[str, Any]) -> Self: ) filters.append(entity_filter) - return cls(component=component, filters=filters) + return cls( + component=component, + filters=filters, + primary_entities_only=primary_entities_only, + ) def matches( - self, hass: HomeAssistant, entity_id: str, domain: str, integration: str + self, + hass: HomeAssistant, + entity_id: str, + domain: str, + integration: str, + check_entity_category: bool, ) -> bool: """Return if entity matches ANY of the filters.""" if not self.filters: return True + + if check_entity_category and self.primary_entities_only: + entry = er.async_get(hass).async_get(entity_id) + if entry is None or entry.entity_category is not None: + return False return any( f.matches(hass, entity_id, domain, integration) for f in self.filters ) @@ -220,6 +236,7 @@ def _async_get_automation_components_for_target( hass, target_helpers.TargetSelection(target_selection), expand_group=expand_group, + primary_entities_only=False, ) _LOGGER.debug("Extracted entities for lookup: %s", extracted) @@ -230,6 +247,7 @@ def _async_get_automation_components_for_target( "Automation components per domain: %s", lookup_table.domain_components ) + check_entity_category = len(extracted.indirectly_referenced) > 0 entity_infos = entity_sources(hass) matched_components: set[str] = set() for entity_id in extracted.referenced | extracted.indirectly_referenced: @@ -253,7 +271,11 @@ def _async_get_automation_components_for_target( if component_data.component in matched_components: continue if component_data.matches( - hass, entity_id, entity_domain, entity_integration + hass, + entity_id, + entity_domain, + entity_integration, + check_entity_category, ): matched_components.add(component_data.component) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 85725361bcf28c..a9755217dd4fe3 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -1076,6 +1076,8 @@ async def handle_execute_script( translation_placeholders=err.translation_placeholders, ) return + finally: + script_obj.async_unload() connection.send_result( msg["id"], { diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index dad8ebe5686e24..dba40e2bcdd877 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -13,6 +13,7 @@ from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers.http import current_request +from homeassistant.helpers.redact import async_redact_data from homeassistant.util.json import JsonValueType from . import const, messages @@ -32,6 +33,15 @@ "current_connection", default=None ) +REDACT_KEYS = { + "access_token", + "password", + "api_password", + "refresh_token", + "token", + "auth_token", +} + type MessageHandler = Callable[[HomeAssistant, ActiveConnection, dict[str, Any]], None] type BinaryHandler = Callable[[HomeAssistant, ActiveConnection, bytes], None] @@ -47,6 +57,7 @@ class ActiveConnection: "last_id", "logger", "refresh_token_id", + "remote", "send_message", "subscriptions", "supported_features", @@ -60,6 +71,7 @@ def __init__( send_message: Callable[[bytes | str | dict[str, Any]], None], user: User, refresh_token: RefreshToken | None, + remote: str | None, ) -> None: """Initialize an active connection.""" self.logger = logger @@ -67,6 +79,7 @@ def __init__( self.send_message = send_message self.user = user self.refresh_token_id = refresh_token.id if refresh_token else None + self.remote = remote self.subscriptions: dict[Hashable, Callable[[], Any]] = {} self.last_id = 0 self.can_coalesce = False @@ -198,6 +211,7 @@ def async_handle(self, msg: JsonValueType) -> None: or type(type_) is not str ) ): + msg = async_redact_data(msg, REDACT_KEYS) self.logger.error("Received invalid command: %s", msg) id_ = msg.get("id") if isinstance(msg, dict) else 0 self.send_message( @@ -261,6 +275,7 @@ def _connect_closed_error( self, msg: bytes | str | dict[str, Any] | Callable[[], str] ) -> None: """Send a message when the connection is closed.""" + msg = async_redact_data(msg, REDACT_KEYS) self.logger.debug("Tried to send message %s on closed connection", msg) @callback @@ -274,6 +289,8 @@ def async_handle_exception(self, msg: dict[str, Any], err: Exception) -> None: translation_key: str | None = None translation_placeholders: dict[str, Any] | None = None + msg = async_redact_data(msg, REDACT_KEYS) + if isinstance(err, Unauthorized): code = const.ERR_UNAUTHORIZED err_message = "Unauthorized" diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index 955256792ccb6f..572f6307f9b0ed 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -99,9 +99,7 @@ def _on_hass_stop(_: Event) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_hass_stop) yaml_config = config.get(DOMAIN, {}) - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - hass.data[DOMAIN] = WemoData( + hass.data[DATA_WEMO] = WemoData( discovery_enabled=yaml_config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY), static_config=yaml_config.get(CONF_STATIC, []), registry=registry, diff --git a/homeassistant/components/window/conditions.yaml b/homeassistant/components/window/conditions.yaml index 327fb2826a8d90..8575532cec14bd 100644 --- a/homeassistant/components/window/conditions.yaml +++ b/homeassistant/components/window/conditions.yaml @@ -8,6 +8,11 @@ options: - all - any + for: + required: true + default: 00:00:00 + selector: + duration: is_closed: fields: *condition_common_fields diff --git a/homeassistant/components/window/strings.json b/homeassistant/components/window/strings.json index 8e0c38399f098e..64398ddafc0b87 100644 --- a/homeassistant/components/window/strings.json +++ b/homeassistant/components/window/strings.json @@ -1,6 +1,7 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "trigger_behavior_name": "Trigger when", "trigger_for_name": "For at least" }, @@ -10,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::window::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::window::common::condition_for_name%]" } }, "name": "Window is closed" @@ -19,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::window::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::window::common::condition_for_name%]" } }, "name": "Window is open" diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index f687979eef896d..79cfc295c4905f 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -44,6 +44,7 @@ ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, @@ -152,6 +153,12 @@ async def _refresh_token() -> str: for coordinator in withings_data.coordinators: await coordinator.async_config_entry_first_refresh() + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, str(entry.unique_id))}, + manufacturer="Withings", + ) entry.runtime_data = withings_data webhook_manager = WithingsWebhookManager(hass, entry) diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py index 5c548fdb260d8f..5781b85990e324 100644 --- a/homeassistant/components/withings/entity.py +++ b/homeassistant/components/withings/entity.py @@ -31,7 +31,6 @@ def __init__( self._attr_unique_id = f"withings_{coordinator.config_entry.unique_id}_{key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, str(coordinator.config_entry.unique_id))}, - manufacturer="Withings", ) diff --git a/homeassistant/components/wolflink/manifest.json b/homeassistant/components/wolflink/manifest.json index 0d8e6603602029..e85d20e3931e14 100644 --- a/homeassistant/components/wolflink/manifest.json +++ b/homeassistant/components/wolflink/manifest.json @@ -1,7 +1,7 @@ { "domain": "wolflink", "name": "Wolf SmartSet Service", - "codeowners": ["@adamkrol93", "@mtielen"], + "codeowners": ["@adamkrol93", "@EnjoyingM"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wolflink", "integration_type": "device", diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 84918b2bad45e4..09d58507668adf 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.94"] + "requirements": ["holidays==0.95"] } diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index 0dfaf950df9102..931de28dbae88b 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -27,12 +27,13 @@ CONF_SID, DEFAULT_DISCOVERY_RETRY, DOMAIN, - GATEWAYS_KEY, KEY_SETUP_LOCK, KEY_UNSUB_STOP, LISTENER_KEY, ) +type XiaomiAqaraConfigEntry = ConfigEntry[XiaomiGateway] + _LOGGER = logging.getLogger(__name__) GATEWAY_PLATFORMS = [ @@ -138,11 +139,10 @@ def remove_device_service(call: ServiceCall) -> None: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: XiaomiAqaraConfigEntry) -> bool: """Set up the xiaomi aqara components from a config entry.""" hass.data.setdefault(DOMAIN, {}) setup_lock = hass.data[DOMAIN].setdefault(KEY_SETUP_LOCK, asyncio.Lock()) - hass.data[DOMAIN].setdefault(GATEWAYS_KEY, {}) # Connect to Xiaomi Aqara Gateway xiaomi_gateway = await hass.async_add_executor_job( @@ -155,7 +155,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PORT], entry.data[CONF_PROTOCOL], ) - hass.data[DOMAIN][GATEWAYS_KEY][entry.entry_id] = xiaomi_gateway + entry.runtime_data = xiaomi_gateway async with setup_lock: if LISTENER_KEY not in hass.data[DOMAIN]: @@ -204,7 +204,9 @@ def stop_xiaomi(event): return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: XiaomiAqaraConfigEntry +) -> bool: """Unload a config entry.""" if config_entry.data[CONF_KEY] is not None: platforms = GATEWAY_PLATFORMS @@ -214,14 +216,11 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> unload_ok = await hass.config_entries.async_unload_platforms( config_entry, platforms ) - if unload_ok: - hass.data[DOMAIN][GATEWAYS_KEY].pop(config_entry.entry_id) if not hass.config_entries.async_loaded_entries(DOMAIN): # No gateways left, stop Xiaomi socket unsub_stop = hass.data[DOMAIN].pop(KEY_UNSUB_STOP) unsub_stop() - hass.data[DOMAIN].pop(GATEWAYS_KEY) _LOGGER.debug("Shutting down Xiaomi Gateway Listener") multicast = hass.data[DOMAIN].pop(LISTENER_KEY) multicast.stop_listen() @@ -229,25 +228,27 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok -def _add_gateway_to_schema(hass, schema): +def _add_gateway_to_schema(hass: HomeAssistant, schema: vol.Schema) -> vol.Schema: """Extend a voluptuous schema with a gateway validator.""" - def gateway(sid): + def gateway(sid: str) -> XiaomiGateway: """Convert sid to a gateway.""" sid = str(sid).replace(":", "").lower() - for gateway in hass.data[DOMAIN][GATEWAYS_KEY].values(): - if gateway.sid == sid: - return gateway + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + entry_gateway = entry.runtime_data + if entry_gateway.sid == sid: + return entry_gateway raise vol.Invalid(f"Unknown gateway sid {sid}") kwargs = {} - if (xiaomi_data := hass.data.get(DOMAIN)) is not None: - gateways = list(xiaomi_data[GATEWAYS_KEY].values()) + gateways = [ + entry.runtime_data for entry in hass.config_entries.async_loaded_entries(DOMAIN) + ] - # If the user has only 1 gateway, make it the default for services. - if len(gateways) == 1: - kwargs["default"] = gateways[0].sid + # If the user has only 1 gateway, make it the default for services. + if len(gateways) == 1: + kwargs["default"] = gateways[0].sid return schema.extend({vol.Required(ATTR_GW_MAC, **kwargs): gateway}) diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py index f62a69ef7f37fe..c16f91dad0bc21 100644 --- a/homeassistant/components/xiaomi_aqara/binary_sensor.py +++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -9,13 +9,12 @@ BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity -from .const import DOMAIN, GATEWAYS_KEY +from . import XiaomiAqaraConfigEntry from .entity import XiaomiDevice _LOGGER = logging.getLogger(__name__) @@ -34,14 +33,12 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" entities: list[XiaomiBinarySensor] = [] - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] + gateway = config_entry.runtime_data for entity in gateway.devices["binary_sensor"]: model = entity["model"] if model in ("motion", "sensor_motion", "sensor_motion.aq2"): @@ -149,7 +146,7 @@ def __init__( xiaomi_hub: XiaomiGateway, data_key: str, device_class: BinarySensorDeviceClass | None, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiSmokeSensor.""" self._data_key = data_key @@ -169,7 +166,7 @@ def __init__( self, device: dict[str, Any], xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiSmokeSensor.""" self._density = None @@ -226,7 +223,7 @@ def __init__( device: dict[str, Any], hass: HomeAssistant, xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiMotionSensor.""" self._hass = hass @@ -335,7 +332,7 @@ def __init__( self, device: dict[str, Any], xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiDoorSensor.""" self._open_since = 0 @@ -402,7 +399,7 @@ def __init__( self, device: dict[str, Any], xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiWaterLeakSensor.""" if "proto" not in device or int(device["proto"][0:1]) == 1: @@ -453,7 +450,7 @@ def __init__( self, device: dict[str, Any], xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiSmokeSensor.""" self._density = 0 @@ -510,7 +507,7 @@ def __init__( name: str, data_key: str, xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiVibration.""" self._last_action = None @@ -558,7 +555,7 @@ def __init__( data_key: str, hass: HomeAssistant, xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiButton.""" self._hass = hass @@ -625,7 +622,7 @@ def __init__( device: dict[str, Any], hass: HomeAssistant, xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the Xiaomi Cube.""" self._hass = hass diff --git a/homeassistant/components/xiaomi_aqara/const.py b/homeassistant/components/xiaomi_aqara/const.py index d137941d6141f5..6b410d0f566a56 100644 --- a/homeassistant/components/xiaomi_aqara/const.py +++ b/homeassistant/components/xiaomi_aqara/const.py @@ -2,7 +2,6 @@ DOMAIN = "xiaomi_aqara" -GATEWAYS_KEY = "gateways" LISTENER_KEY = "listener" KEY_UNSUB_STOP = "unsub_stop" KEY_SETUP_LOCK = "setup_lock" diff --git a/homeassistant/components/xiaomi_aqara/cover.py b/homeassistant/components/xiaomi_aqara/cover.py index c9e7086ddb81a5..676d946104faaf 100644 --- a/homeassistant/components/xiaomi_aqara/cover.py +++ b/homeassistant/components/xiaomi_aqara/cover.py @@ -5,11 +5,10 @@ from xiaomi_gateway import XiaomiGateway from homeassistant.components.cover import ATTR_POSITION, CoverEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, GATEWAYS_KEY +from . import XiaomiAqaraConfigEntry from .entity import XiaomiDevice ATTR_CURTAIN_LEVEL = "curtain_level" @@ -20,14 +19,12 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" entities = [] - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] + gateway = config_entry.runtime_data for device in gateway.devices["cover"]: model = device["model"] if model in ("curtain", "curtain.aq2", "curtain.hagl04"): @@ -50,7 +47,7 @@ def __init__( name: str, data_key: str, xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiGenericCover.""" self._data_key = data_key diff --git a/homeassistant/components/xiaomi_aqara/entity.py b/homeassistant/components/xiaomi_aqara/entity.py index 3f640b675166fa..de7d0dfa7dae9a 100644 --- a/homeassistant/components/xiaomi_aqara/entity.py +++ b/homeassistant/components/xiaomi_aqara/entity.py @@ -6,7 +6,6 @@ from xiaomi_gateway import XiaomiGateway -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, CONF_MAC from homeassistant.core import callback from homeassistant.helpers import device_registry as dr @@ -15,6 +14,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow +from . import XiaomiAqaraConfigEntry from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -32,7 +32,7 @@ def __init__( device: dict[str, Any], device_type: str, xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the Xiaomi device.""" self._is_available = True diff --git a/homeassistant/components/xiaomi_aqara/light.py b/homeassistant/components/xiaomi_aqara/light.py index 5f5eae118e3779..359929de185f52 100644 --- a/homeassistant/components/xiaomi_aqara/light.py +++ b/homeassistant/components/xiaomi_aqara/light.py @@ -13,12 +13,11 @@ ColorMode, LightEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util -from .const import DOMAIN, GATEWAYS_KEY +from . import XiaomiAqaraConfigEntry from .entity import XiaomiDevice _LOGGER = logging.getLogger(__name__) @@ -26,14 +25,12 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" entities = [] - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] + gateway = config_entry.runtime_data for device in gateway.devices["light"]: model = device["model"] if model in ("gateway", "gateway.v3"): @@ -54,7 +51,7 @@ def __init__( device: dict[str, Any], name: str, xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiGatewayLight.""" self._data_key = "rgb" diff --git a/homeassistant/components/xiaomi_aqara/lock.py b/homeassistant/components/xiaomi_aqara/lock.py index 221d3e24a259a0..39f7ef442ccec6 100644 --- a/homeassistant/components/xiaomi_aqara/lock.py +++ b/homeassistant/components/xiaomi_aqara/lock.py @@ -7,12 +7,11 @@ from xiaomi_gateway import XiaomiGateway from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later -from .const import DOMAIN, GATEWAYS_KEY +from . import XiaomiAqaraConfigEntry from .entity import XiaomiDevice FINGER_KEY = "fing_verified" @@ -27,13 +26,11 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] + gateway = config_entry.runtime_data async_add_entities( XiaomiAqaraLock(device, "Lock", gateway, config_entry) for device in gateway.devices["lock"] @@ -49,7 +46,7 @@ def __init__( device: dict[str, Any], name: str, xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiAqaraLock.""" self._attr_changed_by = "0" diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py index 8b3e1cbaa7d619..576178a9c773d9 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -13,7 +13,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, LIGHT_LUX, @@ -25,7 +24,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import BATTERY_MODELS, DOMAIN, GATEWAYS_KEY, POWER_MODELS +from . import XiaomiAqaraConfigEntry +from .const import BATTERY_MODELS, POWER_MODELS from .entity import XiaomiDevice _LOGGER = logging.getLogger(__name__) @@ -87,14 +87,12 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" entities: list[XiaomiSensor | XiaomiBatterySensor] = [] - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] + gateway = config_entry.runtime_data for device in gateway.devices["sensor"]: if device["model"] == "sensor_ht": entities.append( @@ -175,7 +173,7 @@ def __init__( name: str, data_key: str, xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiSensor.""" self._data_key = data_key diff --git a/homeassistant/components/xiaomi_aqara/switch.py b/homeassistant/components/xiaomi_aqara/switch.py index 8d69aa5c25d478..ff1232db898fe0 100644 --- a/homeassistant/components/xiaomi_aqara/switch.py +++ b/homeassistant/components/xiaomi_aqara/switch.py @@ -6,11 +6,10 @@ from xiaomi_gateway import XiaomiGateway from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, GATEWAYS_KEY +from . import XiaomiAqaraConfigEntry from .entity import XiaomiDevice _LOGGER = logging.getLogger(__name__) @@ -30,14 +29,12 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" entities = [] - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] + gateway = config_entry.runtime_data for device in gateway.devices["switch"]: model = device["model"] if model == "plug": @@ -147,7 +144,7 @@ def __init__( data_key: str, supports_power_consumption: bool, xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiPlug.""" self._data_key = data_key diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index c602785e9edc90..dc9f359f8cd33a 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -1,5 +1,4 @@ """Support for Xiaomi Yeelight WiFi color bulb.""" -# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations @@ -38,9 +37,7 @@ CONF_NIGHTLIGHT_SWITCH_TYPE, CONF_SAVE_ON_CHANGE, CONF_TRANSITION, - DATA_CONFIG_ENTRIES, - DATA_CUSTOM_EFFECTS, - DATA_DEVICE, + DATA_CUSTOM_EFFECTS_KEY, DEFAULT_MODE_MUSIC, DEFAULT_NAME, DEFAULT_NIGHTLIGHT_SWITCH, @@ -57,6 +54,8 @@ from .device import YeelightDevice, async_format_id from .scanner import YeelightScanner +type YeelightConfigEntry = ConfigEntry[YeelightDevice] + _LOGGER = logging.getLogger(__name__) @@ -117,10 +116,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Yeelight bulbs.""" conf = config.get(DOMAIN, {}) - hass.data[DOMAIN] = { - DATA_CUSTOM_EFFECTS: conf.get(CONF_CUSTOM_EFFECTS, {}), - DATA_CONFIG_ENTRIES: {}, - } + hass.data[DATA_CUSTOM_EFFECTS_KEY] = conf.get(CONF_CUSTOM_EFFECTS, []) # Make sure the scanner is always started in case we are # going to retry via ConfigEntryNotReady and the bulb has changed # ip @@ -142,13 +138,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _async_initialize( hass: HomeAssistant, - entry: ConfigEntry, + entry: YeelightConfigEntry, device: YeelightDevice, ) -> None: """Initialize a Yeelight device.""" - entry_data = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][entry.entry_id] = {} await device.async_setup() - entry_data[DATA_DEVICE] = device + entry.runtime_data = device if ( device.capabilities @@ -161,7 +156,9 @@ async def _async_initialize( @callback -def _async_normalize_config_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +def _async_normalize_config_entry( + hass: HomeAssistant, entry: YeelightConfigEntry +) -> None: """Move options from data for imported entries. Initialize options with default values for other entries. @@ -204,7 +201,7 @@ def _async_normalize_config_entry(hass: HomeAssistant, entry: ConfigEntry) -> No ) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: YeelightConfigEntry) -> bool: """Set up Yeelight from a config entry.""" _async_normalize_config_entry(hass, entry) @@ -236,15 +233,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: YeelightConfigEntry) -> bool: """Unload a config entry.""" - data_config_entries = hass.data[DOMAIN][DATA_CONFIG_ENTRIES] - data_config_entries.pop(entry.entry_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def _async_get_device( - hass: HomeAssistant, host: str, entry: ConfigEntry + hass: HomeAssistant, host: str, entry: YeelightConfigEntry ) -> YeelightDevice: # Get model from config and capabilities model = entry.options.get(CONF_MODEL) or entry.data.get(CONF_DETECTED_MODEL) diff --git a/homeassistant/components/yeelight/binary_sensor.py b/homeassistant/components/yeelight/binary_sensor.py index 5eb23bc3dd7cb1..5da8e904523760 100644 --- a/homeassistant/components/yeelight/binary_sensor.py +++ b/homeassistant/components/yeelight/binary_sensor.py @@ -3,12 +3,12 @@ import logging from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_CONFIG_ENTRIES, DATA_DEVICE, DATA_UPDATED, DOMAIN +from . import YeelightConfigEntry +from .const import DATA_UPDATED from .entity import YeelightEntity _LOGGER = logging.getLogger(__name__) @@ -16,13 +16,11 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: YeelightConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Yeelight from a config entry.""" - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - device = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][DATA_DEVICE] + device = config_entry.runtime_data if device.is_nightlight_supported: _LOGGER.debug("Adding nightlight mode sensor for %s", device.name) async_add_entities([YeelightNightlightModeSensor(device, config_entry)]) diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 4a65d30be9ec0c..2a985fb3f9b511 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -13,7 +13,6 @@ from homeassistant.components import onboarding from homeassistant.config_entries import ( - ConfigEntry, ConfigEntryState, ConfigFlow, ConfigFlowResult, @@ -28,6 +27,7 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType +from . import YeelightConfigEntry from .const import ( CONF_DETECTED_MODEL, CONF_MODE_MUSIC, @@ -62,7 +62,7 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: YeelightConfigEntry, ) -> OptionsFlowHandler: """Return the options flow.""" return OptionsFlowHandler() diff --git a/homeassistant/components/yeelight/const.py b/homeassistant/components/yeelight/const.py index e9ba80bca95e69..2ef3c2471fc2e8 100644 --- a/homeassistant/components/yeelight/const.py +++ b/homeassistant/components/yeelight/const.py @@ -1,10 +1,13 @@ """Support for Xiaomi Yeelight WiFi color bulb.""" from datetime import timedelta +from typing import Any from homeassistant.const import Platform +from homeassistant.util.hass_dict import HassKey DOMAIN = "yeelight" +DATA_CUSTOM_EFFECTS_KEY: HassKey[list[dict[str, Any]]] = HassKey(DOMAIN) STATE_CHANGE_TIME = 0.40 # seconds @@ -43,12 +46,6 @@ CONF_NIGHTLIGHT_SWITCH_TYPE = "nightlight_switch_type" CONF_NIGHTLIGHT_SWITCH = "nightlight_switch" -DATA_CONFIG_ENTRIES = "config_entries" -DATA_CUSTOM_EFFECTS = "custom_effects" -DATA_DEVICE = "device" -DATA_REMOVE_INIT_DISPATCHER = "remove_init_dispatcher" -DATA_PLATFORMS_LOADED = "platforms_loaded" - ATTR_COUNT = "count" ATTR_ACTION = "action" ATTR_TRANSITIONS = "transitions" diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 87632b1f1eb3e3..eb1ef7881dd5df 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -1,5 +1,4 @@ """Light platform support for yeelight.""" -# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations @@ -40,7 +39,7 @@ from homeassistant.helpers.typing import VolDictType from homeassistant.util import color as color_util -from . import YEELIGHT_FLOW_TRANSITION_SCHEMA +from . import YEELIGHT_FLOW_TRANSITION_SCHEMA, YeelightConfigEntry from .const import ( ACTION_RECOVER, ATTR_ACTION, @@ -52,11 +51,8 @@ CONF_NIGHTLIGHT_SWITCH, CONF_SAVE_ON_CHANGE, CONF_TRANSITION, - DATA_CONFIG_ENTRIES, - DATA_CUSTOM_EFFECTS, - DATA_DEVICE, + DATA_CUSTOM_EFFECTS_KEY, DATA_UPDATED, - DOMAIN, MODELS_WITH_DELAYED_ON_TRANSITION, POWER_STATE_CHANGE_TIME, ) @@ -221,7 +217,9 @@ def _transitions_config_parser(transitions): @callback -def _parse_custom_effects(effects_config) -> dict[str, dict[str, Any]]: +def _parse_custom_effects( + effects_config: list[dict[str, Any]], +) -> dict[str, dict[str, Any]]: effects = {} for config in effects_config: params = config[CONF_FLOW_PARAMS] @@ -279,13 +277,13 @@ async def _async_wrap( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: YeelightConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Yeelight from a config entry.""" - custom_effects = _parse_custom_effects(hass.data[DOMAIN][DATA_CUSTOM_EFFECTS]) + custom_effects = _parse_custom_effects(hass.data[DATA_CUSTOM_EFFECTS_KEY]) - device = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][DATA_DEVICE] + device = config_entry.runtime_data _LOGGER.debug("Adding %s", device.name) nl_switch_light = device.config.get(CONF_NIGHTLIGHT_SWITCH) diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 87dbb9282bf715..4af9013dc4cbd4 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/yolink", "integration_type": "hub", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.6.3"] + "requirements": ["yolink-api==0.6.5"] } diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 7af174686d8cf5..12472fc990da9b 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -9,7 +9,6 @@ from enum import StrEnum import json import logging -import os from typing import Any import voluptuous as vol @@ -20,17 +19,9 @@ from homeassistant.components import onboarding, usb from homeassistant.components.file_upload import process_uploaded_file -from homeassistant.components.hassio import AddonError, AddonState -from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( ZigbeeFlowStrategy, ) -from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware -from homeassistant.components.usb import ( - SerialDevice, - USBDevice, - async_scan_serial_ports, -) from homeassistant.config_entries import ( SOURCE_IGNORE, SOURCE_ZEROCONF, @@ -46,8 +37,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.hassio import is_hassio -from homeassistant.helpers.selector import FileSelector, FileSelectorConfig +from homeassistant.helpers.selector import ( + FileSelector, + FileSelectorConfig, + SerialPortSelector, + SerialPortSelectorConfig, +) from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util import dt as dt_util @@ -64,7 +59,6 @@ _LOGGER = logging.getLogger(__name__) -CONF_MANUAL_PATH = "Enter Manually" DECONZ_DOMAIN = "deconz" # The ZHA config flow takes different branches depending on if you are migrating to a @@ -107,12 +101,6 @@ extra=vol.ALLOW_EXTRA, ) -# USB devices to ignore in serial port selection (non-Zigbee devices) -# Format: (manufacturer, description) -IGNORED_USB_DEVICES = { - ("Nabu Casa", "ZWA-2"), -} - class OptionsMigrationIntent(StrEnum): """Zigbee options flow intents.""" @@ -138,83 +126,12 @@ def _format_backup_choice( return f"{dt_util.as_local(backup.backup_time).strftime('%c')} ({identifier})" -def _format_serial_port_choice( - serial_port: USBDevice | SerialDevice, resolved_paths: dict[str, str] -) -> str: - """Format a serial port selector entry into a line of text.""" - text = resolved_paths[serial_port.device] - - if serial_port.description: - text += f" - {serial_port.description}" - - if serial_port.serial_number: - text += f", s/n: {serial_port.serial_number}" - - if serial_port.manufacturer: - text += f" - {serial_port.manufacturer}" - - return text - - -async def list_serial_ports(hass: HomeAssistant) -> list[USBDevice | SerialDevice]: - """List all serial ports, including the Yellow radio and the multi-PAN addon.""" - ports: list[USBDevice | SerialDevice] = [] - ports.extend(await async_scan_serial_ports(hass)) - - # Add useful info to the Yellow's serial port selection screen - try: - yellow_hardware.async_info(hass) - except HomeAssistantError: - pass - else: - # PySerial does not properly handle the Yellow's serial port with the CM5 - # so we manually include it - port = SerialDevice( - device="/dev/ttyAMA1", - serial_number=None, - manufacturer="Nabu Casa", - description="Yellow Zigbee module", - ) - - ports = [p for p in ports if not p.device.startswith("/dev/ttyAMA")] - ports.insert(0, port) - - if is_hassio(hass): - # Present the multi-PAN addon as a setup option, if it's available - multipan_manager = ( - await silabs_multiprotocol_addon.get_multiprotocol_addon_manager(hass) - ) - - try: - addon_info = await multipan_manager.async_get_addon_info() - except AddonError, KeyError: - addon_info = None - - if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED: - addon_port = SerialDevice( - device=silabs_multiprotocol_addon.get_zigbee_socket(), - serial_number=None, - manufacturer="Nabu Casa", - description="Silicon Labs Multiprotocol add-on", - ) - - ports.append(addon_port) - - # Filter out ignored USB devices - return [ - port - for port in ports - if (port.manufacturer, port.description) not in IGNORED_USB_DEVICES - ] - - class BaseZhaFlow(ConfigEntryBaseFlow): """Mixin for common ZHA flow steps and forms.""" _flow_strategy: ZigbeeFlowStrategy | None = None _overwrite_ieee_during_restore: bool = False _hass: HomeAssistant - _title: str def __init__(self) -> None: """Initialize flow instance.""" @@ -272,29 +189,9 @@ async def async_step_choose_serial_port( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Choose a serial port.""" - ports = await list_serial_ports(self.hass) - - # The full `/dev/serial/by-id/` path is too verbose to show - resolved_paths = { - p.device: await self.hass.async_add_executor_job(os.path.realpath, p.device) - for p in ports - } - - list_of_ports = [_format_serial_port_choice(p, resolved_paths) for p in ports] - - if not list_of_ports: - return await self.async_step_manual_pick_radio_type() - - list_of_ports.append(CONF_MANUAL_PATH) - if user_input is not None: - user_selection = user_input[CONF_DEVICE_PATH] - - if user_selection == CONF_MANUAL_PATH: - return await self.async_step_manual_pick_radio_type() - - port = ports[list_of_ports.index(user_selection)] - self._radio_mgr.device_path = port.device + device_path = user_input[CONF_DEVICE_PATH] + self._radio_mgr.device_path = device_path probe_result = await self._radio_mgr.detect_radio_type() if probe_result == ProbeResult.WRONG_FIRMWARE_INSTALLED: @@ -303,34 +200,25 @@ async def async_step_choose_serial_port( description_placeholders={"repair_url": REPAIR_MY_URL}, ) if probe_result == ProbeResult.PROBING_FAILED: - # Did not autodetect anything, proceed to manual selection + # Did not autodetect anything, proceed to manual radio type return await self.async_step_manual_pick_radio_type() - self._title = ( - f"{port.description}{', s/n: ' + port.serial_number if port.serial_number else ''}" - f" - {port.manufacturer}" - if port.manufacturer - else "" - ) - return await self.async_step_verify_radio() - # Preselect the currently configured port - default_port: vol.Undefined | str = vol.UNDEFINED - - if self._radio_mgr.device_path is not None: - for description, port in zip(list_of_ports, ports, strict=False): - if port.device == self._radio_mgr.device_path: - default_port = description - break - else: - default_port = CONF_MANUAL_PATH - + default_path = self._radio_mgr.device_path or vol.UNDEFINED schema = vol.Schema( { - vol.Required(CONF_DEVICE_PATH, default=default_port): vol.In( - list_of_ports - ) + vol.Required( + CONF_DEVICE_PATH, default=default_path + ): SerialPortSelector( + SerialPortSelectorConfig( + extra_recommended_domains=[ + "homeassistant_yellow", + "homeassistant_sky_connect", + "homeassistant_connect_zbt2", + ] + ) + ), } ) return self.async_show_form(step_id="choose_serial_port", data_schema=schema) @@ -368,7 +256,6 @@ async def async_step_manual_port_config( errors = {} if user_input is not None: - self._title = user_input[CONF_DEVICE_PATH] self._radio_mgr.device_path = user_input[CONF_DEVICE_PATH] self._radio_mgr.device_settings = DEVICE_SCHEMA( { @@ -978,7 +865,11 @@ async def async_step_confirm( return self.async_show_form( step_id="confirm", - description_placeholders={CONF_NAME: self._title}, + description_placeholders={ + CONF_NAME: self.context.get("title_placeholders", {}).get( + CONF_NAME, self._radio_mgr.device_path or "" + ) + }, ) async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: @@ -1004,15 +895,17 @@ async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResu return self.async_abort(reason="not_zha_device") self._radio_mgr.device_path = dev_path - self._title = description or usb.human_readable_device_name( - dev_path, - serial_number, - manufacturer, - description, - vid, - pid, - ) - self.context["title_placeholders"] = {CONF_NAME: self._title} + self.context["title_placeholders"] = { + CONF_NAME: description + or usb.human_readable_device_name( + dev_path, + serial_number, + manufacturer, + description, + vid, + pid, + ) + } return await self.async_step_confirm() async def async_step_zeroconf( @@ -1071,7 +964,6 @@ async def async_step_zeroconf( ) self.context["title_placeholders"] = {CONF_NAME: title} - self._title = title self._radio_mgr.device_path = device_path self._radio_mgr.radio_type = radio_type self._radio_mgr.device_settings = DEVICE_SCHEMA( @@ -1104,7 +996,6 @@ async def async_step_hardware( device_path=device_path, ) - self._title = name self._radio_mgr.radio_type = radio_type self._radio_mgr.device_path = device_path self._radio_mgr.device_settings = device_settings @@ -1125,7 +1016,6 @@ async def _async_create_radio_entry(self) -> ConfigFlowResult: if len(zha_config_entries) == 1: return self.async_update_reload_and_abort( entry=zha_config_entries[0], - title=self._title, data=data, reload_even_if_entry_is_unchanged=True, reason="reconfigure_successful", @@ -1140,10 +1030,7 @@ async def _async_create_radio_entry(self) -> ConfigFlowResult: ) await self.async_set_unique_id(unique_id) - return self.async_create_entry( - title=self._title, - data=data, - ) + return self.async_create_entry(title="", data=data) # This should never be reached return self.async_abort(reason="single_instance_allowed") @@ -1159,7 +1046,6 @@ def __init__(self, config_entry: ConfigEntry) -> None: self._radio_mgr.device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] self._radio_mgr.device_settings = config_entry.data[CONF_DEVICE] self._radio_mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]] - self._title = config_entry.title async def async_step_init( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index f3a0d0584c2bec..190a3748e8f8bc 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -27,7 +27,12 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from .const import DOMAIN -from .helpers import SIGNAL_REMOVE_ENTITIES, EntityData, convert_zha_error_to_ha_error +from .helpers import ( + SIGNAL_REMOVE_ENTITIES, + SIGNAL_REMOVE_ENTITY, + EntityData, + convert_zha_error_to_ha_error, +) _LOGGER = logging.getLogger(__name__) @@ -163,6 +168,16 @@ async def async_added_to_hass(self) -> None: partial(self.async_remove, force_remove=True), ) ) + self._unsubs.append( + async_dispatcher_connect( + self.hass, + ( + f"{SIGNAL_REMOVE_ENTITY}_" + f"{self.entity_data.entity.PLATFORM}_{self.unique_id}" + ), + self.async_remove, + ) + ) self.entity_data.device_proxy.gateway_proxy.register_entity_reference( self.entity_id, self.entity_data, @@ -189,6 +204,7 @@ async def async_will_remove_from_hass(self) -> None: for unsub in self._unsubs[:]: unsub() self._unsubs.remove(unsub) + self.entity_data.device_proxy.gateway_proxy.remove_entity_reference(self) await super().async_will_remove_from_hass() self.remove_future.set_result(True) diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 436e95f8ef9a64..09705321b41b1c 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -77,6 +77,8 @@ from zha.zigbee.device import ( ClusterHandlerConfigurationComplete, Device, + DeviceEntityAddedEvent, + DeviceEntityRemovedEvent, DeviceFirmwareInfoUpdatedEvent, ZHAEvent, ) @@ -206,6 +208,7 @@ ZHA_GW_MSG_LOG_ENTRY = "log_entry" ZHA_GW_MSG_LOG_OUTPUT = "log_output" SIGNAL_REMOVE_ENTITIES = "zha_remove_entities" +SIGNAL_REMOVE_ENTITY = "zha_remove_entity" GROUP_ENTITY_DOMAINS = [Platform.LIGHT, Platform.SWITCH, Platform.FAN] SIGNAL_ADD_ENTITIES = "zha_add_entities" ENTITIES = "entities" @@ -495,6 +498,41 @@ def handle_zha_channel_bind(self, event: ClusterBindEvent) -> None: }, ) + @callback + def handle_zha_device_entity_added_event( + self, event: DeviceEntityAddedEvent + ) -> None: + """Handle a new entity being added to a device at runtime.""" + key = (event.platform, event.unique_id) + if (entity := self.device.platform_entities.get(key)) is None: + return + ha_zha_data = get_zha_data(self.gateway_proxy.hass) + ha_zha_data.platforms[Platform(event.platform)].append( + EntityData(entity=entity, device_proxy=self, group_proxy=None) + ) + async_dispatcher_send(self.gateway_proxy.hass, SIGNAL_ADD_ENTITIES) + + @callback + def handle_zha_device_entity_removed_event( + self, event: DeviceEntityRemovedEvent + ) -> None: + """Handle an entity being removed from a device at runtime.""" + if not event.remove: + # Soft remove: signal the entity to unload; registry entry stays + async_dispatcher_send( + self.gateway_proxy.hass, + f"{SIGNAL_REMOVE_ENTITY}_{event.platform}_{event.unique_id}", + ) + return + + # Hard remove: delete from registry, also works without a live entity loaded + entity_registry = er.async_get(self.gateway_proxy.hass) + domain = Platform(event.platform) + if entity_id := entity_registry.async_get_entity_id( + domain, DOMAIN, event.unique_id + ): + entity_registry.async_remove(entity_id) + class EntityReference(NamedTuple): """Describes an entity reference.""" @@ -814,13 +852,12 @@ def get_entity_reference(self, entity_id: str) -> EntityReference | None: def remove_entity_reference(self, entity: ZHAEntity) -> None: """Remove entity reference for given entity_id if found.""" - if entity.zha_device.ieee in self.ha_entity_refs: - entity_refs = self.ha_entity_refs.get(entity.zha_device.ieee) - self.ha_entity_refs[entity.zha_device.ieee] = [ - e - for e in entity_refs # type: ignore[union-attr] - if e.ha_entity_id != entity.entity_id - ] + ieee = entity.entity_data.device_proxy.device.ieee + if (entity_refs := self._ha_entity_refs.get(ieee)) is None: + return + self._ha_entity_refs[ieee] = [ + e for e in entity_refs if e.ha_entity_id != entity.entity_id + ] def _async_get_or_create_device_proxy(self, zha_device: Device) -> ZHADeviceProxy: """Get or create a ZHA device.""" diff --git a/homeassistant/components/zinvolt/__init__.py b/homeassistant/components/zinvolt/__init__.py index ff8b7fdfe90c32..71b9d97f7e7196 100644 --- a/homeassistant/components/zinvolt/__init__.py +++ b/homeassistant/components/zinvolt/__init__.py @@ -17,6 +17,7 @@ _PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/zinvolt/select.py b/homeassistant/components/zinvolt/select.py new file mode 100644 index 00000000000000..ae68470c146759 --- /dev/null +++ b/homeassistant/components/zinvolt/select.py @@ -0,0 +1,57 @@ +"""Select platform for Zinvolt integration.""" + +from zinvolt.models import SmartMode + +from homeassistant.components.select import SelectEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ZinvoltConfigEntry, ZinvoltDeviceCoordinator +from .entity import ZinvoltEntity + +MODE_MAP = { + SmartMode.DYNAMIC: "dynamic", + SmartMode.SELF_USE: "self_use", + SmartMode.PERFORMANCE: "fast_discharge", + SmartMode.CHARGED: "charged", + SmartMode.DEFAULT: "idle", + SmartMode.FEED: "fast_charge", +} + +HA_TO_MODE = {v: k for k, v in MODE_MAP.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ZinvoltConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize the entries.""" + + async_add_entities( + ZinvoltBatteryMode(coordinator) for coordinator in entry.runtime_data.values() + ) + + +class ZinvoltBatteryMode(ZinvoltEntity, SelectEntity): + """Zinvolt select.""" + + _attr_options = list(HA_TO_MODE.keys()) + _attr_translation_key = "battery_mode" + + def __init__(self, coordinator: ZinvoltDeviceCoordinator) -> None: + """Initialize the select.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.data.battery.serial_number}.mode" + + @property + def current_option(self) -> str | None: + """Return the current battery mode.""" + return MODE_MAP.get(self.coordinator.data.battery.smart_mode) + + async def async_select_option(self, option: str) -> None: + """Set battery mode.""" + await self.coordinator.client.set_smart_mode( + self.coordinator.battery.identifier, HA_TO_MODE[option] + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/zinvolt/strings.json b/homeassistant/components/zinvolt/strings.json index d4bc22a1247fde..f0ecb751af21ba 100644 --- a/homeassistant/components/zinvolt/strings.json +++ b/homeassistant/components/zinvolt/strings.json @@ -61,6 +61,19 @@ "upper_threshold": { "name": "Maximum charge level" } + }, + "select": { + "battery_mode": { + "name": "Mode", + "state": { + "charged": "Charged", + "dynamic": "Dynamic", + "fast_charge": "Fast charge", + "fast_discharge": "Fast discharge", + "idle": "[%key:common::state::idle%]", + "self_use": "Self-use" + } + } } }, "exceptions": { diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 9ec546be756299..afbe308e1b84d8 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -47,6 +47,7 @@ is_opening_state_notification_value, ) from .models import ( + FirmwareVersionRange, NewZWaveDiscoverySchema, ValueType, ZwaveDiscoveryInfo, @@ -1346,6 +1347,38 @@ def __init__( ), entity_class=ZWaveBooleanBinarySensor, ), + NewZWaveDiscoverySchema( + # Fibaro FGMS001 Motion Sensor: + # On firmware <= 2.8 the device supports Binary Sensor CC v1, which + # does not give us any information about the type of the sensor. + # As a result it is exposed via the generic "Any" sensor type, + # which fits no other discovery schema. + platform=Platform.BINARY_SENSOR, + manufacturer_id={0x010F}, + product_type={0x0800, 0x0801, 0x8800}, + product_id={ + 0x1001, + 0x1002, + 0x2001, + 0x2002, + 0x3001, + 0x3002, + 0x4001, + 0x4002, + 0x6001, + }, + firmware_version_range=FirmwareVersionRange(max="2.8"), + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SENSOR_BINARY}, + property={"Any"}, + type={ValueType.BOOLEAN}, + ), + entity_description=BinarySensorEntityDescription( + key="motion", + device_class=BinarySensorDeviceClass.MOTION, + ), + entity_class=ZWaveBooleanBinarySensor, + ), NewZWaveDiscoverySchema( platform=Platform.BINARY_SENSOR, primary_value=ZWaveValueDiscoverySchema( diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 810338d54878db..c767ec88a07bdf 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -14,6 +14,7 @@ "integration", "min_max", "mold_indicator", + "otp", "random", "statistics", "switch_as_x", @@ -311,6 +312,7 @@ "homewizard", "homeworks", "honeywell", + "honeywell_string_lights", "hr_energy_qube", "html5", "huawei_lte", @@ -496,6 +498,7 @@ "nobo_hub", "nordpool", "notion", + "novy_cooker_hood", "nrgkick", "ntfy", "nuheat", @@ -533,7 +536,6 @@ "orvibo", "osoenergy", "otbr", - "otp", "ourgroceries", "overkiz", "overseerr", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 550fe74d22ae8c..79382b649e5cdb 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2975,6 +2975,12 @@ "config_flow": true, "iot_class": "cloud_polling", "name": "Honeywell Total Connect Comfort (US)" + }, + "honeywell_string_lights": { + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state", + "name": "Honeywell String Lights" } } }, @@ -2998,7 +3004,7 @@ }, "html5": { "name": "HTML5 Push Notifications", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_push", "single_config_entry": true @@ -4759,6 +4765,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "novy_cooker_hood": { + "name": "Novy Cooker Hood", + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state" + }, "nrgkick": { "name": "NRGkick", "integration_type": "device", @@ -5108,12 +5120,6 @@ "config_flow": true, "iot_class": "local_polling" }, - "otp": { - "name": "One-Time Password (OTP)", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" - }, "ourgroceries": { "name": "OurGroceries", "integration_type": "service", @@ -8310,6 +8316,12 @@ "config_flow": true, "iot_class": "calculated" }, + "otp": { + "name": "One-Time Password (OTP)", + "integration_type": "helper", + "config_flow": true, + "iot_class": "local_polling" + }, "random": { "integration_type": "helper", "config_flow": true, diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 9915ac22019970..45b5c803fd32b9 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -341,24 +341,6 @@ "manufacturer": "Synology", }, ], - "unifi": [ - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine", - }, - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine Pro", - }, - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine SE", - }, - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine Pro Max", - }, - ], "unifi_discovery": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index aef673cb5001a6..b1d4390cc5d5b9 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -545,13 +545,21 @@ def __init__( model_name: str, create_schema: VolDictType, update_schema: VolDictType, + *, + admin_only: bool = False, ) -> None: - """Initialize a websocket CRUD.""" + """Initialize a websocket CRUD. + + When ``admin_only`` is set, the ``/list`` and ``/subscribe`` commands + are also restricted to admin users (the mutating commands are always + admin-only). Use this for collections whose items contain secrets. + """ self.storage_collection = storage_collection self.api_prefix = api_prefix self.model_name = model_name self.create_schema = create_schema self.update_schema = update_schema + self.admin_only = admin_only self._remove_subscription: CALLBACK_TYPE | None = None self._subscribers: set[tuple[websocket_api.ActiveConnection, int]] = set() @@ -566,10 +574,18 @@ def item_id_key(self) -> str: @callback def async_setup(self, hass: HomeAssistant) -> None: """Set up the websocket commands.""" + list_handler: websocket_api.const.WebSocketCommandHandler = self.ws_list_item + subscribe_handler: websocket_api.const.WebSocketCommandHandler = ( + self._ws_subscribe + ) + if self.admin_only: + list_handler = websocket_api.require_admin(list_handler) + subscribe_handler = websocket_api.require_admin(subscribe_handler) + websocket_api.async_register_command( hass, f"{self.api_prefix}/list", - self.ws_list_item, + list_handler, websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( {vol.Required("type"): f"{self.api_prefix}/list"} ), @@ -592,7 +608,7 @@ def async_setup(self, hass: HomeAssistant) -> None: websocket_api.async_register_command( hass, f"{self.api_prefix}/subscribe", - self._ws_subscribe, + subscribe_handler, websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( {vol.Required("type"): f"{self.api_prefix}/subscribe"} ), diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 41ca3536c79c73..9df4ea95191375 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -95,7 +95,12 @@ NumericThresholdType, TargetSelector, ) -from .target import TargetSelection, async_extract_referenced_entity_ids +from .target import ( + TargetSelection, + TargetStateChangedData, + async_extract_referenced_entity_ids, + async_track_target_selector_state_change_event, +) from .template import Template, render_complex from .trace import ( TraceElement, @@ -462,6 +467,7 @@ def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: if TYPE_CHECKING: assert config.target assert config.options + self._target = config.target self._target_selection = TargetSelection(config.target) self._behavior = config.options[ATTR_BEHAVIOR] self._duration: timedelta | None = config.options.get(CONF_FOR) @@ -469,11 +475,100 @@ def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: self._matcher = self._check_any_match_state elif self._behavior == BEHAVIOR_ALL: self._matcher = self._check_all_match_state + self._on_unload: list[Callable[[], None]] = [] + self._valid_since: dict[str, datetime] = {} def entity_filter(self, entities: set[str]) -> set[str]: """Filter entities matching any of the domain specs.""" return filter_by_domain_specs(self._hass, self._domain_specs, entities) + @property + def _needs_duration_tracking(self) -> bool: + """Whether this condition needs active state change tracking for duration. + + The base implementation intentionally defaults to always tracking + duration and should be overridden by subclasses that can safely use + state.last_changed directly. For example, conditions that are true + for a single main state value may not need active tracking, while + conditions that track attributes or match multiple states do because + last_changed does not capture those transitions. + """ + return True + + def _update_valid_since(self, entity_id: str, _state: State | None) -> None: + """Update _valid_since tracking for an entity based on its current state. + + If the entity is in a valid state and not already tracked, records when + the condition became true. If the entity is not in a valid state, removes + it from tracking. + + For state-based conditions (value_source is None), last_changed + accurately reflects when the state changed to the current value. + For attribute-based conditions, last_changed only tracks main state + changes, so we use last_updated which is bumped on any update + (state or attributes). This is conservative — the tracked attribute + may have held its value longer — but it's the best we can do + to avoid false positives. + """ + if ( + _state is not None + and _state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + and self.is_valid_state(_state) + ): + # Only record the time if not already tracked, to avoid + # resetting the duration on unrelated state/attribute updates. + if entity_id not in self._valid_since: + domain_spec = self._domain_specs[_state.domain] + if domain_spec.value_source is None: + self._valid_since[entity_id] = _state.last_changed + else: + self._valid_since[entity_id] = _state.last_updated + else: + self._valid_since.pop(entity_id, None) + + @override + async def async_setup(self) -> None: + """Set up state tracking for duration-based conditions.""" + await super().async_setup() + if not self._duration or not self._needs_duration_tracking: + return + + @callback + def _state_change_listener( + data: TargetStateChangedData, + ) -> None: + """Track when entities enter or leave a valid state.""" + event = data.state_change_event + entity_id = event.data["entity_id"] + to_state = event.data["new_state"] + + self._update_valid_since(entity_id, to_state) + + @callback + def _on_entities_update(added: set[str], removed: set[str]) -> None: + """Handle changes to the tracked entity set.""" + for entity_id in added: + self._update_valid_since(entity_id, self._hass.states.get(entity_id)) + for entity_id in removed: + self._valid_since.pop(entity_id, None) + + unsub = async_track_target_selector_state_change_event( + self._hass, + self._target, + _state_change_listener, + self.entity_filter, + _on_entities_update, + ) + self._on_unload.append(unsub) + + @override + def async_unload(self) -> None: + """Unsubscribe from listeners.""" + super().async_unload() + for cb in self._on_unload: + cb() + self._on_unload.clear() + def _get_tracked_value(self, entity_state: State) -> Any: """Get the tracked value from a state based on the DomainSpec.""" domain_spec = self._domain_specs[entity_state.domain] @@ -490,9 +585,16 @@ def _check_any_match_state(self, states: list[State]) -> bool: if not self._duration: # Skip duration check if duration is not specified or 0 return any(self.is_valid_state(state) for state in states) - duration = dt_util.utcnow() - self._duration + cutoff = dt_util.utcnow() - self._duration + if not self._needs_duration_tracking: + return any( + self.is_valid_state(state) and state.last_changed <= cutoff + for state in states + ) return any( - self.is_valid_state(state) and duration > state.last_changed + self.is_valid_state(state) + and (valid_since := self._valid_since.get(state.entity_id)) is not None + and valid_since <= cutoff for state in states ) @@ -501,9 +603,16 @@ def _check_all_match_state(self, states: list[State]) -> bool: if not self._duration: # Skip duration check if duration is not specified or 0 return all(self.is_valid_state(state) for state in states) - duration = dt_util.utcnow() - self._duration + cutoff = dt_util.utcnow() - self._duration + if not self._needs_duration_tracking: + return all( + self.is_valid_state(state) and state.last_changed <= cutoff + for state in states + ) return all( - self.is_valid_state(state) and duration > state.last_changed + self.is_valid_state(state) + and (valid_since := self._valid_since.get(state.entity_id)) is not None + and valid_since <= cutoff for state in states ) @@ -533,6 +642,15 @@ class EntityStateConditionBase(EntityConditionBase): _states: set[str | bool] + @property + def _needs_duration_tracking(self) -> bool: + """Single-state conditions with no attribute tracking can use last_changed.""" + if len(self._states) != 1: + return True + return any( + spec.value_source is not None for spec in self._domain_specs.values() + ) + def is_valid_state(self, entity_state: State) -> bool: """Check if the state matches the expected state(s).""" return self._get_tracked_value(entity_state) in self._states @@ -1005,6 +1123,7 @@ async def async_from_config( else: checker = factory(config) if isinstance(checker, ConditionChecker): + await checker.async_setup() return checker return LegacyConditionChecker(hass, cast(ConditionCheckerType, checker)) @@ -1488,7 +1607,7 @@ def time( after = datetime.strptime(after_entity.state, "%H:%M:%S").time() elif ( after_entity.attributes.get(ATTR_DEVICE_CLASS) - == SensorDeviceClass.TIMESTAMP + in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME) ) and after_entity.state not in ( STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -1518,7 +1637,7 @@ def time( return False elif ( before_entity.attributes.get(ATTR_DEVICE_CLASS) - == SensorDeviceClass.TIMESTAMP + in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME) ) and before_entity.state not in ( STATE_UNAVAILABLE, STATE_UNKNOWN, diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index f9b536a91417a4..6851756c9e4377 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -759,15 +759,7 @@ def dynamic_template(value: Any) -> template_helper.Template: if not template_helper.is_template_string(str(value)): raise vol.Invalid("template value does not contain a dynamic template") if not (hass := _async_get_hass_or_none()): - from .frame import ReportBehavior, report_usage # noqa: PLC0415 - - report_usage( - ( - "validates schema outside the event loop, " - "which will stop working in HA Core 2025.10" - ), - core_behavior=ReportBehavior.LOG, - ) + raise vol.Invalid("Validates schema outside the event loop") template_value = template_helper.Template(str(value), hass) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 9eef6395ff1eca..957185ecf7613a 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -36,7 +36,7 @@ callback, split_entity_id, ) -from homeassistant.exceptions import HomeAssistantError, TemplateError +from homeassistant.exceptions import TemplateError from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.event_type import EventType @@ -990,14 +990,6 @@ def __init__( self._last_result: dict[Template, bool | str | TemplateError] = {} - for track_template_ in track_templates: - if track_template_.template.hass: - continue - - raise HomeAssistantError( - "Calls async_track_template_result with template without hass" - ) - self._rate_limit = KeyedRateLimit(hass) self._info: dict[Template, RenderInfo] = {} self._track_state_changes: _TrackStateChangeFiltered | None = None diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index c9ca479df8ea91..827a018f9294f0 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -1160,6 +1160,26 @@ async def async_call( return {"success": True, "result": items} +def _live_context_match_error( + match_result: intent.MatchTargetsResult, + name_filter: str | None, + area_filter: str | None, + domain_filter: list[str] | None, +) -> str: + """Build an actionable error message for a failed GetLiveContext match.""" + reason = match_result.no_match_reason + if reason is intent.MatchFailedReason.INVALID_AREA: + return f"Area '{match_result.no_match_name}' does not exist" + if reason is intent.MatchFailedReason.NAME: + return f"No exposed entities matched name '{name_filter}'" + if reason is intent.MatchFailedReason.AREA: + return f"No exposed entities found in area '{area_filter}'" + if reason is intent.MatchFailedReason.DOMAIN: + domains = ", ".join(domain_filter) if domain_filter else "" + return f"No exposed entities found in domain(s): {domains}" + return "No entities matched the provided filter" + + class GetLiveContextTool(Tool): """Tool for getting the current state of exposed entities. @@ -1173,7 +1193,25 @@ class GetLiveContextTool(Tool): "Provides real-time information about the CURRENT state, value, or mode of devices, sensors, entities, or areas. " "Use this tool for: " "1. Answering questions about current conditions (e.g., 'Is the light on?'). " - "2. As the first step in conditional actions (e.g., 'If the weather is rainy, turn off sprinklers' requires checking the weather first)." + "2. As the first step in conditional actions (e.g., 'If the weather is rainy, turn off sprinklers' requires checking the weather first). " + "You may filter for devices by name, domain, and area, including combining those filters. " + "Prefer filtering by domain when searching for multiple devices of the same type." + ) + parameters = vol.Schema( + { + vol.Optional( + "name", + description="Filter entities by name or alias (case-insensitive).", + ): cv.string, + vol.Optional( + "domain", + description="Filter entities by domain (e.g. 'light', 'sensor'). Accepts a single domain or a list.", + ): vol.Any(cv.string, [cv.string]), + vol.Optional( + "area", + description="Filter entities by area name or alias (case-insensitive).", + ): cv.string, + } ) async def async_call( @@ -1188,12 +1226,62 @@ async def async_call( # exposed if no assistant is configured. return {"success": False, "error": "No assistant configured"} + args = self.parameters(tool_input.tool_args) exposed_entities = _get_exposed_entities(hass, llm_context.assistant) + if not exposed_entities["entities"]: return {"success": False, "error": NO_ENTITIES_PROMPT} + + name_filter = args.get("name") + area_filter = args.get("area") + domain_filter = args.get("domain") + + if isinstance(domain_filter, str): + domain_filter = [domain_filter] + + if domain_filter is not None: + domain_filter = [ + normalized_domain + for domain in domain_filter + if (normalized_domain := domain.strip().lower()) + ] + + if name_filter or area_filter or domain_filter: + exposed_states = [ + state + for entity_id in exposed_entities["entities"] + if (state := hass.states.get(entity_id)) is not None + ] + match_result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + name=name_filter, + area_name=area_filter, + domains=domain_filter, + ), + states=exposed_states, + ) + + if not match_result.is_match: + return { + "success": False, + "error": _live_context_match_error( + match_result, name_filter, area_filter, domain_filter + ), + } + + matched_ids = {state.entity_id for state in match_result.states} + entities = [ + info + for entity_id, info in exposed_entities["entities"].items() + if entity_id in matched_ids + ] + else: + entities = list(exposed_entities["entities"].values()) + prompt = [ "Live Context: An overview of the areas and the devices in this smart home:", - yaml_util.dump(list(exposed_entities["entities"].values())), + yaml_util.dump(entities), ] return { "success": True, diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 0947ddc90d1db4..3b4ba9e2560c29 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -702,7 +702,7 @@ def traced_test_conditions( with trace_path(condition_path): for idx, cond in enumerate(conditions): with trace_path(str(idx)): - if cond(hass, variables) is False: + if cond.async_check(variables=variables) is False: return False except exceptions.ConditionError as ex: self._log( @@ -753,7 +753,7 @@ async def _async_step_condition(self) -> None: trace_element = trace_stack_top(trace_stack_cv) if trace_element: trace_element.reuse_by_child = True - check = cond(self._hass, self._variables) + check = cond.async_check(variables=self._variables) except exceptions.ConditionError as ex: self._log("Error in 'condition' evaluation:\n%s", ex, level=logging.WARNING) check = False diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index b885081f83ba64..51c79596a7827e 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1771,9 +1771,11 @@ def __call__(self, data: Any) -> Any: return [parent_schema(vol.Schema(str)(val)) for val in data] -class SerialPortSelectorConfig(BaseSelectorConfig): +class SerialPortSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a serial port selector config.""" + extra_recommended_domains: list[str] + @SELECTORS.register("serial_port") class SerialPortSelector(Selector[SerialPortSelectorConfig]): @@ -1781,7 +1783,11 @@ class SerialPortSelector(Selector[SerialPortSelectorConfig]): selector_type = "serial_port" - CONFIG_SCHEMA = make_selector_config_schema() + CONFIG_SCHEMA = make_selector_config_schema( + { + vol.Optional("extra_recommended_domains"): [str], + } + ) def __init__(self, config: SerialPortSelectorConfig | None = None) -> None: """Instantiate a selector.""" diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index b5d5f3fbd8f8bb..6bcf3de76f330a 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -357,6 +357,7 @@ def __init__( target_selection: TargetSelection, action: Callable[[TargetStateChangedData], Any], entity_filter: Callable[[set[str]], set[str]], + on_entities_update: Callable[[set[str], set[str]], None] | None = None, *, primary_entities_only: bool = True, ) -> None: @@ -368,10 +369,20 @@ def __init__( primary_entities_only=primary_entities_only, ) self._action = action + self._on_entities_update = on_entities_update self._state_change_unsub: CALLBACK_TYPE | None = None + self._tracked_entities: set[str] = set() def _handle_entities_update(self, tracked_entities: set[str]) -> None: """Handle the tracked entities.""" + previous_entities = self._tracked_entities + self._tracked_entities = tracked_entities + + if self._on_entities_update is not None: + added = tracked_entities - previous_entities + removed = previous_entities - tracked_entities + if added or removed: + self._on_entities_update(added, removed) @callback def state_change_listener(event: Event[EventStateChangedData]) -> None: @@ -399,6 +410,7 @@ def async_track_target_selector_state_change_event( target_selector_config: ConfigType, action: Callable[[TargetStateChangedData], Any], entity_filter: Callable[[set[str]], set[str]] = lambda x: x, + on_entities_update: Callable[[set[str], set[str]], None] | None = None, *, primary_entities_only: bool = True, ) -> CALLBACK_TYPE: @@ -417,6 +429,7 @@ def async_track_target_selector_state_change_event( target_selection, action, entity_filter, + on_entities_update, primary_entities_only=primary_entities_only, ) return tracker.async_setup() diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index 8cb247c6917475..fb2aeb5b03c7f7 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -5,40 +5,27 @@ from ast import literal_eval import asyncio import collections.abc -from collections.abc import Callable, Iterable +from collections.abc import Callable from datetime import timedelta -from functools import lru_cache, partial, wraps +from functools import lru_cache, partial import logging import pathlib import re import sys from types import CodeType -from typing import TYPE_CHECKING, Any, Concatenate, Literal, NoReturn, Self, overload +from typing import TYPE_CHECKING, Any, Literal, Self, overload import weakref import jinja2 -from jinja2 import pass_context, pass_eval_context from jinja2.runtime import AsyncLoopContext, LoopContext from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_LATITUDE, - ATTR_LONGITUDE, - ATTR_PERSONS, - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, - STATE_UNAVAILABLE, - STATE_UNKNOWN, - UnitOfLength, -) -from homeassistant.core import HomeAssistant, State, callback, valid_entity_id +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import location as loc_helper from homeassistant.helpers.singleton import singleton from homeassistant.helpers.typing import TemplateVarsType -from homeassistant.util import convert, location as location_util from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads @@ -62,9 +49,6 @@ StateTranslated, TemplateState as TemplateState, TemplateStateFromEntityId as TemplateStateFromEntityId, - _collect_state, - _get_state, - _resolve_state, ) if TYPE_CHECKING: @@ -267,27 +251,11 @@ class Template: "template", ) - def __init__(self, template: str, hass: HomeAssistant | None = None) -> None: - """Instantiate a template. - - Note: A valid hass instance should always be passed in. The hass parameter - will be non optional in Home Assistant Core 2025.10. - """ - from homeassistant.helpers.frame import ( # noqa: PLC0415 - ReportBehavior, - report_usage, - ) - + def __init__(self, template: str, hass: HomeAssistant) -> None: + """Instantiate a template.""" if not isinstance(template, str): raise TypeError("Expected template to be a string") - if not hass: - report_usage( - "creates a template object without passing hass", - core_behavior=ReportBehavior.LOG, - breaks_in_ha_version="2025.10", - ) - self.template: str = template.strip() self._compiled_code: CodeType | None = None self._compiled: jinja2.Template | None = None @@ -302,8 +270,6 @@ def __init__(self, template: str, hass: HomeAssistant | None = None) -> None: @property def _env(self) -> TemplateEnvironment: - if self.hass is None: - return _NO_HASS_ENV # Bypass cache if a custom log function is specified if self._log_fn is not None: return TemplateEnvironment( @@ -631,217 +597,6 @@ def __repr__(self) -> str: return f"Template" -def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]: - """Expand out any groups and zones into entity states.""" - # circular import. - from homeassistant.helpers import entity as entity_helper # noqa: PLC0415 - - search = list(args) - found = {} - sources = entity_helper.entity_sources(hass) - while search: - entity = search.pop() - if isinstance(entity, str): - entity_id = entity - if (entity := _get_state(hass, entity)) is None: - continue - elif isinstance(entity, State): - entity_id = entity.entity_id - elif isinstance(entity, collections.abc.Iterable): - search += entity - continue - else: - # ignore other types - continue - - if entity_id in found: - continue - - domain = entity.domain - if domain == "group" or ( - (source := sources.get(entity_id)) and source["domain"] == "group" - ): - # Collect state will be called in here since it's wrapped - if group_entities := entity.attributes.get(ATTR_ENTITY_ID): - search += group_entities - elif domain == "zone": - if zone_entities := entity.attributes.get(ATTR_PERSONS): - search += zone_entities - else: - _collect_state(hass, entity_id) - found[entity_id] = entity - - return list(found.values()) - - -def closest(hass: HomeAssistant, *args: Any) -> State | None: - """Find closest entity. - - Closest to home: - closest(states) - closest(states.device_tracker) - closest('group.children') - closest(states.group.children) - - Closest to a point: - closest(23.456, 23.456, 'group.children') - closest('zone.school', 'group.children') - closest(states.zone.school, 'group.children') - - As a filter: - states | closest - states.device_tracker | closest - ['group.children', states.device_tracker] | closest - 'group.children' | closest(23.456, 23.456) - states.device_tracker | closest('zone.school') - 'group.children' | closest(states.zone.school) - - """ - if len(args) == 1: - latitude = hass.config.latitude - longitude = hass.config.longitude - entities = args[0] - - elif len(args) == 2: - point_state = _resolve_state(hass, args[0]) - - if point_state is None: - _LOGGER.warning("Closest:Unable to find state %s", args[0]) - return None - if not loc_helper.has_location(point_state): - _LOGGER.warning( - "Closest:State does not contain valid location: %s", point_state - ) - return None - - latitude = point_state.attributes[ATTR_LATITUDE] - longitude = point_state.attributes[ATTR_LONGITUDE] - - entities = args[1] - - else: - latitude_arg = convert(args[0], float) - longitude_arg = convert(args[1], float) - - if latitude_arg is None or longitude_arg is None: - _LOGGER.warning( - "Closest:Received invalid coordinates: %s, %s", args[0], args[1] - ) - return None - - latitude = latitude_arg - longitude = longitude_arg - - entities = args[2] - - states = expand(hass, entities) - - # state will already be wrapped here - return loc_helper.closest(latitude, longitude, states) - - -def closest_filter(hass: HomeAssistant, *args: Any) -> State | None: - """Call closest as a filter. Need to reorder arguments.""" - new_args = list(args[1:]) - new_args.append(args[0]) - return closest(hass, *new_args) - - -def distance(hass: HomeAssistant, *args: Any) -> float | None: - """Calculate distance. - - Will calculate distance from home to a point or between points. - Points can be passed in using state objects or lat/lng coordinates. - """ - locations: list[tuple[float, float]] = [] - - to_process = list(args) - - while to_process: - value = to_process.pop(0) - if isinstance(value, str) and not valid_entity_id(value): - point_state = None - else: - point_state = _resolve_state(hass, value) - - if point_state is None: - # We expect this and next value to be lat&lng - if not to_process: - _LOGGER.warning( - "Distance:Expected latitude and longitude, got %s", value - ) - return None - - value_2 = to_process.pop(0) - latitude_to_process = convert(value, float) - longitude_to_process = convert(value_2, float) - - if latitude_to_process is None or longitude_to_process is None: - _LOGGER.warning( - "Distance:Unable to process latitude and longitude: %s, %s", - value, - value_2, - ) - return None - - latitude = latitude_to_process - longitude = longitude_to_process - - else: - if not loc_helper.has_location(point_state): - _LOGGER.warning( - "Distance:State does not contain valid location: %s", point_state - ) - return None - - latitude = point_state.attributes[ATTR_LATITUDE] - longitude = point_state.attributes[ATTR_LONGITUDE] - - locations.append((latitude, longitude)) - - if len(locations) == 1: - return hass.config.distance(*locations[0]) - - return hass.config.units.length( - location_util.distance(*locations[0] + locations[1]), UnitOfLength.METERS - ) - - -def is_state(hass: HomeAssistant, entity_id: str, state: str | list[str]) -> bool: - """Test if a state is a specific value.""" - state_obj = _get_state(hass, entity_id) - return state_obj is not None and ( - state_obj.state == state - or (isinstance(state, list) and state_obj.state in state) - ) - - -def is_state_attr(hass: HomeAssistant, entity_id: str, name: str, value: Any) -> bool: - """Test if a state's attribute is a specific value.""" - if (state_obj := _get_state(hass, entity_id)) is not None: - attr = state_obj.attributes.get(name, _SENTINEL) - if attr is _SENTINEL: - return False - return bool(attr == value) - return False - - -def state_attr(hass: HomeAssistant, entity_id: str, name: str) -> Any: - """Get a specific attribute from a state.""" - if (state_obj := _get_state(hass, entity_id)) is not None: - return state_obj.attributes.get(name) - return None - - -def has_value(hass: HomeAssistant, entity_id: str) -> bool: - """Test if an entity has a valid value.""" - state_obj = _get_state(hass, entity_id) - - return state_obj is not None and ( - state_obj.state not in [STATE_UNAVAILABLE, STATE_UNKNOWN] - ) - - def make_logging_undefined( strict: bool | None, log_fn: Callable[[int, str], None] | None ) -> type[jinja2.Undefined]: @@ -992,106 +747,17 @@ def __init__( self.add_extension( "homeassistant.helpers.template.extensions.SerializationExtension" ) + self.add_extension("homeassistant.helpers.template.extensions.StateExtension") self.add_extension("homeassistant.helpers.template.extensions.StringExtension") self.add_extension( "homeassistant.helpers.template.extensions.TypeCastExtension" ) self.add_extension("homeassistant.helpers.template.extensions.VersionExtension") - if hass is None: - return - - # This environment has access to hass, attach its loader to enable imports. - self.loader = _get_hass_loader(hass) - - # We mark these as a context functions to ensure they get - # evaluated fresh with every execution, rather than executed - # at compile time and the value stored. The context itself - # can be discarded, we only need to get at the hass object. - def hassfunction[**_P, _R]( - func: Callable[Concatenate[HomeAssistant, _P], _R], - jinja_context: Callable[ - [Callable[Concatenate[Any, _P], _R]], - Callable[Concatenate[Any, _P], _R], - ] = pass_context, - ) -> Callable[Concatenate[Any, _P], _R]: - """Wrap function that depend on hass.""" - - @wraps(func) - def wrapper(_: Any, *args: _P.args, **kwargs: _P.kwargs) -> _R: - return func(hass, *args, **kwargs) - - return jinja_context(wrapper) - - if limited: - - def unsupported(name: str) -> Callable[[], NoReturn]: - def warn_unsupported(*args: Any, **kwargs: Any) -> NoReturn: - raise TemplateError( - f"Use of '{name}' is not supported in limited templates" - ) - - return warn_unsupported - - hass_globals = [ - "closest", - "distance", - "expand", - "has_value", - "is_state_attr", - "is_state", - "state_attr", - "state_attr_translated", - "state_translated", - "states", - ] - hass_filters = [ - "closest", - "expand", - "has_value", - "state_attr", - "state_attr_translated", - "state_translated", - "states", - ] - hass_tests = [ - "has_value", - "is_state_attr", - "is_state", - ] - for glob in hass_globals: - self.globals[glob] = unsupported(glob) - for filt in hass_filters: - self.filters[filt] = unsupported(filt) - for test in hass_tests: - self.tests[test] = unsupported(test) - return - - self.globals["closest"] = hassfunction(closest) - self.globals["distance"] = hassfunction(distance) - self.globals["expand"] = hassfunction(expand) - self.globals["has_value"] = hassfunction(has_value) - - self.filters["closest"] = hassfunction(closest_filter) - self.filters["expand"] = self.globals["expand"] - self.filters["has_value"] = self.globals["has_value"] - - self.tests["has_value"] = hassfunction(has_value, pass_eval_context) - - # State extensions - - self.globals["is_state_attr"] = hassfunction(is_state_attr) - self.globals["is_state"] = hassfunction(is_state) - self.globals["state_attr"] = hassfunction(state_attr) - self.globals["state_attr_translated"] = StateAttrTranslated(hass) - self.globals["state_translated"] = StateTranslated(hass) - self.globals["states"] = AllStates(hass) - self.filters["state_attr"] = self.globals["state_attr"] - self.filters["state_attr_translated"] = self.globals["state_attr_translated"] - self.filters["state_translated"] = self.globals["state_translated"] - self.filters["states"] = self.globals["states"] - self.tests["is_state_attr"] = hassfunction(is_state_attr, pass_eval_context) - self.tests["is_state"] = hassfunction(is_state, pass_eval_context) + if hass is not None: + # This environment has access to hass, attach its loader + # to enable imports. + self.loader = _get_hass_loader(hass) def is_safe_callable(self, obj): """Test if callback is safe.""" @@ -1160,6 +826,3 @@ def compile( compiled = super().compile(source) self.template_cache[source] = compiled return compiled - - -_NO_HASS_ENV = TemplateEnvironment(None) diff --git a/homeassistant/helpers/template/extensions/__init__.py b/homeassistant/helpers/template/extensions/__init__.py index f3de266cb2cd38..7b15aa0d6d7f8b 100644 --- a/homeassistant/helpers/template/extensions/__init__.py +++ b/homeassistant/helpers/template/extensions/__init__.py @@ -15,6 +15,7 @@ from .math import MathExtension from .regex import RegexExtension from .serialization import SerializationExtension +from .state import StateExtension from .string import StringExtension from .type_cast import TypeCastExtension from .version import VersionExtension @@ -35,6 +36,7 @@ "MathExtension", "RegexExtension", "SerializationExtension", + "StateExtension", "StringExtension", "TypeCastExtension", "VersionExtension", diff --git a/homeassistant/helpers/template/extensions/base.py b/homeassistant/helpers/template/extensions/base.py index 5aae08e33d9178..c2beffb585f400 100644 --- a/homeassistant/helpers/template/extensions/base.py +++ b/homeassistant/helpers/template/extensions/base.py @@ -32,6 +32,9 @@ class TemplateFunction: True # Whether this function is available in limited environments ) requires_hass: bool = False # Whether this function requires hass to be available + pass_context: bool = ( + True # Whether to wrap with pass_context when requires_hass is True + ) def _pass_context[**_P, _R]( @@ -91,7 +94,7 @@ def __init__( func = template_func.func - if template_func.requires_hass: + if template_func.requires_hass and template_func.pass_context: # We wrap these as a context functions to ensure they get # evaluated fresh with every execution, rather than executed # at compile time and the value stored. diff --git a/homeassistant/helpers/template/extensions/state.py b/homeassistant/helpers/template/extensions/state.py new file mode 100644 index 00000000000000..52da777b486e72 --- /dev/null +++ b/homeassistant/helpers/template/extensions/state.py @@ -0,0 +1,355 @@ +"""State functions for Home Assistant templates.""" + +from __future__ import annotations + +import collections.abc +from collections.abc import Iterable +import logging +from typing import TYPE_CHECKING, Any + +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_LATITUDE, + ATTR_LONGITUDE, + ATTR_PERSONS, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfLength, +) +from homeassistant.core import State, valid_entity_id +from homeassistant.helpers import location as loc_helper +from homeassistant.helpers.template.states import ( + AllStates, + StateAttrTranslated, + StateTranslated, + _collect_state, + _get_state, + _resolve_state, +) +from homeassistant.util import convert, location as location_util + +from .base import BaseTemplateExtension, TemplateFunction + +if TYPE_CHECKING: + from homeassistant.helpers.template import TemplateEnvironment + +_LOGGER = logging.getLogger(__name__) + +_SENTINEL = object() + + +class StateExtension(BaseTemplateExtension): + """Jinja2 extension for state functions.""" + + def __init__(self, environment: TemplateEnvironment) -> None: + """Initialize the state extension.""" + # Build the class-based instance functions only when hass is available. + # These use pass_context=False because they are callable class instances + # that should not be wrapped by _pass_context. + class_functions: list[TemplateFunction] = [] + if (hass := environment.hass) is not None: + class_functions = [ + TemplateFunction( + "state_attr_translated", + StateAttrTranslated(hass), + as_global=True, + as_filter=True, + requires_hass=True, + limited_ok=False, + pass_context=False, + ), + TemplateFunction( + "state_translated", + StateTranslated(hass), + as_global=True, + as_filter=True, + requires_hass=True, + limited_ok=False, + pass_context=False, + ), + TemplateFunction( + "states", + AllStates(hass), + as_global=True, + as_filter=True, + requires_hass=True, + limited_ok=False, + pass_context=False, + ), + ] + + super().__init__( + environment, + functions=[ + TemplateFunction( + "closest", + self.closest, + as_global=True, + requires_hass=True, + limited_ok=False, + ), + TemplateFunction( + "closest", + self.closest_filter, + as_filter=True, + requires_hass=True, + limited_ok=False, + ), + TemplateFunction( + "distance", + self.distance, + as_global=True, + requires_hass=True, + limited_ok=False, + ), + TemplateFunction( + "expand", + self.expand, + as_global=True, + as_filter=True, + requires_hass=True, + limited_ok=False, + ), + TemplateFunction( + "has_value", + self.has_value, + as_global=True, + as_filter=True, + as_test=True, + requires_hass=True, + limited_ok=False, + ), + TemplateFunction( + "is_state", + self.is_state, + as_global=True, + as_test=True, + requires_hass=True, + limited_ok=False, + ), + TemplateFunction( + "is_state_attr", + self.is_state_attr, + as_global=True, + as_test=True, + requires_hass=True, + limited_ok=False, + ), + TemplateFunction( + "state_attr", + self.state_attr, + as_global=True, + as_filter=True, + requires_hass=True, + limited_ok=False, + ), + *class_functions, + ], + ) + + def expand(self, *args: Any) -> Iterable[State]: + """Expand out any groups and zones into entity states.""" + # circular import. + from homeassistant.helpers import entity as entity_helper # noqa: PLC0415 + + hass = self.hass + search = list(args) + found = {} + sources = entity_helper.entity_sources(hass) + while search: + entity = search.pop() + if isinstance(entity, str): + entity_id = entity + if (entity := _get_state(hass, entity)) is None: + continue + elif isinstance(entity, State): + entity_id = entity.entity_id + elif isinstance(entity, collections.abc.Iterable): + search += entity + continue + else: + # ignore other types + continue + + if entity_id in found: + continue + + domain = entity.domain + if domain == "group" or ( + (source := sources.get(entity_id)) and source["domain"] == "group" + ): + # Collect state will be called in here since it's wrapped + if group_entities := entity.attributes.get(ATTR_ENTITY_ID): + search += group_entities + elif domain == "zone": + if zone_entities := entity.attributes.get(ATTR_PERSONS): + search += zone_entities + else: + _collect_state(hass, entity_id) + found[entity_id] = entity + + return list(found.values()) + + def closest(self, *args: Any) -> State | None: + """Find closest entity. + + Closest to home: + closest(states) + closest(states.device_tracker) + closest('group.children') + closest(states.group.children) + + Closest to a point: + closest(23.456, 23.456, 'group.children') + closest('zone.school', 'group.children') + closest(states.zone.school, 'group.children') + + As a filter: + states | closest + states.device_tracker | closest + ['group.children', states.device_tracker] | closest + 'group.children' | closest(23.456, 23.456) + states.device_tracker | closest('zone.school') + 'group.children' | closest(states.zone.school) + + """ + hass = self.hass + if len(args) == 1: + latitude = hass.config.latitude + longitude = hass.config.longitude + entities = args[0] + + elif len(args) == 2: + point_state = _resolve_state(hass, args[0]) + + if point_state is None: + _LOGGER.warning("Closest:Unable to find state %s", args[0]) + return None + if not loc_helper.has_location(point_state): + _LOGGER.warning( + "Closest:State does not contain valid location: %s", point_state + ) + return None + + latitude = point_state.attributes[ATTR_LATITUDE] + longitude = point_state.attributes[ATTR_LONGITUDE] + + entities = args[1] + + else: + latitude_arg = convert(args[0], float) + longitude_arg = convert(args[1], float) + + if latitude_arg is None or longitude_arg is None: + _LOGGER.warning( + "Closest:Received invalid coordinates: %s, %s", args[0], args[1] + ) + return None + + latitude = latitude_arg + longitude = longitude_arg + + entities = args[2] + + states = self.expand(entities) + + # state will already be wrapped here + return loc_helper.closest(latitude, longitude, states) + + def closest_filter(self, *args: Any) -> State | None: + """Call closest as a filter. Need to reorder arguments.""" + new_args = list(args[1:]) + new_args.append(args[0]) + return self.closest(*new_args) + + def distance(self, *args: Any) -> float | None: + """Calculate distance. + + Will calculate distance from home to a point or between points. + Points can be passed in using state objects or lat/lng coordinates. + """ + hass = self.hass + locations: list[tuple[float, float]] = [] + + to_process = list(args) + + while to_process: + value = to_process.pop(0) + if isinstance(value, str) and not valid_entity_id(value): + point_state = None + else: + point_state = _resolve_state(hass, value) + + if point_state is None: + # We expect this and next value to be lat&lng + if not to_process: + _LOGGER.warning( + "Distance:Expected latitude and longitude, got %s", value + ) + return None + + value_2 = to_process.pop(0) + latitude_to_process = convert(value, float) + longitude_to_process = convert(value_2, float) + + if latitude_to_process is None or longitude_to_process is None: + _LOGGER.warning( + "Distance:Unable to process latitude and longitude: %s, %s", + value, + value_2, + ) + return None + + latitude = latitude_to_process + longitude = longitude_to_process + + else: + if not loc_helper.has_location(point_state): + _LOGGER.warning( + "Distance:State does not contain valid location: %s", + point_state, + ) + return None + + latitude = point_state.attributes[ATTR_LATITUDE] + longitude = point_state.attributes[ATTR_LONGITUDE] + + locations.append((latitude, longitude)) + + if len(locations) == 1: + return hass.config.distance(*locations[0]) + + return hass.config.units.length( + location_util.distance(*locations[0] + locations[1]), UnitOfLength.METERS + ) + + def is_state(self, entity_id: str, state: str | list[str]) -> bool: + """Test if a state is a specific value.""" + state_obj = _get_state(self.hass, entity_id) + return state_obj is not None and ( + state_obj.state == state + or (isinstance(state, list) and state_obj.state in state) + ) + + def is_state_attr(self, entity_id: str, name: str, value: Any) -> bool: + """Test if a state's attribute is a specific value.""" + if (state_obj := _get_state(self.hass, entity_id)) is not None: + attr = state_obj.attributes.get(name, _SENTINEL) + if attr is _SENTINEL: + return False + return bool(attr == value) + return False + + def state_attr(self, entity_id: str, name: str) -> Any: + """Get a specific attribute from a state.""" + if (state_obj := _get_state(self.hass, entity_id)) is not None: + return state_obj.attributes.get(name) + return None + + def has_value(self, entity_id: str) -> bool: + """Test if an entity has a valid value.""" + state_obj = _get_state(self.hass, entity_id) + + return state_obj is not None and ( + state_obj.state not in [STATE_UNAVAILABLE, STATE_UNKNOWN] + ) diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index dc8f52763c32e8..87d03c0115274c 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -403,12 +403,13 @@ def __init__( def _set_native_value_with_possible_timestamp(self, value: Any) -> None: """Set native value with possible timestamp. - If self.device_class is `date` or `timestamp`, + If self.device_class is `date`, `timestamp`, or `uptime`, it will try to parse the value to a date/datetime object. """ if self.device_class not in ( SensorDeviceClass.DATE, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, ): self._attr_native_value = value elif value is not None: diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 7bed9ca1f28502..aac3ecec0fd9b1 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -199,7 +199,14 @@ def __async_remove_listener_internal(self, listener_id: int) -> None: def async_update_listeners(self) -> None: """Update all registered listeners.""" for update_callback, _ in list(self._listeners.values()): - update_callback() + try: + update_callback() + except Exception: + self.logger.exception( + "Unexpected error updating listener %s for %s", + id(update_callback), + self.name, + ) async def async_shutdown(self) -> None: """Cancel any scheduled call, and ignore new runs.""" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 46229a1ccef694..8d24b6cb4ee027 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==6.1.0 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260325.7 +home-assistant-frontend==20260325.8 home-assistant-intents==2026.3.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/homeassistant/util/aiohttp.py b/homeassistant/util/aiohttp.py index 888da368053db3..b65f64d6ecb8fc 100644 --- a/homeassistant/util/aiohttp.py +++ b/homeassistant/util/aiohttp.py @@ -58,7 +58,7 @@ async def write(self, *args: Any, **kwargs: Any) -> None: class MockRequest: """Mock an aiohttp request.""" - mock_source: str | None = None + mock_source: str def __init__( self, @@ -69,6 +69,7 @@ def __init__( headers: dict[str, str] | None = None, query_string: str | None = None, url: str = "", + remote: str | None = None, ) -> None: """Initialize a request.""" self.method = method @@ -81,6 +82,7 @@ def __init__( self._content = content self.mock_source = mock_source self._payload_writer = _MOCK_PAYLOAD_WRITER + self.remote = remote async def _prepare_hook(self, response: Any) -> None: """Prepare hook.""" diff --git a/requirements.txt b/requirements.txt index dd720c4db0071c..0d2178285f0e64 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,7 +47,7 @@ python-slugify==8.0.4 PyTurboJPEG==1.8.3 PyYAML==6.0.3 requests==2.33.1 -rf-protocols==2.1.0 +rf-protocols==2.2.0 securetar==2026.4.1 SQLAlchemy==2.0.49 standard-aifc==3.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index b89fb6f4be7c14..68df97a18d774b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -56,7 +56,7 @@ PyFlume==0.6.5 PyFronius==0.8.2 # homeassistant.components.pyload -PyLoadAPI==2.0.0 +PyLoadAPI==2.1.0 # homeassistant.components.met_eireann PyMetEireann==2024.11.0 @@ -83,7 +83,7 @@ PyRMVtransport==0.3.3 PySrDaliGateway==0.20.4 # homeassistant.components.switchbot -PySwitchbot==2.1.0 +PySwitchbot==2.2.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -99,7 +99,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.3 # homeassistant.components.vicare -PyViCare==2.59.0 +PyViCare==2.60.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -133,7 +133,7 @@ WSDiscovery==2.1.2 accuweather==5.1.0 # homeassistant.components.actron_air -actron-neo-api==0.5.3 +actron-neo-api==0.5.6 # homeassistant.components.adax adax==0.4.0 @@ -200,7 +200,7 @@ aioambient==2024.08.0 aioapcaccess==1.0.0 # homeassistant.components.aquacell -aioaquacell==0.2.0 +aioaquacell==1.0.0 # homeassistant.components.aseko_pool_live aioaseko==1.0.0 @@ -209,7 +209,7 @@ aioaseko==1.0.0 aioasuswrt==1.5.4 # homeassistant.components.husqvarna_automower -aioautomower==2.7.3 +aioautomower==2.7.4 # homeassistant.components.azure_devops aioazuredevops==2.2.2 @@ -224,7 +224,7 @@ aiobafi6==0.9.0 aiobotocore==2.21.1 # homeassistant.components.comelit -aiocomelit==2.0.2 +aiocomelit==2.0.3 # homeassistant.components.dhcp aiodhcpwatcher==1.2.1 @@ -324,7 +324,7 @@ aiolookin==1.0.0 aiolyric==2.0.2 # homeassistant.components.mealie -aiomealie==1.2.3 +aiomealie==1.2.4 # homeassistant.components.modern_forms aiomodernforms==0.1.8 @@ -339,7 +339,7 @@ aionanoleaf2==1.0.2 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.8.4 +aiontfy==0.8.5 # homeassistant.components.nut aionut==4.3.4 @@ -399,7 +399,7 @@ aiorussound==5.0.1 aioruuvigateway==0.1.0 # homeassistant.components.shelly -aioshelly==13.24.0 +aioshelly==13.24.1 # homeassistant.components.skybell aioskybell==22.7.0 @@ -600,7 +600,7 @@ avea==1.6.1 # avion==0.10 # homeassistant.components.axis -axis==68 +axis==69 # homeassistant.components.fujitsu_fglair ayla-iot-unofficial==1.4.7 @@ -794,7 +794,7 @@ debugpy==1.8.17 decora-wifi==1.4 # homeassistant.components.ecovacs -deebot-client==18.1.0 +deebot-client==18.2.0 # homeassistant.components.ihc # homeassistant.components.ohmconnect @@ -859,7 +859,7 @@ eagle100==0.1.1 earn-e-p1==0.1.0 # homeassistant.components.easyenergy -easyenergy==2.2.0 +easyenergy==3.0.0 # homeassistant.components.ebusd ebusdpy==0.0.17 @@ -1025,7 +1025,7 @@ forecast-solar==5.0.0 fortiosapi==1.0.5 # homeassistant.components.freebox -freebox-api==1.3.0 +freebox-api==1.3.1 # homeassistant.components.free_mobile freesms==0.2.0 @@ -1038,7 +1038,7 @@ fressnapftracker==0.2.2 fritzconnection[qr]==1.15.1 # homeassistant.components.fumis -fumis==0.3.0 +fumis==0.4.0 # homeassistant.components.fyta fyta_cli==0.7.2 @@ -1135,7 +1135,7 @@ google_air_quality_api==3.0.1 goslide-api==0.7.0 # homeassistant.components.tailwind -gotailwind==0.3.0 +gotailwind==0.4.0 # homeassistant.components.govee_ble govee-ble==1.2.0 @@ -1242,10 +1242,10 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.94 +holidays==0.95 # homeassistant.components.frontend -home-assistant-frontend==20260325.7 +home-assistant-frontend==20260325.8 # homeassistant.components.conversation home-assistant-intents==2026.3.24 @@ -1284,7 +1284,7 @@ hyponcloud==0.9.3 iammeter==0.2.1 # homeassistant.components.iaqualink -iaqualink==0.6.0 +iaqualink==0.7.0 # homeassistant.components.ibeacon ibeacon-ble==1.2.0 @@ -1329,7 +1329,7 @@ imgw_pib==2.1.1 incomfort-client==0.7.0 # homeassistant.components.indevolt -indevolt-api==1.4.2 +indevolt-api==1.6.4 # homeassistant.components.influxdb influxdb-client==1.50.0 @@ -1368,7 +1368,7 @@ isal==1.8.0 ismartgate==5.0.2 # homeassistant.components.israel_rail -israel-rail-api==0.1.4 +israel-rail-api==0.1.5 # homeassistant.components.abode jaraco.abode==6.4.0 @@ -1402,7 +1402,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2026.4.22.141111 +knx-frontend==2026.4.25.155016 # homeassistant.components.konnected konnected==1.2.0 @@ -2222,7 +2222,7 @@ pyitachip2ir==0.0.7 pyituran==0.1.5 # homeassistant.components.jvc_projector -pyjvcprojector==2.0.5 +pyjvcprojector==2.0.6 # homeassistant.components.kaleidescape pykaleidescape==1.1.5 @@ -2578,7 +2578,7 @@ python-digitalocean==1.13.2 python-dropbox-api==0.1.3 # homeassistant.components.duco -python-duco-client==0.3.6 +python-duco-client==0.3.9 # homeassistant.components.ecobee python-ecobee-api==0.3.2 @@ -2651,7 +2651,7 @@ python-opensky==1.0.1 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.9.0 +python-otbr-api==2.10.0 # homeassistant.components.overseerr python-overseerr==0.9.0 @@ -2840,8 +2840,10 @@ renson-endura-delta==1.7.2 # homeassistant.components.reolink reolink-aio==0.19.1 +# homeassistant.components.honeywell_string_lights +# homeassistant.components.novy_cooker_hood # homeassistant.components.radio_frequency -rf-protocols==2.1.0 +rf-protocols==2.2.0 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2895,7 +2897,7 @@ samsungtvws[async,encrypted]==2.7.2 sanix==1.0.6 # homeassistant.components.satel_integra -satel-integra==1.2.1 +satel-integra==1.2.2 # homeassistant.components.screenlogic screenlogicpy==0.10.2 @@ -3095,7 +3097,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.4.5 +tesla-fleet-api==1.4.7 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -3191,7 +3193,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==10.3.1 +uiprotect==10.4.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -3232,7 +3234,7 @@ vallox-websocket-api==6.0.0 vegehub==0.1.26 # homeassistant.components.rdw -vehicle==2.2.2 +vehicle==3.0.0 # homeassistant.components.velbus velbus-aio==2026.4.1 @@ -3372,7 +3374,7 @@ yeelight==0.7.16 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.6.3 +yolink-api==0.6.5 # homeassistant.components.youless youless-api==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3bf6b20f230632..c524e04ce74ac4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -56,7 +56,7 @@ PyFlume==0.6.5 PyFronius==0.8.2 # homeassistant.components.pyload -PyLoadAPI==2.0.0 +PyLoadAPI==2.1.0 # homeassistant.components.met_eireann PyMetEireann==2024.11.0 @@ -83,7 +83,7 @@ PyRMVtransport==0.3.3 PySrDaliGateway==0.20.4 # homeassistant.components.switchbot -PySwitchbot==2.1.0 +PySwitchbot==2.2.0 # homeassistant.components.syncthru PySyncThru==0.8.0 @@ -96,7 +96,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.3 # homeassistant.components.vicare -PyViCare==2.59.0 +PyViCare==2.60.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -124,7 +124,7 @@ WSDiscovery==2.1.2 accuweather==5.1.0 # homeassistant.components.actron_air -actron-neo-api==0.5.3 +actron-neo-api==0.5.6 # homeassistant.components.adax adax==0.4.0 @@ -191,7 +191,7 @@ aioambient==2024.08.0 aioapcaccess==1.0.0 # homeassistant.components.aquacell -aioaquacell==0.2.0 +aioaquacell==1.0.0 # homeassistant.components.aseko_pool_live aioaseko==1.0.0 @@ -200,7 +200,7 @@ aioaseko==1.0.0 aioasuswrt==1.5.4 # homeassistant.components.husqvarna_automower -aioautomower==2.7.3 +aioautomower==2.7.4 # homeassistant.components.azure_devops aioazuredevops==2.2.2 @@ -215,7 +215,7 @@ aiobafi6==0.9.0 aiobotocore==2.21.1 # homeassistant.components.comelit -aiocomelit==2.0.2 +aiocomelit==2.0.3 # homeassistant.components.dhcp aiodhcpwatcher==1.2.1 @@ -309,7 +309,7 @@ aiolookin==1.0.0 aiolyric==2.0.2 # homeassistant.components.mealie -aiomealie==1.2.3 +aiomealie==1.2.4 # homeassistant.components.modern_forms aiomodernforms==0.1.8 @@ -324,7 +324,7 @@ aionanoleaf2==1.0.2 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.8.4 +aiontfy==0.8.5 # homeassistant.components.nut aionut==4.3.4 @@ -384,7 +384,7 @@ aiorussound==5.0.1 aioruuvigateway==0.1.0 # homeassistant.components.shelly -aioshelly==13.24.0 +aioshelly==13.24.1 # homeassistant.components.skybell aioskybell==22.7.0 @@ -552,7 +552,7 @@ autoskope_client==1.4.1 av==16.0.1 # homeassistant.components.axis -axis==68 +axis==69 # homeassistant.components.fujitsu_fglair ayla-iot-unofficial==1.4.7 @@ -706,7 +706,7 @@ debugpy==1.8.17 decora-wifi==1.4 # homeassistant.components.ecovacs -deebot-client==18.1.0 +deebot-client==18.2.0 # homeassistant.components.ihc # homeassistant.components.ohmconnect @@ -768,7 +768,7 @@ eagle100==0.1.1 earn-e-p1==0.1.0 # homeassistant.components.easyenergy -easyenergy==2.2.0 +easyenergy==3.0.0 # homeassistant.components.egauge egauge-async==0.4.0 @@ -910,7 +910,7 @@ foobot_async==1.0.0 forecast-solar==5.0.0 # homeassistant.components.freebox -freebox-api==1.3.0 +freebox-api==1.3.1 # homeassistant.components.fressnapf_tracker fressnapftracker==0.2.2 @@ -920,7 +920,7 @@ fressnapftracker==0.2.2 fritzconnection[qr]==1.15.1 # homeassistant.components.fumis -fumis==0.3.0 +fumis==0.4.0 # homeassistant.components.fyta fyta_cli==0.7.2 @@ -1014,7 +1014,7 @@ google_air_quality_api==3.0.1 goslide-api==0.7.0 # homeassistant.components.tailwind -gotailwind==0.3.0 +gotailwind==0.4.0 # homeassistant.components.govee_ble govee-ble==1.2.0 @@ -1106,10 +1106,10 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.94 +holidays==0.95 # homeassistant.components.frontend -home-assistant-frontend==20260325.7 +home-assistant-frontend==20260325.8 # homeassistant.components.conversation home-assistant-intents==2026.3.24 @@ -1142,7 +1142,7 @@ hyperion-py==0.7.6 hyponcloud==0.9.3 # homeassistant.components.iaqualink -iaqualink==0.6.0 +iaqualink==0.7.0 # homeassistant.components.ibeacon ibeacon-ble==1.2.0 @@ -1181,7 +1181,7 @@ imgw_pib==2.1.1 incomfort-client==0.7.0 # homeassistant.components.indevolt -indevolt-api==1.4.2 +indevolt-api==1.6.4 # homeassistant.components.influxdb influxdb-client==1.50.0 @@ -1217,7 +1217,7 @@ isal==1.8.0 ismartgate==5.0.2 # homeassistant.components.israel_rail -israel-rail-api==0.1.4 +israel-rail-api==0.1.5 # homeassistant.components.abode jaraco.abode==6.4.0 @@ -1242,7 +1242,7 @@ kiosker-python-api==1.2.9 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2026.4.22.141111 +knx-frontend==2026.4.25.155016 # homeassistant.components.konnected konnected==1.2.0 @@ -1905,7 +1905,7 @@ pyisy==3.4.1 pyituran==0.1.5 # homeassistant.components.jvc_projector -pyjvcprojector==2.0.5 +pyjvcprojector==2.0.6 # homeassistant.components.kaleidescape pykaleidescape==1.1.5 @@ -2204,7 +2204,7 @@ python-citybikes==0.3.3 python-dropbox-api==0.1.3 # homeassistant.components.duco -python-duco-client==0.3.6 +python-duco-client==0.3.9 # homeassistant.components.ecobee python-ecobee-api==0.3.2 @@ -2259,7 +2259,7 @@ python-opensky==1.0.1 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.9.0 +python-otbr-api==2.10.0 # homeassistant.components.overseerr python-overseerr==0.9.0 @@ -2424,8 +2424,10 @@ renson-endura-delta==1.7.2 # homeassistant.components.reolink reolink-aio==0.19.1 +# homeassistant.components.honeywell_string_lights +# homeassistant.components.novy_cooker_hood # homeassistant.components.radio_frequency -rf-protocols==2.1.0 +rf-protocols==2.2.0 # homeassistant.components.rflink rflink==0.0.67 @@ -2467,7 +2469,7 @@ samsungtvws[async,encrypted]==2.7.2 sanix==1.0.6 # homeassistant.components.satel_integra -satel-integra==1.2.1 +satel-integra==1.2.2 # homeassistant.components.screenlogic screenlogicpy==0.10.2 @@ -2625,7 +2627,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.4.5 +tesla-fleet-api==1.4.7 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2712,7 +2714,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==10.3.1 +uiprotect==10.4.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2747,7 +2749,7 @@ vallox-websocket-api==6.0.0 vegehub==0.1.26 # homeassistant.components.rdw -vehicle==2.2.2 +vehicle==3.0.0 # homeassistant.components.velbus velbus-aio==2026.4.1 @@ -2866,7 +2868,7 @@ yalexs==9.2.0 yeelight==0.7.16 # homeassistant.components.yolink -yolink-api==0.6.3 +yolink-api==0.6.5 # homeassistant.components.youless youless-api==2.2.0 diff --git a/script/gen_copilot_instructions.py b/script/gen_copilot_instructions.py index ed8c75d18f5b20..a057de587bbe7f 100755 --- a/script/gen_copilot_instructions.py +++ b/script/gen_copilot_instructions.py @@ -13,57 +13,54 @@ f"\n\n" ) -SKILLS_DIR = Path(".claude/skills") AGENTS_FILE = Path("AGENTS.md") OUTPUT_FILE = Path(".github/copilot-instructions.md") +INTEGRATION_SKILL_FILE = Path(".claude/skills/ha-integration-knowledge/SKILL.md") +INTEGRATION_PATH_SPECIFIC_OUTPUT_FILE = Path( + ".github/instructions/integrations.instructions.md" +) -EXCLUDED_SKILLS = {"github-pr-reviewer"} COPILOT_SPECIFIC_INSTRUCTIONS = """ # Copilot code review instructions - Start review comments with a short, one-sentence summary of the suggested fix. -- Do not add comments about code style, formatting or linting issues. +- Do not comment on code style, formatting or linting issues. """ +INTEGRATION_PATH_SPECIFIC_INSTRUCTIONS = """--- +applyTo: "homeassistant/components/**, tests/components/**" +excludeAgent: "cloud-agent" +--- +""" -def gather_skills() -> list[tuple[str, Path]]: - """Gather all skills from the skills directory. - - Returns a list of tuples (skill_name, skill_file_path). - """ - skills: list[tuple[str, Path]] = [] - - if not SKILLS_DIR.exists(): - return skills - for skill_dir in sorted(SKILLS_DIR.iterdir()): - if not skill_dir.is_dir(): - continue +def _strip_frontmatter(text: str) -> str: + """Strip YAML frontmatter from the start of a markdown document.""" + if not text.startswith("---\n"): + return text - if skill_dir.name in EXCLUDED_SKILLS: - continue + end = text.find("\n---\n", 4) + if end == -1: + return text - skill_file = skill_dir / "SKILL.md" - if not skill_file.exists(): - continue + return text[end + len("\n---\n") :].lstrip("\n") - skill_content = skill_file.read_text() - # Extract skill name from frontmatter if present - skill_name = skill_dir.name - if skill_content.startswith("---"): - # Parse YAML frontmatter - end_idx = skill_content.find("---", 3) - if end_idx != -1: - frontmatter = skill_content[3:end_idx] - for line in frontmatter.split("\n"): - if line.startswith("name:"): - skill_name = line[5:].strip() - break +def generate_integration_path_specific_instructions() -> str: + """Generate instructions for integration paths.""" + if not INTEGRATION_SKILL_FILE.exists(): + print(f"Error: {INTEGRATION_SKILL_FILE} not found") + sys.exit(1) - skills.append((skill_name, skill_file)) + skill_content = _strip_frontmatter(INTEGRATION_SKILL_FILE.read_text()) - return skills + return ( + INTEGRATION_PATH_SPECIFIC_INSTRUCTIONS + + "\n" + + GENERATED_MESSAGE + + "\n" + + skill_content + ) def generate_output() -> str: @@ -79,43 +76,47 @@ def generate_output() -> str: output_parts.append(agents_content.strip()) output_parts.append("") - # Add skills section as a bullet list of name: path - skills = gather_skills() - if skills: - output_parts.append("") - output_parts.append("# Skills") - output_parts.append("") - for skill_name, skill_file in skills: - output_parts.append(f"- {skill_name}: {skill_file}") - output_parts.append("") - return "\n".join(output_parts) +def check_file(path: Path, expected_content: str): + """Check if the file exists and has the expected content.""" + if not path.exists(): + print(f"Error: {path} does not exist") + sys.exit(1) + + existing = path.read_text() + if existing != expected_content: + print(f"Error: {path} is out of date") + print("Please run: python -m script.gen_copilot_instructions") + sys.exit(1) + + print(f"{path} is up to date") + + def main(validate: bool = False) -> int: """Run the script.""" if not Path("homeassistant").is_dir(): print("Run this from HA root dir") return 1 - content = generate_output() + main_content = generate_output() + integration_path_specific_content = ( + generate_integration_path_specific_instructions() + ) if validate: - if not OUTPUT_FILE.exists(): - print(f"Error: {OUTPUT_FILE} does not exist") - return 1 - - existing = OUTPUT_FILE.read_text() - if existing != content: - print(f"Error: {OUTPUT_FILE} is out of date") - print("Please run: python -m script.gen_copilot_instructions") - return 1 - - print(f"{OUTPUT_FILE} is up to date") + check_file(OUTPUT_FILE, main_content) + check_file( + INTEGRATION_PATH_SPECIFIC_OUTPUT_FILE, integration_path_specific_content + ) return 0 - OUTPUT_FILE.write_text(content) + OUTPUT_FILE.write_text(main_content) print(f"Generated {OUTPUT_FILE}") + + INTEGRATION_PATH_SPECIFIC_OUTPUT_FILE.write_text(integration_path_specific_content) + print(f"Generated {INTEGRATION_PATH_SPECIFIC_OUTPUT_FILE}") return 0 diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index fe48e8a8607df4..0993b90d348e8a 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -13,6 +13,10 @@ from . import ast_parse_module from .model import Config, Integration +# Duplicated from homeassistant.bootstrap to avoid importing bootstrap (and its +# eager component pre-imports) into hassfest. Kept in sync via test_dependencies. +CORE_INTEGRATIONS = {"homeassistant", "persistent_notification"} + class ImportCollector(ast.NodeVisitor): """Collect all integrations referenced.""" @@ -86,6 +90,7 @@ def visit_Import(self, node: ast.Import) -> None: ALLOWED_USED_COMPONENTS = { + *CORE_INTEGRATIONS, *{platform.value for platform in Platform}, # Internal integrations "alert", @@ -95,7 +100,6 @@ def visit_Import(self, node: ast.Import) -> None: "device_automation", "frontend", "group", - "homeassistant", "input_boolean", "input_button", "input_datetime", @@ -106,7 +110,6 @@ def visit_Import(self, node: ast.Import) -> None: "media_source", "onboarding", "panel_custom", - "persistent_notification", "person", "script", "shopping_list", @@ -332,6 +335,13 @@ def _validate_dependencies( "dependencies", f"Dependency {dep} does not exist" ) + if dep in CORE_INTEGRATIONS: + integration.add_error( + "dependencies", + f"Dependency {dep} is a core integration and is " + "unconditionally loaded", + ) + def validate( integrations: dict[str, Integration], diff --git a/script/hassfest/integration_type.py b/script/hassfest/integration_type.py index e9d13a85b050c5..52b927ed2e0b8c 100644 --- a/script/hassfest/integration_type.py +++ b/script/hassfest/integration_type.py @@ -37,7 +37,6 @@ "gree", "holiday", "homekit", - "html5", "ifttt", "influxdb", "ios", @@ -54,7 +53,6 @@ "modern_forms", "ness_alarm", "nmap_tracker", - "otp", "profiler", "proximity", "rhasspy", diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index f2dc1d77b3da4b..93e54ca5bec651 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1293,7 +1293,6 @@ class Rule: "eliqonline", "elkm1", "elmax", - "elgato", "elv", "elvia", "emby", diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 5ab54be3ac9ad0..d8d31899eedf6c 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -729,7 +729,7 @@ def check_dependency_files( if not (top := file.parts[0].lower()).endswith((".dist-info", ".py")): top_level.add(top) if (name := str(file).lower()) in FORBIDDEN_FILE_NAMES or ( - name.endswith(".pth") and len(file.parts) == 1 + name.endswith((".pth", ".start")) and len(file.parts) == 1 ): file_names.add(str(file)) results = _PackageFilesCheckResult( diff --git a/tests/components/accuweather/__init__.py b/tests/components/accuweather/__init__.py index 0e5313ceb94838..8c01630b29e42b 100644 --- a/tests/components/accuweather/__init__.py +++ b/tests/components/accuweather/__init__.py @@ -16,7 +16,6 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: "api_key": "32-character-string-1234567890qw", "latitude": 55.55, "longitude": 122.12, - "name": "Home", }, ) diff --git a/tests/components/accuweather/conftest.py b/tests/components/accuweather/conftest.py index abecc7cc198eea..d70f688010d9e1 100644 --- a/tests/components/accuweather/conftest.py +++ b/tests/components/accuweather/conftest.py @@ -33,6 +33,7 @@ def mock_accuweather_client() -> Generator[AsyncMock]: client.async_get_daily_forecast.return_value = daily_forecast client.async_get_hourly_forecast.return_value = hourly_forecast client.location_key = "0123456" + client.location_name = "Test location" client.requests_remaining = 10 yield client diff --git a/tests/components/accuweather/snapshots/test_diagnostics.ambr b/tests/components/accuweather/snapshots/test_diagnostics.ambr index 7477602f3a4c4d..29692aea368358 100644 --- a/tests/components/accuweather/snapshots/test_diagnostics.ambr +++ b/tests/components/accuweather/snapshots/test_diagnostics.ambr @@ -5,7 +5,6 @@ 'api_key': '**REDACTED**', 'latitude': '**REDACTED**', 'longitude': '**REDACTED**', - 'name': 'Home', }), 'observation_data': dict({ 'ApparentTemperature': dict({ diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py index f17f4362aca329..62822db4d2e516 100644 --- a/tests/components/accuweather/test_config_flow.py +++ b/tests/components/accuweather/test_config_flow.py @@ -7,7 +7,7 @@ from homeassistant.components.accuweather.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -16,7 +16,6 @@ from tests.common import MockConfigEntry VALID_CONFIG = { - CONF_NAME: "abcd", CONF_API_KEY: "32-character-string-1234567890qw", CONF_LATITUDE: 55.55, CONF_LONGITUDE: 122.12, @@ -115,8 +114,7 @@ async def test_create_entry( ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "abcd" - assert result["data"][CONF_NAME] == "abcd" + assert result["title"] == "Test location" assert result["data"][CONF_LATITUDE] == 55.55 assert result["data"][CONF_LONGITUDE] == 122.12 assert result["data"][CONF_API_KEY] == "32-character-string-1234567890qw" diff --git a/tests/components/actron_air/fixtures/status.json b/tests/components/actron_air/fixtures/status.json index fdb5c01dabca9a..0f6c9c1f1017e2 100644 --- a/tests/components/actron_air/fixtures/status.json +++ b/tests/components/actron_air/fixtures/status.json @@ -22,6 +22,13 @@ "TurboMode": { "Enabled": false, "Supported": true + }, + "ModeSupport": { + "Cool": true, + "Heat": true, + "Fan": true, + "Auto": true, + "Dry": false } }, "MasterInfo": { diff --git a/tests/components/actron_air/test_climate.py b/tests/components/actron_air/test_climate.py index 292513e03bd0b9..5ac2b84c12d8e4 100644 --- a/tests/components/actron_air/test_climate.py +++ b/tests/components/actron_air/test_climate.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch from actron_neo_api import ActronAirAPIError +from actron_neo_api.models.settings import ActronAirModeSupport import pytest from syrupy.assertion import SnapshotAssertion @@ -362,3 +363,122 @@ async def test_zone_hvac_mode_inactive( state = hass.states.get("climate.living_room") assert state.state == "off" + + +async def test_system_hvac_modes_default( + hass: HomeAssistant, + mock_actron_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test system reports correct HVAC modes when DRY is not supported.""" + with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.test_system") + assert state.attributes["hvac_modes"] == [ + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.AUTO, + HVACMode.OFF, + ] + + +async def test_system_hvac_modes_with_dry( + hass: HomeAssistant, + mock_actron_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test system reports DRY HVAC mode when hardware supports it.""" + + status = mock_actron_api.state_manager.get_status.return_value + status.user_aircon_settings.mode_support = ActronAirModeSupport( + Cool=True, Heat=True, Fan=True, Auto=True, Dry=True + ) + + with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.test_system") + assert state.attributes["hvac_modes"] == [ + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.AUTO, + HVACMode.DRY, + HVACMode.OFF, + ] + + +async def test_system_hvac_modes_no_mode_support( + hass: HomeAssistant, + mock_actron_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test system falls back to default modes when ModeSupport is absent.""" + status = mock_actron_api.state_manager.get_status.return_value + status.user_aircon_settings.mode_support = None + + with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.test_system") + assert state.attributes["hvac_modes"] == [ + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.AUTO, + HVACMode.OFF, + ] + + +async def test_zone_hvac_modes_with_dry( + hass: HomeAssistant, + mock_actron_api: MagicMock, + mock_config_entry: MockConfigEntry, + mock_zone: MagicMock, +) -> None: + """Test zone reports DRY HVAC mode when hardware supports it.""" + + status = mock_actron_api.state_manager.get_status.return_value + status.user_aircon_settings.mode_support = ActronAirModeSupport( + Cool=True, Heat=True, Fan=True, Auto=True, Dry=True + ) + status.remote_zone_info = [mock_zone] + + with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.living_room") + assert state.attributes["hvac_modes"] == [ + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.AUTO, + HVACMode.DRY, + HVACMode.OFF, + ] + + +async def test_zone_hvac_modes_no_mode_support( + hass: HomeAssistant, + mock_actron_api: MagicMock, + mock_config_entry: MockConfigEntry, + mock_zone: MagicMock, +) -> None: + """Test zone falls back to default modes when ModeSupport is absent.""" + status = mock_actron_api.state_manager.get_status.return_value + status.user_aircon_settings.mode_support = None + status.remote_zone_info = [mock_zone] + + with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.living_room") + assert state.attributes["hvac_modes"] == [ + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.AUTO, + HVACMode.OFF, + ] diff --git a/tests/components/airly/__init__.py b/tests/components/airly/__init__.py index 401bf641350cfb..199c7a26870220 100644 --- a/tests/components/airly/__init__.py +++ b/tests/components/airly/__init__.py @@ -29,7 +29,6 @@ async def init_integration( "api_key": "foo", "latitude": 123, "longitude": 456, - "name": "Home", }, ) diff --git a/tests/components/airly/snapshots/test_diagnostics.ambr b/tests/components/airly/snapshots/test_diagnostics.ambr index 1c760eaec52244..925e48237c1c8c 100644 --- a/tests/components/airly/snapshots/test_diagnostics.ambr +++ b/tests/components/airly/snapshots/test_diagnostics.ambr @@ -6,7 +6,6 @@ 'api_key': '**REDACTED**', 'latitude': '**REDACTED**', 'longitude': '**REDACTED**', - 'name': 'Home', }), 'disabled_by': None, 'discovery_keys': dict({ diff --git a/tests/components/airly/snapshots/test_sensor.ambr b/tests/components/airly/snapshots/test_sensor.ambr index b70c89c3d78ebe..224543057ecfa9 100644 --- a/tests/components/airly/snapshots/test_sensor.ambr +++ b/tests/components/airly/snapshots/test_sensor.ambr @@ -29,14 +29,14 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Carbon monoxide', 'platform': 'airly', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'co', + 'translation_key': None, 'unique_id': '123-456-co', 'unit_of_measurement': 'μg/m³', }) @@ -45,6 +45,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Airly', + 'device_class': 'carbon_monoxide', 'friendly_name': 'Home Carbon monoxide', 'limit': 4000, 'percent': 4, diff --git a/tests/components/airly/test_config_flow.py b/tests/components/airly/test_config_flow.py index 482c97799f6a0c..f6687f787492fa 100644 --- a/tests/components/airly/test_config_flow.py +++ b/tests/components/airly/test_config_flow.py @@ -4,9 +4,9 @@ from airly.exceptions import AirlyError -from homeassistant.components.airly.const import CONF_USE_NEAREST, DOMAIN +from homeassistant.components.airly.const import CONF_USE_NEAREST, DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -16,7 +16,6 @@ from tests.test_util.aiohttp import AiohttpClientMocker CONFIG = { - CONF_NAME: "Home", CONF_API_KEY: "foo", CONF_LATITUDE: 123, CONF_LONGITUDE: 456, @@ -124,7 +123,7 @@ async def test_create_entry( ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == CONFIG[CONF_NAME] + assert result["title"] == DEFAULT_NAME assert result["data"][CONF_LATITUDE] == CONFIG[CONF_LATITUDE] assert result["data"][CONF_LONGITUDE] == CONFIG[CONF_LONGITUDE] assert result["data"][CONF_API_KEY] == CONFIG[CONF_API_KEY] @@ -151,7 +150,7 @@ async def test_create_entry_with_nearest_method( ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == CONFIG[CONF_NAME] + assert result["title"] == DEFAULT_NAME assert result["data"][CONF_LATITUDE] == CONFIG[CONF_LATITUDE] assert result["data"][CONF_LONGITUDE] == CONFIG[CONF_LONGITUDE] assert result["data"][CONF_API_KEY] == CONFIG[CONF_API_KEY] diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py index ea24fe80c0aa03..da606d718a3b62 100644 --- a/tests/components/airly/test_init.py +++ b/tests/components/airly/test_init.py @@ -43,7 +43,6 @@ async def test_config_not_ready( "api_key": "foo", "latitude": 123, "longitude": 456, - "name": "Home", "use_nearest": True, }, ) @@ -65,7 +64,6 @@ async def test_config_without_unique_id( "api_key": "foo", "latitude": 123, "longitude": 456, - "name": "Home", }, ) @@ -90,7 +88,6 @@ async def test_config_with_turned_off_station( "api_key": "foo", "latitude": 123, "longitude": 456, - "name": "Home", }, ) @@ -122,7 +119,6 @@ async def test_update_interval( "api_key": "foo", "latitude": 123, "longitude": 456, - "name": "Home", }, ) @@ -157,7 +153,6 @@ async def test_update_interval( "api_key": "foo", "latitude": 66.66, "longitude": 111.11, - "name": "Work", }, ) @@ -216,7 +211,6 @@ async def test_migrate_device_entry( "api_key": "foo", "latitude": 123, "longitude": 456, - "name": "Home", }, ) diff --git a/tests/components/alarm_control_panel/test_condition.py b/tests/components/alarm_control_panel/test_condition.py index 5ecbc088d99bad..22e215126ea53b 100644 --- a/tests/components/alarm_control_panel/test_condition.py +++ b/tests/components/alarm_control_panel/test_condition.py @@ -16,6 +16,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, other_states, parametrize_condition_states_all, parametrize_condition_states_any, @@ -49,6 +50,36 @@ async def test_alarm_control_panel_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("alarm_control_panel.is_armed", {}, True, False), + ("alarm_control_panel.is_armed_away", {}, True, True), + ("alarm_control_panel.is_armed_home", {}, True, True), + ("alarm_control_panel.is_armed_night", {}, True, True), + ("alarm_control_panel.is_armed_vacation", {}, True, True), + ("alarm_control_panel.is_disarmed", {}, True, True), + ("alarm_control_panel.is_triggered", {}, True, True), + ], +) +async def test_alarm_control_panel_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that alarm_control_panel conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py index d40b562a56e1f5..b2cdeddacab1ea 100644 --- a/tests/components/alexa_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -13,7 +13,13 @@ ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_PASSWORD, TEST_USERNAME +from .const import ( + TEST_DEVICE_1, + TEST_DEVICE_1_SN, + TEST_PASSWORD, + TEST_USER_ID, + TEST_USERNAME, +) from tests.common import MockConfigEntry @@ -44,12 +50,13 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]: client = mock_client.return_value client.login = AsyncMock() client.login.login_mode_interactive.return_value = { - "customer_info": {"user_id": TEST_USERNAME}, + "customer_info": {"user_id": TEST_USER_ID}, CONF_SITE: "https://www.amazon.com", } client.get_devices_data.return_value = { TEST_DEVICE_1_SN: deepcopy(TEST_DEVICE_1) } + client.routines = ["Test Routine"] client.send_sound_notification = AsyncMock() yield client @@ -59,7 +66,7 @@ def mock_config_entry() -> MockConfigEntry: """Mock a config entry.""" return MockConfigEntry( domain=DOMAIN, - title="Amazon Test Account", + title=TEST_USERNAME, data={ CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, @@ -68,7 +75,7 @@ def mock_config_entry() -> MockConfigEntry: CONF_SITE: "https://www.amazon.com", }, }, - unique_id=TEST_USERNAME, + unique_id=TEST_USER_ID, version=1, minor_version=3, ) diff --git a/tests/components/alexa_devices/const.py b/tests/components/alexa_devices/const.py index 77eee5133b1cee..e01647a71dcfa7 100644 --- a/tests/components/alexa_devices/const.py +++ b/tests/components/alexa_devices/const.py @@ -12,6 +12,7 @@ TEST_CODE = "023123" TEST_PASSWORD = "fake_password" TEST_USERNAME = "fake_email@gmail.com" +TEST_USER_ID = "amzn1.account.fake_user_id" TEST_DEVICE_1_SN = "echo_test_serial_number" TEST_DEVICE_1_ID = "echo_test_device_id" diff --git a/tests/components/alexa_devices/snapshots/test_button.ambr b/tests/components/alexa_devices/snapshots/test_button.ambr new file mode 100644 index 00000000000000..ce237adb111924 --- /dev/null +++ b/tests/components/alexa_devices/snapshots/test_button.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_all_entities[button.fake_email_gmail_com_test_routine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.fake_email_gmail_com_test_routine', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Test Routine', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Test Routine', + 'platform': 'alexa_devices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'amzn1_account_fake_user_id-test_routine', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.fake_email_gmail_com_test_routine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_email@gmail.com Test Routine', + }), + 'context': , + 'entity_id': 'button.fake_email_gmail_com_test_routine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr index 7388d97d158016..8c30470005f643 100644 --- a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr +++ b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr @@ -97,7 +97,7 @@ 'subentries': list([ ]), 'title': '**REDACTED**', - 'unique_id': 'fake_email@gmail.com', + 'unique_id': 'amzn1.account.fake_user_id', 'version': 1, }), }) diff --git a/tests/components/alexa_devices/test_button.py b/tests/components/alexa_devices/test_button.py new file mode 100644 index 00000000000000..a1bba9b7e1a94d --- /dev/null +++ b/tests/components/alexa_devices/test_button.py @@ -0,0 +1,96 @@ +"""Test Alexa Devices button entities.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import slugify + +from . import setup_integration +from .const import TEST_USERNAME + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + + with patch("homeassistant.components.alexa_devices.PLATFORMS", [Platform.BUTTON]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_pressing_routine_button( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test routine run button.""" + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: f"button.{slugify(TEST_USERNAME)}_test_routine"}, + blocking=True, + ) + mock_amazon_devices_client.call_routine.assert_called_once() + + +@pytest.mark.parametrize( + ("initial_routine", "updated_routines"), + [ + (["Test Routine"], ["Test Routine", "New Routine"]), # Add a routine + (["Test Routine", "New Routine"], ["Test Routine"]), # Remove a routine + (["Test Routine"], []), # Remove all routines + ], +) +async def test_dynamic_entities( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + initial_routine: list[str], + updated_routines: list[str], +) -> None: + """Test entities are dynamically created and deleted.""" + + mock_amazon_devices_client.routines = initial_routine + + await setup_integration(hass, mock_config_entry) + + # Check initial routine(s) exist + for routine in initial_routine: + entity_id = f"button.{slugify(TEST_USERNAME)}_{slugify(routine)}" + assert hass.states.get(entity_id) is not None + + mock_amazon_devices_client.routines = updated_routines + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # After update, check which routines should exist + for routine in updated_routines: + entity_id = f"button.{slugify(TEST_USERNAME)}_{slugify(routine)}" + assert hass.states.get(entity_id) is not None + + # Check routines that were removed no longer exist + for routine in set(initial_routine) - set(updated_routines): + entity_id = f"button.{slugify(TEST_USERNAME)}_{slugify(routine)}" + assert hass.states.get(entity_id) is None diff --git a/tests/components/alexa_devices/test_config_flow.py b/tests/components/alexa_devices/test_config_flow.py index 1368b357610467..7b19e28c080eac 100644 --- a/tests/components/alexa_devices/test_config_flow.py +++ b/tests/components/alexa_devices/test_config_flow.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import TEST_CODE, TEST_PASSWORD, TEST_USERNAME +from .const import TEST_CODE, TEST_PASSWORD, TEST_USER_ID, TEST_USERNAME from tests.common import MockConfigEntry @@ -51,11 +51,11 @@ async def test_full_flow( CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, CONF_LOGIN_DATA: { - "customer_info": {"user_id": TEST_USERNAME}, + "customer_info": {"user_id": TEST_USER_ID}, CONF_SITE: "https://www.amazon.com", }, } - assert result["result"].unique_id == TEST_USERNAME + assert result["result"].unique_id == TEST_USER_ID mock_amazon_devices_client.login.login_mode_interactive.assert_called_once_with( "023123" ) @@ -170,7 +170,7 @@ async def test_reauth_successful( CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: "other_fake_password", CONF_LOGIN_DATA: { - "customer_info": {"user_id": TEST_USERNAME}, + "customer_info": {"user_id": TEST_USER_ID}, CONF_SITE: "https://www.amazon.com", }, } @@ -228,7 +228,7 @@ async def test_reauth_not_successful( CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: "fake_password", CONF_LOGIN_DATA: { - "customer_info": {"user_id": TEST_USERNAME}, + "customer_info": {"user_id": TEST_USER_ID}, CONF_SITE: "https://www.amazon.com", }, } @@ -268,7 +268,7 @@ async def test_reconfigure_successful( CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: new_password, CONF_LOGIN_DATA: { - "customer_info": {"user_id": TEST_USERNAME}, + "customer_info": {"user_id": TEST_USER_ID}, CONF_SITE: "https://www.amazon.com", }, } @@ -327,7 +327,7 @@ async def test_reconfigure_fails( CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, CONF_LOGIN_DATA: { - "customer_info": {"user_id": TEST_USERNAME}, + "customer_info": {"user_id": TEST_USER_ID}, CONF_SITE: "https://www.amazon.com", }, } diff --git a/tests/components/alexa_devices/test_init.py b/tests/components/alexa_devices/test_init.py index 0b20b1fe239e7d..623c1a7315df54 100644 --- a/tests/components/alexa_devices/test_init.py +++ b/tests/components/alexa_devices/test_init.py @@ -16,7 +16,7 @@ from homeassistant.helpers import device_registry as dr from . import setup_integration -from .const import TEST_DEVICE_1_SN, TEST_PASSWORD, TEST_USERNAME +from .const import TEST_DEVICE_1_SN, TEST_PASSWORD, TEST_USER_ID, TEST_USERNAME from tests.common import MockConfigEntry @@ -109,7 +109,7 @@ async def test_migrate_entry( CONF_PASSWORD: TEST_PASSWORD, **(extra_data), }, - unique_id=TEST_USERNAME, + unique_id=TEST_USER_ID, version=1, minor_version=minor_version, ) diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index 6b0128d3f32104..19e7fb06365d0e 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -238,6 +238,25 @@ async def test_websocket_list_empty(ws_client: ClientFixture) -> None: assert await client.cmd_result("list") == [] +async def test_websocket_config_entry_requires_admin( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_read_only_access_token: str, +) -> None: + """Test config_entry websocket command requires admin.""" + ws_client = await hass_ws_client(hass, hass_read_only_access_token) + await ws_client.send_json( + { + "id": 1, + "type": "application_credentials/config_entry", + "config_entry_id": "some_id", + } + ) + resp = await ws_client.receive_json() + assert not resp["success"] + assert resp["error"]["code"] == "unauthorized" + + async def test_websocket_create(ws_client: ClientFixture) -> None: """Test websocket create command.""" client = await ws_client() @@ -267,6 +286,22 @@ async def test_websocket_create(ws_client: ClientFixture) -> None: ] +@pytest.mark.parametrize("cmd", ["list", "subscribe"]) +async def test_websocket_list_subscribe_require_admin( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_read_only_access_token: str, + cmd: str, +) -> None: + """Test that list and subscribe are restricted to admin users.""" + ws = await hass_ws_client(access_token=hass_read_only_access_token) + await ws.send_json({"id": 1, "type": f"{DOMAIN}/{cmd}"}) + resp = await ws.receive_json() + assert resp["id"] == 1 + assert not resp["success"] + assert resp["error"]["code"] == "unauthorized" + + async def test_websocket_create_invalid_domain(ws_client: ClientFixture) -> None: """Test websocket create command.""" client = await ws_client() diff --git a/tests/components/aquacell/conftest.py b/tests/components/aquacell/conftest.py index 443f7da77cef4f..49d11537c6dc4a 100644 --- a/tests/components/aquacell/conftest.py +++ b/tests/components/aquacell/conftest.py @@ -47,7 +47,7 @@ def mock_aquacell_api() -> Generator[MagicMock]: "aquacell/get_all_softeners_one_softener.json" ) - softeners = [Softener(softener) for softener in softeners_dict] + softeners = [Softener.from_dict(softener) for softener in softeners_dict] mock_aquacell_api.get_all_softeners.return_value = softeners yield mock_aquacell_api diff --git a/tests/components/arcam_fmj/test_sensor.py b/tests/components/arcam_fmj/test_sensor.py index 016c5e9850bf16..d214759cc52eab 100644 --- a/tests/components/arcam_fmj/test_sensor.py +++ b/tests/components/arcam_fmj/test_sensor.py @@ -92,3 +92,37 @@ async def test_sensor_audio_parameters( hass.states.get("sensor.arcam_fmj_127_0_0_1_incoming_audio_sample_rate").state == "48000" ) + + +@pytest.mark.usefixtures("player_setup") +async def test_sensor_enum_unknown( + hass: HomeAssistant, + state_1: State, + client: Mock, +) -> None: + """Test parameter sensors with unknown data.""" + video_params = Mock() + video_params.horizontal_resolution = 0 + video_params.vertical_resolution = 0 + video_params.refresh_rate = 0 + video_params.aspect_ratio = IncomingVideoAspectRatio.from_int(0x99) + video_params.colorspace = IncomingVideoColorspace.from_int(0x99) + + state_1.get_incoming_video_parameters.return_value = video_params + state_1.get_incoming_audio_format.return_value = ( + None, + IncomingAudioConfig.from_int(0x99), + ) + + client.notify_data_updated() + await hass.async_block_till_done() + + def _get(key: str) -> str: + state = hass.states.get(f"sensor.arcam_fmj_127_0_0_1_{key}") + assert state + return state.state + + assert _get("incoming_audio_format") == "unknown" + assert _get("incoming_audio_configuration") == "unknown" + assert _get("incoming_video_aspect_ratio") == "unknown" + assert _get("incoming_video_colorspace") == "unknown" diff --git a/tests/components/assist_satellite/test_condition.py b/tests/components/assist_satellite/test_condition.py index 26c43ec7db9c02..3594c3ba3e94df 100644 --- a/tests/components/assist_satellite/test_condition.py +++ b/tests/components/assist_satellite/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, other_states, parametrize_condition_states_all, parametrize_condition_states_any, @@ -42,6 +43,33 @@ async def test_assist_satellite_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("assist_satellite.is_idle", {}, True, True), + ("assist_satellite.is_listening", {}, True, True), + ("assist_satellite.is_processing", {}, True, True), + ("assist_satellite.is_responding", {}, True, True), + ], +) +async def test_assist_satellite_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that assist_satellite conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 8695ef6128e616..702ea03aed9c15 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -29,11 +29,12 @@ from homeassistant.components.media_source import PlayMedia from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, Unauthorized from . import ENTITY_ID from .conftest import MockAssistSatellite +from tests.common import MockUser from tests.components.tts.common import MockResultStream @@ -967,6 +968,24 @@ async def async_start_conversation(start_announcement): assert response == asdict(expected_answer) +async def test_ask_question_requires_entity_permission( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_read_only_user: MockUser, +) -> None: + """Test ask_question is denied for users without POLICY_CONTROL on the entity.""" + with pytest.raises(Unauthorized): + await hass.services.async_call( + "assist_satellite", + "ask_question", + {"entity_id": "assist_satellite.test_entity", "question": "Anything?"}, + blocking=True, + return_response=True, + context=Context(user_id=hass_read_only_user.id), + ) + + async def test_wake_word_start_keeps_responding( hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite ) -> None: diff --git a/tests/components/assist_satellite/test_websocket_api.py b/tests/components/assist_satellite/test_websocket_api.py index 23eec7e8461182..34fb4dfb92a7c9 100644 --- a/tests/components/assist_satellite/test_websocket_api.py +++ b/tests/components/assist_satellite/test_websocket_api.py @@ -423,6 +423,33 @@ async def test_set_wake_words_bad_id( } +async def test_connection_test_require_admin( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_ws_client: WebSocketGenerator, + hass_read_only_access_token: str, +) -> None: + """Test connection test requires admin access.""" + ws_client = await hass_ws_client(hass, hass_read_only_access_token) + + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/test_connection", + "entity_id": ENTITY_ID, + } + ) + + async with asyncio.timeout(1): + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"] == { + "code": "unauthorized", + "message": "Unauthorized", + } + + async def test_connection_test( hass: HomeAssistant, init_components: ConfigEntry, diff --git a/tests/components/auth/test_mfa_setup_flow.py b/tests/components/auth/test_mfa_setup_flow.py index cb8d0d81ffe660..c497a31d08aa28 100644 --- a/tests/components/auth/test_mfa_setup_flow.py +++ b/tests/components/auth/test_mfa_setup_flow.py @@ -1,7 +1,6 @@ """Tests for the mfa setup flow.""" from homeassistant.auth import auth_manager_from_config -from homeassistant.components.auth import mfa_setup_flow from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.setup import async_setup_component @@ -52,7 +51,7 @@ async def test_ws_setup_depose_mfa( await client.send_json( { "id": 10, - "type": mfa_setup_flow.WS_TYPE_SETUP_MFA, + "type": "auth/setup_mfa", "mfa_module_id": "invalid_module", } ) @@ -65,7 +64,7 @@ async def test_ws_setup_depose_mfa( await client.send_json( { "id": 11, - "type": mfa_setup_flow.WS_TYPE_SETUP_MFA, + "type": "auth/setup_mfa", "mfa_module_id": "example_module", } ) @@ -84,7 +83,7 @@ async def test_ws_setup_depose_mfa( await client.send_json( { "id": 12, - "type": mfa_setup_flow.WS_TYPE_SETUP_MFA, + "type": "auth/setup_mfa", "flow_id": flow["flow_id"], "user_input": {"pin": "654321"}, } @@ -103,7 +102,7 @@ async def test_ws_setup_depose_mfa( await client.send_json( { "id": 13, - "type": mfa_setup_flow.WS_TYPE_DEPOSE_MFA, + "type": "auth/depose_mfa", "mfa_module_id": "invalid_id", } ) @@ -116,7 +115,7 @@ async def test_ws_setup_depose_mfa( await client.send_json( { "id": 14, - "type": mfa_setup_flow.WS_TYPE_DEPOSE_MFA, + "type": "auth/depose_mfa", "mfa_module_id": "example_module", } ) diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 25042b11d5445d..66b5d32ad47a8a 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -3591,6 +3591,34 @@ async def test_websocket_config( assert msg["error"]["code"] == "not_found" +async def test_websocket_config_requires_admin( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_read_only_access_token: str, +) -> None: + """Test config command requires admin.""" + config = { + "alias": "hello", + "triggers": {"trigger": "event", "event_type": "test_event"}, + "actions": {"action": "test.automation", "data": 100}, + } + assert await async_setup_component( + hass, automation.DOMAIN, {automation.DOMAIN: config} + ) + client = await hass_ws_client(hass, hass_read_only_access_token) + await client.send_json( + { + "id": 5, + "type": "automation/config", + "entity_id": "automation.hello", + } + ) + + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "unauthorized" + + async def test_automation_turns_off_other_automation(hass: HomeAssistant) -> None: """Test an automation that turns off another automation.""" hass.set_state(CoreState.not_running) @@ -4022,3 +4050,53 @@ async def test_reload_when_labs_flag_changes( await hass.async_block_till_done() assert len(calls) == 1 assert calls[-1].data.get("event") == "test_event2" + + +async def test_remove_automation_unloads_condition_and_script( + hass: HomeAssistant, + calls: list[ServiceCall], +) -> None: + """Test that removing an automation unloads its condition and action script.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "alias": "test_unload", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": { + "condition": "state", + "entity_id": "binary_sensor.test", + "state": "on", + }, + "action": {"action": "test.automation"}, + } + }, + ) + + entity = hass.data[automation.DATA_COMPONENT].get_entity("automation.test_unload") + assert entity is not None + assert isinstance(entity, AutomationEntity) + + # Reload with empty config to remove the automation + with ( + patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value={automation.DOMAIN: []}, + ), + patch.object( + entity._condition, "async_unload", wraps=entity._condition.async_unload + ) as condition_unload, + patch.object( + entity.action_script, + "async_unload", + wraps=entity.action_script.async_unload, + ) as script_unload, + ): + await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) + await hass.async_block_till_done() + + condition_unload.assert_called_once() + script_unload.assert_called_once() diff --git a/tests/components/backup/test_init.py b/tests/components/backup/test_init.py index 10bd2d8b97a252..6469f51863fd99 100644 --- a/tests/components/backup/test_init.py +++ b/tests/components/backup/test_init.py @@ -7,12 +7,12 @@ from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntryState -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceNotFound +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import ServiceNotFound, Unauthorized from .common import setup_backup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, MockUser from tests.typing import WebSocketGenerator @@ -157,3 +157,30 @@ async def test_setup_entry( assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + ("service", "with_hassio"), + [ + ("create", False), + ("create_automatic", False), + ("create_automatic", True), + ], +) +@pytest.mark.usefixtures("supervisor_client") +async def test_services_require_admin( + hass: HomeAssistant, + hass_read_only_user: MockUser, + service: str, + with_hassio: bool, +) -> None: + """Test backup services require admin.""" + await setup_backup_integration(hass, with_hassio=with_hassio) + + with pytest.raises(Unauthorized): + await hass.services.async_call( + DOMAIN, + service, + context=Context(user_id=hass_read_only_user.id), + blocking=True, + ) diff --git a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr index 10066babd8f032..303e36b76b64ae 100644 --- a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr +++ b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr @@ -124,7 +124,7 @@ 'battery_level': dict({ 'attributes': dict({ 'device_class': 'battery', - 'friendly_name': 'Living room Balance Battery', + 'friendly_name': 'Lounge room A5 Battery', 'state_class': 'measurement', 'unit_of_measurement': '%', }), @@ -134,7 +134,7 @@ 'charging': dict({ 'attributes': dict({ 'device_class': 'battery_charging', - 'friendly_name': 'Living room Balance Charging', + 'friendly_name': 'Lounge room A5 Charging', }), 'entity_id': 'binary_sensor.lounge_room_a5_charging', 'state': 'off', @@ -174,12 +174,12 @@ 'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com', }), 'self': dict({ - 'Living room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + 'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com', }), }), 'device_class': 'speaker', 'entity_picture_local': None, - 'friendly_name': 'Living room Balance', + 'friendly_name': 'Lounge room A5', 'group_members': list([ 'media_player.lounge_room_a5', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', diff --git a/tests/components/bang_olufsen/test_binary_sensor.py b/tests/components/bang_olufsen/test_binary_sensor.py index 72a5b776bd8055..9a44277a88f644 100644 --- a/tests/components/bang_olufsen/test_binary_sensor.py +++ b/tests/components/bang_olufsen/test_binary_sensor.py @@ -2,14 +2,19 @@ from unittest.mock import AsyncMock -from mozart_api.models import BatteryState +from mozart_api.models import BatteryState, BeolinkSelf from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry from .conftest import mock_websocket_connection -from .const import TEST_BATTERY, TEST_BATTERY_CHARGING_BINARY_SENSOR_ENTITY_ID +from .const import ( + TEST_BATTERY, + TEST_BATTERY_CHARGING_BINARY_SENSOR_ENTITY_ID, + TEST_FRIENDLY_NAME_4, + TEST_JID_4, +) from tests.common import MockConfigEntry @@ -23,6 +28,9 @@ async def test_battery_charging( """Test the battery charging time entity.""" # Ensure battery entities are created mock_mozart_client.get_battery_state.return_value = TEST_BATTERY + mock_mozart_client.get_beolink_self.return_value = BeolinkSelf( + friendly_name=TEST_FRIENDLY_NAME_4, jid=TEST_JID_4 + ) # Load entry mock_config_entry_a5.add_to_hass(hass) diff --git a/tests/components/bang_olufsen/test_diagnostics.py b/tests/components/bang_olufsen/test_diagnostics.py index f8984cbb564052..4209543cf2e8c0 100644 --- a/tests/components/bang_olufsen/test_diagnostics.py +++ b/tests/components/bang_olufsen/test_diagnostics.py @@ -1,6 +1,6 @@ """Test bang_olufsen config entry diagnostics.""" -from mozart_api.models import BatteryState +from mozart_api.models import BatteryState, BeolinkSelf from syrupy.assertion import SnapshotAssertion from syrupy.filters import props @@ -8,7 +8,12 @@ from homeassistant.helpers.entity_registry import EntityRegistry from .conftest import mock_websocket_connection -from .const import TEST_BUTTON_EVENT_ENTITY_ID, TEST_REMOTE_KEY_EVENT_ENTITY_ID +from .const import ( + TEST_BUTTON_EVENT_ENTITY_ID, + TEST_FRIENDLY_NAME_4, + TEST_JID_4, + TEST_REMOTE_KEY_EVENT_ENTITY_ID, +) from tests.common import AsyncMock, MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -65,6 +70,9 @@ async def test_async_get_config_entry_diagnostics_with_battery( mock_mozart_client.get_battery_state.return_value = BatteryState( battery_level=1, state="BatteryVeryLow" ) + mock_mozart_client.get_beolink_self.return_value = BeolinkSelf( + friendly_name=TEST_FRIENDLY_NAME_4, jid=TEST_JID_4 + ) # Load entry mock_config_entry_a5.add_to_hass(hass) diff --git a/tests/components/bang_olufsen/test_event.py b/tests/components/bang_olufsen/test_event.py index 7253cace0ec37e..dcdb41441b758e 100644 --- a/tests/components/bang_olufsen/test_event.py +++ b/tests/components/bang_olufsen/test_event.py @@ -2,7 +2,12 @@ from unittest.mock import AsyncMock -from mozart_api.models import BeoRemoteButton, ButtonEvent, PairedRemoteResponse +from mozart_api.models import ( + BeolinkSelf, + BeoRemoteButton, + ButtonEvent, + PairedRemoteResponse, +) from pytest_unordered import unordered from syrupy.assertion import SnapshotAssertion @@ -20,6 +25,10 @@ from .const import ( TEST_BATTERY, TEST_BUTTON_EVENT_ENTITY_ID, + TEST_FRIENDLY_NAME_3, + TEST_FRIENDLY_NAME_4, + TEST_JID_3, + TEST_JID_4, TEST_REMOTE_KEY_EVENT_ENTITY_ID, TEST_SERIAL_NUMBER_3, TEST_SERIAL_NUMBER_4, @@ -109,6 +118,9 @@ async def test_button_event_creation_premiere( snapshot: SnapshotAssertion, ) -> None: """Test Bluetooth and Microphone button event entities are not created when using a Beosound Premiere.""" + mock_mozart_client.get_beolink_self.return_value = BeolinkSelf( + friendly_name=TEST_FRIENDLY_NAME_3, jid=TEST_JID_3 + ) await _check_button_event_creation( hass, @@ -132,6 +144,9 @@ async def test_button_event_creation_a5( ) -> None: """Test Microphone button event entity is not created when using a Beosound A5.""" mock_mozart_client.get_battery_state.return_value = TEST_BATTERY + mock_mozart_client.get_beolink_self.return_value = BeolinkSelf( + friendly_name=TEST_FRIENDLY_NAME_4, jid=TEST_JID_4 + ) await _check_button_event_creation( hass, diff --git a/tests/components/bang_olufsen/test_sensor.py b/tests/components/bang_olufsen/test_sensor.py index f1219ecb1b1d40..43095ebdffb9d1 100644 --- a/tests/components/bang_olufsen/test_sensor.py +++ b/tests/components/bang_olufsen/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory -from mozart_api.models import PairedRemote, PairedRemoteResponse +from mozart_api.models import BeolinkSelf, PairedRemote, PairedRemoteResponse from homeassistant.components.bang_olufsen.sensor import SCAN_INTERVAL from homeassistant.const import STATE_UNKNOWN @@ -13,6 +13,8 @@ from .const import ( TEST_BATTERY, TEST_BATTERY_SENSOR_ENTITY_ID, + TEST_FRIENDLY_NAME_4, + TEST_JID_4, TEST_REMOTE_BATTERY_LEVEL_SENSOR_ENTITY_ID, TEST_REMOTE_SERIAL, ) @@ -28,6 +30,9 @@ async def test_battery_level( """Test the battery level entity.""" # Ensure battery entities are created mock_mozart_client.get_battery_state.return_value = TEST_BATTERY + mock_mozart_client.get_beolink_self.return_value = BeolinkSelf( + friendly_name=TEST_FRIENDLY_NAME_4, jid=TEST_JID_4 + ) # Load entry mock_config_entry_a5.add_to_hass(hass) diff --git a/tests/components/battery/test_condition.py b/tests/components/battery/test_condition.py index 5e011431f20920..e0cb7d4be85e3c 100644 --- a/tests/components/battery/test_condition.py +++ b/tests/components/battery/test_condition.py @@ -18,6 +18,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_numerical_condition_above_below_all, @@ -62,6 +63,33 @@ async def test_battery_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("battery.is_low", {}, True, True), + ("battery.is_not_low", {}, True, True), + ("battery.is_charging", {}, True, True), + ("battery.is_not_charging", {}, True, True), + ], +) +async def test_battery_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that battery conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/bayesian/test_config_flow.py b/tests/components/bayesian/test_config_flow.py index 0911113a22a3f3..7b9e2c408f9063 100644 --- a/tests/components/bayesian/test_config_flow.py +++ b/tests/components/bayesian/test_config_flow.py @@ -10,7 +10,6 @@ from homeassistant import config_entries from homeassistant.components.bayesian.config_flow import ( - OBSERVATION_SELECTOR, USER, ObservationTypes, OptionsFlowSteps, @@ -27,6 +26,7 @@ ConfigEntry, ConfigSubentry, ConfigSubentryDataWithId, + FlowType, ) from homeassistant.const import ( CONF_ABOVE, @@ -72,10 +72,9 @@ async def test_config_flow_step_user(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - # We move on to the next step - the observation selector - assert result1["step_id"] == OBSERVATION_SELECTOR - assert result1["type"] is FlowResultType.MENU - assert result1["flow_id"] is not None + assert result1["type"] == FlowResultType.CREATE_ENTRY + assert result1["result"].title == "Office occupied" + assert result1["next_flow"][0] == FlowType.CONFIG_SUBENTRIES_FLOW async def test_subentry_flow(hass: HomeAssistant) -> None: @@ -254,13 +253,15 @@ async def test_single_state_observation(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + entry_id = result["result"].entry_id + sub_flow_id = result["next_flow"][1] + # Confirm the next step is the menu - assert result["step_id"] == OBSERVATION_SELECTOR - assert result["type"] is FlowResultType.MENU + result = hass.config_entries.subentries.async_get(sub_flow_id) assert result["flow_id"] is not None - assert result["menu_options"] == ["state", "numeric_state", "template"] - result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.subentries.async_configure( result["flow_id"], {"next_step_id": str(ObservationTypes.STATE)} ) await hass.async_block_till_done() @@ -268,7 +269,7 @@ async def test_single_state_observation(hass: HomeAssistant) -> None: assert result["step_id"] == str(ObservationTypes.STATE) assert result["type"] is FlowResultType.FORM - result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.subentries.async_configure( result["flow_id"], { CONF_ENTITY_ID: "sensor.kitchen_occupancy", @@ -279,22 +280,9 @@ async def test_single_state_observation(hass: HomeAssistant) -> None: }, ) - assert result["step_id"] == OBSERVATION_SELECTOR - assert result["type"] is FlowResultType.MENU - assert result["flow_id"] is not None - assert result["menu_options"] == [ - "state", - "numeric_state", - "template", - "finish", - ] - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "finish"} - ) + assert result["type"] == FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - entry_id = result["result"].entry_id config_entry = hass.config_entries.async_get_entry(entry_id) assert config_entry is not None assert type(config_entry) is ConfigEntry @@ -341,22 +329,20 @@ async def test_single_numeric_state_observation(hass: HomeAssistant) -> None: CONF_PRIOR: 20, }, ) + assert result["type"] == FlowResultType.CREATE_ENTRY + config_entry = result["result"] + sub_flow_id = result["next_flow"][1] await hass.async_block_till_done() - # Confirm the next step is the menu - assert result["step_id"] == OBSERVATION_SELECTOR - assert result["type"] is FlowResultType.MENU - assert result["flow_id"] is not None - # select numeric state observation - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": str(ObservationTypes.NUMERIC_STATE)} + result = await hass.config_entries.subentries.async_configure( + sub_flow_id, {"next_step_id": str(ObservationTypes.NUMERIC_STATE)} ) await hass.async_block_till_done() assert result["step_id"] == str(ObservationTypes.NUMERIC_STATE) assert result["type"] is FlowResultType.FORM - result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.subentries.async_configure( result["flow_id"], { CONF_ENTITY_ID: "sensor.outside_temperature", @@ -367,21 +353,8 @@ async def test_single_numeric_state_observation(hass: HomeAssistant) -> None: CONF_NAME: "20 - 35 outside", }, ) - assert result["step_id"] == OBSERVATION_SELECTOR - assert result["type"] is FlowResultType.MENU - assert result["flow_id"] is not None - assert result["menu_options"] == [ - "state", - "numeric_state", - "template", - "finish", - ] - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "finish"} - ) await hass.async_block_till_done() - config_entry = result["result"] + assert config_entry.options == { CONF_NAME: "Nice day", CONF_PROBABILITY_THRESHOLD: 0.51, @@ -427,20 +400,19 @@ async def test_multi_numeric_state_observation(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - # Confirm the next step is the menu - assert result["step_id"] == OBSERVATION_SELECTOR - assert result["type"] is FlowResultType.MENU - assert result["flow_id"] is not None + assert result["type"] == FlowResultType.CREATE_ENTRY + config_entry = result["result"] + sub_flow_id = result["next_flow"][1] # select numeric state observation - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": str(ObservationTypes.NUMERIC_STATE)} + result = await hass.config_entries.subentries.async_configure( + sub_flow_id, {"next_step_id": str(ObservationTypes.NUMERIC_STATE)} ) await hass.async_block_till_done() assert result["step_id"] == str(ObservationTypes.NUMERIC_STATE) assert result["type"] is FlowResultType.FORM - result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.subentries.async_configure( result["flow_id"], { CONF_ENTITY_ID: "sensor.outside_temperature", @@ -451,18 +423,17 @@ async def test_multi_numeric_state_observation(hass: HomeAssistant) -> None: CONF_NAME: "20 - 35 outside", }, ) - - # Confirm the next step is the menu - assert result["step_id"] == OBSERVATION_SELECTOR - assert result["type"] is FlowResultType.MENU - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": str(ObservationTypes.NUMERIC_STATE)} - ) await hass.async_block_till_done() # This should fail as overlapping ranges for the same entity are not allowed - current_step = result["step_id"] - result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "observation"), + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"next_step_id": str(ObservationTypes.NUMERIC_STATE)} + ) + result = await hass.config_entries.subentries.async_configure( result["flow_id"], { CONF_ENTITY_ID: "sensor.outside_temperature", @@ -475,11 +446,10 @@ async def test_multi_numeric_state_observation(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() assert result["errors"] == {"base": "overlapping_ranges"} - assert result["step_id"] == current_step # This should fail as above should always be less than below current_step = result["step_id"] - result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.subentries.async_configure( result["flow_id"], { CONF_ENTITY_ID: "sensor.outside_temperature", @@ -495,7 +465,7 @@ async def test_multi_numeric_state_observation(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "above_below"} # This should work - result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.subentries.async_configure( result["flow_id"], { CONF_ENTITY_ID: "sensor.outside_temperature", @@ -506,22 +476,8 @@ async def test_multi_numeric_state_observation(hass: HomeAssistant) -> None: CONF_NAME: "35 - 40 outside", }, ) - assert result["step_id"] == OBSERVATION_SELECTOR - assert result["type"] is FlowResultType.MENU - assert result["flow_id"] is not None - assert result["menu_options"] == [ - "state", - "numeric_state", - "template", - "finish", - ] - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "finish"} - ) await hass.async_block_till_done() - config_entry = result["result"] assert config_entry.version == 1 assert config_entry.options == { CONF_NAME: "Nice day", @@ -582,20 +538,19 @@ async def test_single_template_observation(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - # Confirm the next step is the menu - assert result["step_id"] == OBSERVATION_SELECTOR - assert result["type"] is FlowResultType.MENU - assert result["flow_id"] is not None + assert result["type"] == FlowResultType.CREATE_ENTRY + config_entry = result["result"] + sub_flow_id = result["next_flow"][1] # Select template observation - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": str(ObservationTypes.TEMPLATE)} + result = await hass.config_entries.subentries.async_configure( + sub_flow_id, {"next_step_id": str(ObservationTypes.TEMPLATE)} ) await hass.async_block_till_done() assert result["step_id"] == str(ObservationTypes.TEMPLATE) assert result["type"] is FlowResultType.FORM - result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.subentries.async_configure( result["flow_id"], { CONF_VALUE_TEMPLATE: "{{is_state('device_tracker.paulus','not_home') and ((as_timestamp(now()) - as_timestamp(states.device_tracker.paulus.last_changed)) > 300)}}", @@ -604,21 +559,7 @@ async def test_single_template_observation(hass: HomeAssistant) -> None: CONF_NAME: "Not seen in last 5 minutes", }, ) - assert result["step_id"] == OBSERVATION_SELECTOR - assert result["type"] is FlowResultType.MENU - assert result["flow_id"] is not None - assert result["menu_options"] == [ - "state", - "numeric_state", - "template", - "finish", - ] - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "finish"} - ) await hass.async_block_till_done() - config_entry = result["result"] assert config_entry.version == 1 assert config_entry.options == { CONF_NAME: "Paulus Home", @@ -1087,13 +1028,12 @@ async def test_invalid_configs(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result.get("errors") is None - # Confirm the next step is the menu - assert result["step_id"] == OBSERVATION_SELECTOR - assert result["type"] is FlowResultType.MENU - assert result["flow_id"] is not None + assert result["type"] == FlowResultType.CREATE_ENTRY + config_entry = result["result"] + sub_flow_id = result["next_flow"][1] - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": str(ObservationTypes.STATE)} + result = await hass.config_entries.subentries.async_configure( + sub_flow_id, {"next_step_id": str(ObservationTypes.STATE)} ) await hass.async_block_till_done() @@ -1102,7 +1042,7 @@ async def test_invalid_configs(hass: HomeAssistant) -> None: # Observations with a probability of 0 will create certainties with pytest.raises(vol.Invalid) as excinfo: - result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.subentries.async_configure( result["flow_id"], { CONF_ENTITY_ID: "sensor.work_laptop", @@ -1117,7 +1057,7 @@ async def test_invalid_configs(hass: HomeAssistant) -> None: # Observations with a probability of 1 will create certainties with pytest.raises(vol.Invalid) as excinfo: - result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.subentries.async_configure( result["flow_id"], { CONF_ENTITY_ID: "sensor.work_laptop", @@ -1133,7 +1073,7 @@ async def test_invalid_configs(hass: HomeAssistant) -> None: # Observations with equal probabilities have no effect # Try with a ObservationTypes.STATE observation current_step = result["step_id"] - result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.subentries.async_configure( result["flow_id"], { CONF_ENTITY_ID: "sensor.work_laptop", @@ -1148,7 +1088,7 @@ async def test_invalid_configs(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "equal_probabilities"} # now submit a valid result - result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.subentries.async_configure( result["flow_id"], { CONF_ENTITY_ID: "sensor.work_laptop", @@ -1159,13 +1099,15 @@ async def test_invalid_configs(hass: HomeAssistant) -> None: }, ) await hass.async_block_till_done() - result = await hass.config_entries.flow.async_configure( + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "observation"), + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.subentries.async_configure( result["flow_id"], {"next_step_id": str(ObservationTypes.NUMERIC_STATE)} ) - - await hass.async_block_till_done() - current_step = result["step_id"] - result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.subentries.async_configure( result["flow_id"], { CONF_ENTITY_ID: "sensor.office_illuminance_lux", @@ -1176,10 +1118,9 @@ async def test_invalid_configs(hass: HomeAssistant) -> None: }, ) await hass.async_block_till_done() - assert result["step_id"] == current_step assert result["errors"] == {"base": "equal_probabilities"} - result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.subentries.async_configure( result["flow_id"], { CONF_ENTITY_ID: "sensor.office_illuminance_lux", @@ -1191,13 +1132,15 @@ async def test_invalid_configs(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() # Try with a ObservationTypes.TEMPLATE observation - result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "observation"), + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.subentries.async_configure( result["flow_id"], {"next_step_id": str(ObservationTypes.TEMPLATE)} ) - - await hass.async_block_till_done() current_step = result["step_id"] - result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.subentries.async_configure( result["flow_id"], { CONF_VALUE_TEMPLATE: "{{ is_state('device_tracker.paulus', 'not_home') }}", diff --git a/tests/components/blink/test_services.py b/tests/components/blink/test_services.py deleted file mode 100644 index 17a9fcea2844a0..00000000000000 --- a/tests/components/blink/test_services.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Test the Blink services.""" - -from unittest.mock import MagicMock - -import pytest - -from homeassistant.components.blink.const import DOMAIN -from homeassistant.components.blink.services import SERVICE_SEND_PIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_PIN -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import issue_registry as ir - -from tests.common import MockConfigEntry - -CAMERA_NAME = "Camera 1" -FILENAME = "blah" -PIN = "1234" - - -async def test_pin_service_calls( - hass: HomeAssistant, - mock_blink_api: MagicMock, - mock_blink_auth_api: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test pin service calls.""" - - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.LOADED - assert mock_blink_api.refresh.call_count == 1 - - issue_registry = ir.async_get(hass) - - # Service should always raise an exception and create a repair issue - with pytest.raises( - HomeAssistantError, match="The service blink.send_pin has been removed" - ): - await hass.services.async_call( - DOMAIN, - SERVICE_SEND_PIN, - {ATTR_CONFIG_ENTRY_ID: [mock_config_entry.entry_id], CONF_PIN: PIN}, - blocking=True, - ) - - # Verify repair issue was created - issues = issue_registry.issues - assert len(issues) == 1 - issue = next(iter(issues.values())) - assert issue.issue_id == "service_send_pin_deprecation" - assert issue.domain == DOMAIN - - # Service should still raise error with bad config ID - with pytest.raises( - HomeAssistantError, match="The service blink.send_pin has been removed" - ): - await hass.services.async_call( - DOMAIN, - SERVICE_SEND_PIN, - {ATTR_CONFIG_ENTRY_ID: ["bad-config_id"], CONF_PIN: PIN}, - blocking=True, - ) - - -async def test_service_pin_creates_repair_issue( - hass: HomeAssistant, - mock_blink_api: MagicMock, - mock_blink_auth_api: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test that the send PIN service creates a repair issue.""" - - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - issue_registry = ir.async_get(hass) - - # Initially no issues - assert len(issue_registry.issues) == 0 - - # Call the service (should fail but create repair issue) - with pytest.raises( - HomeAssistantError, match="The service blink.send_pin has been removed" - ): - await hass.services.async_call( - DOMAIN, - SERVICE_SEND_PIN, - {ATTR_CONFIG_ENTRY_ID: [mock_config_entry.entry_id], CONF_PIN: PIN}, - blocking=True, - ) - - # Verify repair issue was created - issues = issue_registry.issues - assert len(issues) == 1 - issue = next(iter(issues.values())) - assert issue.issue_id == "service_send_pin_deprecation" - assert issue.domain == DOMAIN - assert issue.severity == ir.IssueSeverity.ERROR - assert not issue.is_fixable - - # Call service again - should not create duplicate issue - with pytest.raises( - HomeAssistantError, match="The service blink.send_pin has been removed" - ): - await hass.services.async_call( - DOMAIN, - SERVICE_SEND_PIN, - {ATTR_CONFIG_ENTRY_ID: [mock_config_entry.entry_id], CONF_PIN: PIN}, - blocking=True, - ) - - # Still only one issue - assert len(issue_registry.issues) == 1 diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index 2afd59e83cffc4..dda0ce049461cd 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -9,12 +9,15 @@ from homeassistant.components.bluetooth import ( MONOTONIC_TIME, BaseHaRemoteScanner, + BluetoothChange, BluetoothScanningMode, + BluetoothServiceInfo, HaBluetoothConnector, + async_clear_advertisement_history, async_scanner_by_source, async_scanner_devices_by_address, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from . import ( FakeRemoteScanner, @@ -23,6 +26,7 @@ _get_manager, generate_advertisement_data, generate_ble_device, + inject_advertisement, ) @@ -228,3 +232,49 @@ async def test_async_current_scanners(hass: HomeAssistant) -> None: # Verify we're back to the initial scanner final_scanners = bluetooth.async_current_scanners(hass) assert len(final_scanners) == initial_scanner_count + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_clear_advertisement_history(hass: HomeAssistant) -> None: + """Test clearing advertisement history bypasses the dedup guard.""" + callbacks: list[tuple[BluetoothServiceInfo, BluetoothChange]] = [] + + @callback + def _fake_subscriber( + service_info: BluetoothServiceInfo, change: BluetoothChange + ) -> None: + callbacks.append((service_info, change)) + + cancel = bluetooth.async_register_callback( + hass, + _fake_subscriber, + {"address": "44:44:33:11:23:45"}, + BluetoothScanningMode.ACTIVE, + ) + + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, + ) + + inject_advertisement(hass, switchbot_device, switchbot_adv) + await hass.async_block_till_done() + + # Identical advertisement is deduplicated by the manager + inject_advertisement(hass, switchbot_device, switchbot_adv) + await hass.async_block_till_done() + + assert len(callbacks) == 1 + + # Clearing the advertisement history makes the next identical + # advertisement be treated as new data + async_clear_advertisement_history(hass, "44:44:33:11:23:45") + + inject_advertisement(hass, switchbot_device, switchbot_adv) + await hass.async_block_till_done() + + assert len(callbacks) == 2 + + cancel() diff --git a/tests/components/broadlink/test_radio_frequency.py b/tests/components/broadlink/test_radio_frequency.py new file mode 100644 index 00000000000000..21feaa598be3d7 --- /dev/null +++ b/tests/components/broadlink/test_radio_frequency.py @@ -0,0 +1,137 @@ +"""Tests for the Broadlink radio_frequency platform.""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import MagicMock, call + +from broadlink.exceptions import BroadlinkException +import pytest +from rf_protocols import OOKCommand + +from homeassistant.components import radio_frequency +from homeassistant.components.broadlink.const import DOMAIN +from homeassistant.components.broadlink.radio_frequency import ( + _RF_315_TYPE_BYTE, + _RF_433_TYPE_BYTE, + _TICK_US, + encode_rf_packet, +) +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import dt as dt_util + +from . import get_device + +from tests.common import async_fire_time_changed + +_FREQ_433 = 433_920_000 +_FREQ_315 = 315_000_000 + + +async def _setup_rf_device(hass: HomeAssistant) -> tuple[MagicMock, str]: + """Set up the RMPRO test device, return its api mock and RF entity_id.""" + device = get_device("Office") + mock_setup = await device.setup_entry(hass) + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_setup.entry.unique_id)} + ) + entries = er.async_entries_for_device(entity_registry, device_entry.id) + rf_entity = next(e for e in entries if e.domain == Platform.RADIO_FREQUENCY) + return mock_setup.api, rf_entity.entity_id + + +@pytest.mark.parametrize( + ("device_name", "has_rf"), + [ + ("Office", True), # RMPRO + ("Garage", True), # RM4PRO + ("Entrance", False), # RMMINI + ], +) +async def test_radio_frequency_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + device_name: str, + has_rf: bool, +) -> None: + """RF entity is created only for RF-capable devices.""" + device = get_device(device_name) + mock_setup = await device.setup_entry(hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_setup.entry.unique_id)} + ) + entries = er.async_entries_for_device(entity_registry, device_entry.id) + rf_entities = [e for e in entries if e.domain == Platform.RADIO_FREQUENCY] + assert len(rf_entities) == (1 if has_rf else 0) + + +def test_encode_rf_packet() -> None: + """Pulses are encoded inline below 256 ticks, escape-prefixed above.""" + timings = [round(12 * _TICK_US), round(300 * _TICK_US), round(12 * _TICK_US)] + packet = encode_rf_packet( + type_byte=_RF_433_TYPE_BYTE, repeat_count=3, timings_us=timings + ) + # type byte, repeat count, payload length (le16), 12, escape (00 01 2c), 12 + assert packet == bytes([0xB2, 0x03, 0x05, 0x00, 0x0C, 0x00, 0x01, 0x2C, 0x0C]) + + +async def test_send_command(hass: HomeAssistant) -> None: + """An OOK command transmits the encoded packet once.""" + api, entity_id = await _setup_rf_device(hass) + + timings = [400, -800, 400, -800] + command = OOKCommand(frequency=_FREQ_433, timings=timings) + await radio_frequency.async_send_command(hass, entity_id, command) + + expected = encode_rf_packet( + type_byte=_RF_433_TYPE_BYTE, repeat_count=0, timings_us=timings + ) + assert api.send_data.call_args == call(expected) + + +async def test_send_command_315_band(hass: HomeAssistant) -> None: + """A 315 MHz command uses the 0xB4 type byte.""" + api, entity_id = await _setup_rf_device(hass) + + command = OOKCommand(frequency=_FREQ_315, timings=[400, -800]) + await radio_frequency.async_send_command(hass, entity_id, command) + + assert api.send_data.call_args.args[0][0] == _RF_315_TYPE_BYTE + + +async def test_send_command_rejects_out_of_band(hass: HomeAssistant) -> None: + """An out-of-band frequency is rejected before send.""" + api, entity_id = await _setup_rf_device(hass) + + command = OOKCommand(frequency=868_000_000, timings=[400, -800]) + with pytest.raises(HomeAssistantError): + await radio_frequency.async_send_command(hass, entity_id, command) + api.send_data.assert_not_called() + + +async def test_send_command_transmit_failure(hass: HomeAssistant) -> None: + """A broadlink exception surfaces as HomeAssistantError.""" + api, entity_id = await _setup_rf_device(hass) + api.send_data.side_effect = BroadlinkException("nope") + + command = OOKCommand(frequency=_FREQ_433, timings=[400, -800]) + with pytest.raises(HomeAssistantError): + await radio_frequency.async_send_command(hass, entity_id, command) + + +async def test_entity_availability(hass: HomeAssistant) -> None: + """Entity becomes unavailable when the device stops responding.""" + api, entity_id = await _setup_rf_device(hass) + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + api.check_sensors.side_effect = OSError("disconnected") + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=2)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE diff --git a/tests/components/calendar/test_condition.py b/tests/components/calendar/test_condition.py index 05b7c71131493b..2f49b982bc5d29 100644 --- a/tests/components/calendar/test_condition.py +++ b/tests/components/calendar/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -38,6 +39,30 @@ async def test_calendar_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("calendar.is_event_active", {}, True, True), + ], +) +async def test_calendar_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that calendar conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 7f87d0c0c57658..19efbffa200449 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -28,7 +28,7 @@ from .conftest import MockCalendarEntity, MockConfigEntry -from tests.common import async_fire_time_changed +from tests.common import MockUser, async_fire_time_changed from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -952,3 +952,113 @@ async def test_websocket_subscribe_debounces_rapid_updates( # The final message has all events assert len(messages[-1]["event"]["events"]) == 6 + + +async def test_events_http_api_unauthorized( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_admin_user: MockUser, +) -> None: + """Test that the events HTTP API enforces per-entity read permission.""" + hass_admin_user.groups = [] + hass_admin_user.mock_policy( + {"entities": {"entity_ids": {"calendar.calendar_2": True}}} + ) + client = await hass_client() + start = dt_util.now() + end = start + timedelta(days=1) + response = await client.get( + f"/api/calendars/calendar.calendar_1?start={start.isoformat()}&end={end.isoformat()}" + ) + assert response.status == HTTPStatus.UNAUTHORIZED + # Allowed entity still works + response = await client.get( + f"/api/calendars/calendar.calendar_2?start={start.isoformat()}&end={end.isoformat()}" + ) + assert response.status == HTTPStatus.OK + + +async def test_calendars_http_api_filters_unauthorized( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_admin_user: MockUser, +) -> None: + """Test that the calendar list filters out entities the user cannot read.""" + hass_admin_user.groups = [] + hass_admin_user.mock_policy( + {"entities": {"entity_ids": {"calendar.calendar_2": True}}} + ) + client = await hass_client() + response = await client.get("/api/calendars") + assert response.status == HTTPStatus.OK + data = await response.json() + assert data == [{"entity_id": "calendar.calendar_2", "name": "Calendar 2"}] + + +@pytest.mark.parametrize( + "command", + [ + { + "type": "calendar/event/create", + "entity_id": "calendar.calendar_1", + "event": { + "summary": "Bastille Day Party", + "dtstart": "1997-07-14T17:00:00+00:00", + "dtend": "1997-07-15T04:00:00+00:00", + }, + }, + { + "type": "calendar/event/delete", + "entity_id": "calendar.calendar_1", + "uid": "some-uid", + }, + { + "type": "calendar/event/update", + "entity_id": "calendar.calendar_1", + "uid": "some-uid", + "event": { + "summary": "Bastille Day Party", + "dtstart": "1997-07-14T17:00:00+00:00", + "dtend": "1997-07-15T04:00:00+00:00", + }, + }, + ], +) +async def test_websocket_event_mutate_unauthorized( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_admin_user: MockUser, + command: dict[str, Any], +) -> None: + """Test that mutating event WS commands enforce per-entity control permission.""" + hass_admin_user.groups = [] + hass_admin_user.mock_policy({}) + client = await hass_ws_client(hass) + await client.send_json_auto_id(command) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "unauthorized" + + +async def test_websocket_subscribe_unauthorized( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_admin_user: MockUser, +) -> None: + """Test calendar event subscription enforces per-entity read permission.""" + hass_admin_user.groups = [] + hass_admin_user.mock_policy({}) + client = await hass_ws_client(hass) + start = dt_util.now() + end = start + timedelta(days=1) + await client.send_json_auto_id( + { + "type": "calendar/event/subscribe", + "entity_id": "calendar.calendar_1", + "start": start.isoformat(), + "end": end.isoformat(), + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "unauthorized" diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index ef31bc33fc1a1f..841439bd34f1eb 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -388,6 +388,27 @@ async def test_websocket_get_prefs( assert msg["success"] +@pytest.mark.usefixtures("mock_camera") +async def test_websocket_update_prefs_requires_admin( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_read_only_access_token: str, +) -> None: + """Test updating camera preferences requires admin.""" + client = await hass_ws_client(hass, hass_read_only_access_token) + await client.send_json( + { + "id": 7, + "type": "camera/update_prefs", + "entity_id": "camera.demo_camera", + "preload_stream": True, + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "unauthorized" + + @pytest.mark.usefixtures("mock_camera") async def test_websocket_update_preload_prefs( hass: HomeAssistant, hass_ws_client: WebSocketGenerator diff --git a/tests/components/climate/test_condition.py b/tests/components/climate/test_condition.py index 13bf598241a204..b943a0005d9edc 100644 --- a/tests/components/climate/test_condition.py +++ b/tests/components/climate/test_condition.py @@ -22,6 +22,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, assert_numerical_condition_unit_conversion, other_states, parametrize_condition_states_all, @@ -59,6 +60,34 @@ async def test_climate_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("climate.is_off", {}, True, True), + ("climate.is_on", {}, True, False), + ("climate.is_cooling", {}, True, False), + ("climate.is_drying", {}, True, False), + ("climate.is_heating", {}, True, False), + ], +) +async def test_climate_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that climate conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 7fc8c73785bfc9..8ab862eef9522d 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -113,7 +113,10 @@ async def test_alexa_config_expose_entity_prefs( conf = alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) + assert "alexa" not in hass.config.components await conf.async_initialize() + await hass.async_block_till_done() + assert "alexa" in hass.config.components # an entity which is not in the entity registry can be exposed expose_entity(hass, "light.kitchen", True) @@ -134,11 +137,6 @@ async def test_alexa_config_expose_entity_prefs( expose_entity(hass, entity_entry5.entity_id, None) assert not conf.should_expose(entity_entry5.entity_id) - assert "alexa" not in hass.config.components - await hass.async_block_till_done() - assert "alexa" in hass.config.components - assert not conf.should_expose(entity_entry5.entity_id) - async def test_alexa_config_report_state( hass: HomeAssistant, cloud_prefs: CloudPreferences, cloud_stub: Mock diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 283e2ff39f1814..8b4d29155fa349 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -316,6 +316,51 @@ async def handler( assert '{"nonexisting": "payload"}' in caplog.text +async def test_webhook_msg_local_only(hass: HomeAssistant) -> None: + """Test a cloudhook for a local_only webhook does not fire the handler.""" + with patch("hass_nabucasa.Cloud.initialize"): + setup = await async_setup_component(hass, "cloud", {"cloud": {}}) + assert setup + cloud = hass.data[DATA_CLOUD] + + await cloud.client.prefs.async_initialize() + await cloud.client.prefs.async_update( + cloudhooks={ + "mock-webhook-id": { + "webhook_id": "mock-webhook-id", + "cloudhook_id": "mock-cloud-id", + }, + } + ) + + received = [] + + async def handler( + hass: HomeAssistant, webhook_id: str, request: web.Request + ) -> web.Response: + """Handle a webhook.""" + received.append(request) + return web.json_response({"from": "handler"}) + + webhook.async_register( + hass, "test", "Test", "mock-webhook-id", handler, local_only=True + ) + + response = await cloud.client.async_webhook_message( + { + "cloudhook_id": "mock-cloud-id", + "body": '{"hello": "world"}', + "headers": {"content-type": CONTENT_TYPE_JSON}, + "method": "POST", + "query": None, + } + ) + + assert response["status"] == 200 + # Handler not called because cloudhooks are not considered local + assert len(received) == 0 + + @pytest.mark.usefixtures("mock_cloud_setup", "mock_cloud_login") async def test_google_config_expose_entity( hass: HomeAssistant, diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index f0535c3ed35189..6f11ee8e03f048 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1803,6 +1803,44 @@ async def test_api_calls_require_admin( assert resp.status == HTTPStatus.UNAUTHORIZED +async def test_support_package_requires_admin( + setup_cloud: None, + hass_client: ClientSessionGenerator, + hass_read_only_access_token: str, +) -> None: + """Test the support package download is restricted to admins.""" + client = await hass_client(hass_read_only_access_token) + resp = await client.get("/api/cloud/support_package") + + assert resp.status == HTTPStatus.UNAUTHORIZED + + +@pytest.mark.parametrize( + "msg", + [ + {"type": "cloud/subscription"}, + {"type": "cloud/update_prefs", "alexa_report_state": True}, + {"type": "cloud/cloudhook/create", "webhook_id": "mock-webhook-id"}, + {"type": "cloud/cloudhook/delete", "webhook_id": "mock-webhook-id"}, + ], +) +async def test_ws_commands_require_admin( + hass: HomeAssistant, + setup_cloud: None, + hass_ws_client: WebSocketGenerator, + hass_read_only_access_token: str, + msg: dict[str, Any], +) -> None: + """Test cloud WebSocket commands do not work as a normal user.""" + client = await hass_ws_client(hass, hass_read_only_access_token) + + await client.send_json({"id": 5, **msg}) + response = await client.receive_json() + + assert not response["success"] + assert response["error"]["code"] == "unauthorized" + + async def test_login_view_dispatch_event( hass: HomeAssistant, cloud: MagicMock, diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index ccc67570212ccd..898b6ad2dbd974 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -274,6 +274,7 @@ async def test_get_tts_audio( # Force streaming await client.get(response["path"]) + await hass.async_block_till_done(wait_background_tasks=True) if data.get("engine_id", "").startswith("tts."): # Streaming diff --git a/tests/components/common.py b/tests/components/common.py index 9741f299b80106..b87f45ce8fc39f 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -1272,18 +1272,18 @@ async def assert_condition_behavior_any( for excluded_entity_id in excluded_entity_ids: set_or_remove_state(hass, excluded_entity_id, excluded_state) await hass.async_block_till_done() - assert cond(hass) is False + assert cond.async_check() is False set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert cond(hass) == state["condition_true"] + assert cond.async_check() == state["condition_true"] # Set other included entities to the included state to verify that # they don't change the condition evaluation for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() - assert cond(hass) == state["condition_true"] + assert cond.async_check() == state["condition_true"] async def assert_condition_behavior_all( @@ -1322,7 +1322,7 @@ async def assert_condition_behavior_all( set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert cond(hass) == state["condition_true_first_entity"] + assert cond.async_check() == state["condition_true_first_entity"] for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) @@ -1331,7 +1331,7 @@ async def assert_condition_behavior_all( set_or_remove_state(hass, excluded_entity_id, excluded_state) await hass.async_block_till_done() - assert cond(hass) == state["condition_true"] + assert cond.async_check() == state["condition_true"] async def assert_trigger_behavior_any( @@ -1964,10 +1964,10 @@ async def assert_numerical_condition_unit_conversion( ) for state in pass_states: set_or_remove_state(hass, entity_id, state) - assert cond(hass) is True + assert cond.async_check() is True for state in fail_states: set_or_remove_state(hass, entity_id, state) - assert cond(hass) is False + assert cond.async_check() is False # Test limits set by entity cond = await create_target_condition( @@ -1982,10 +1982,10 @@ async def assert_numerical_condition_unit_conversion( set_or_remove_state(hass, limit_entities[1], limit_states[1]) for state in pass_states: set_or_remove_state(hass, entity_id, state) - assert cond(hass) is True + assert cond.async_check() is True for state in fail_states: set_or_remove_state(hass, entity_id, state) - assert cond(hass) is False + assert cond.async_check() is False # Test invalid unit for limit_states in invalid_limit_entity_states: @@ -1993,7 +1993,7 @@ async def assert_numerical_condition_unit_conversion( set_or_remove_state(hass, limit_entities[1], limit_states[1]) for state in pass_states: set_or_remove_state(hass, entity_id, state) - assert cond(hass) is False + assert cond.async_check() is False for state in fail_states: set_or_remove_state(hass, entity_id, state) - assert cond(hass) is False + assert cond.async_check() is False diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py index c6a9547b451aa2..201c909199f352 100644 --- a/tests/components/config/test_auth.py +++ b/tests/components/config/test_auth.py @@ -26,7 +26,7 @@ async def test_list_requires_admin( """Test get users requires auth.""" client = await hass_ws_client(hass, hass_read_only_access_token) - await client.send_json({"id": 5, "type": auth_config.WS_TYPE_LIST}) + await client.send_json({"id": 5, "type": "config/auth/list"}) result = await client.receive_json() assert not result["success"], result @@ -65,7 +65,7 @@ async def test_list( access_token = hass.auth.async_create_access_token(refresh_token) client = await hass_ws_client(hass, access_token) - await client.send_json({"id": 5, "type": auth_config.WS_TYPE_LIST}) + await client.send_json({"id": 5, "type": "config/auth/list"}) result = await client.receive_json() assert result["success"], result @@ -125,9 +125,7 @@ async def test_delete_requires_admin( """Test delete command requires an admin.""" client = await hass_ws_client(hass, hass_read_only_access_token) - await client.send_json( - {"id": 5, "type": auth_config.WS_TYPE_DELETE, "user_id": "abcd"} - ) + await client.send_json({"id": 5, "type": "config/auth/delete", "user_id": "abcd"}) result = await client.receive_json() assert not result["success"], result @@ -142,7 +140,7 @@ async def test_delete_unable_self_account( refresh_token = hass.auth.async_validate_access_token(hass_access_token) await client.send_json( - {"id": 5, "type": auth_config.WS_TYPE_DELETE, "user_id": refresh_token.user.id} + {"id": 5, "type": "config/auth/delete", "user_id": refresh_token.user.id} ) result = await client.receive_json() @@ -156,9 +154,7 @@ async def test_delete_unknown_user( """Test we cannot delete an unknown user.""" client = await hass_ws_client(hass, hass_access_token) - await client.send_json( - {"id": 5, "type": auth_config.WS_TYPE_DELETE, "user_id": "abcd"} - ) + await client.send_json({"id": 5, "type": "config/auth/delete", "user_id": "abcd"}) result = await client.receive_json() assert not result["success"], result @@ -175,7 +171,7 @@ async def test_delete( cur_users = len(await hass.auth.async_get_users()) await client.send_json( - {"id": 5, "type": auth_config.WS_TYPE_DELETE, "user_id": test_user.id} + {"id": 5, "type": "config/auth/delete", "user_id": test_user.id} ) result = await client.receive_json() diff --git a/tests/components/configurator/test_init.py b/tests/components/configurator/test_init.py index da0c8058675487..5fabcbd9954bfc 100644 --- a/tests/components/configurator/test_init.py +++ b/tests/components/configurator/test_init.py @@ -6,10 +6,11 @@ from homeassistant.components import configurator from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import Unauthorized from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import MockUser, async_fire_time_changed @pytest.mark.parametrize( @@ -130,3 +131,22 @@ async def test_request_done_fail_silently_on_bad_request_id( ) -> None: """Test that request_done fails silently with a bad request id.""" configurator.async_request_done(hass, 2016) + + +@pytest.mark.parametrize( + "ignore_missing_translations", ["component.configurator.services.configure."] +) +async def test_configure_service_requires_admin( + hass: HomeAssistant, hass_read_only_user: MockUser +) -> None: + """Test the configure service requires admin.""" + request_id = configurator.async_request_config(hass, "Test Request", lambda _: None) + + with pytest.raises(Unauthorized): + await hass.services.async_call( + configurator.DOMAIN, + configurator.SERVICE_CONFIGURE, + {configurator.ATTR_CONFIGURE_ID: request_id}, + context=Context(user_id=hass_read_only_user.id), + blocking=True, + ) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 52b3445fb1c89b..a5d9366c8b72f9 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -98,6 +98,7 @@ from .conversation import MockAgent from .device_tracker.common import MockScanner from .light.common import MockLight + from .radio_frequency.common import MockRadioFrequencyEntity from .sensor.common import MockSensor from .switch.common import MockSwitch @@ -204,6 +205,27 @@ def mock_conversation_agent_fixture(hass: HomeAssistant) -> MockAgent: return mock_conversation_agent_fixture_helper(hass) +# Radio frequency test fixtures +@pytest.fixture(name="init_radio_frequency") +async def init_radio_frequency_fixture(hass: HomeAssistant) -> None: + """Set up the Radio Frequency integration for testing.""" + from .radio_frequency.common import ( # noqa: PLC0415 + init_radio_frequency_fixture_helper, + ) + + await init_radio_frequency_fixture_helper(hass) + + +@pytest.fixture(name="mock_rf_entity") +async def mock_rf_entity_fixture( + hass: HomeAssistant, init_radio_frequency: None +) -> MockRadioFrequencyEntity: + """Return a mock radio frequency entity.""" + from .radio_frequency.common import mock_rf_entity_fixture_helper # noqa: PLC0415 + + return await mock_rf_entity_fixture_helper(hass) + + @pytest.fixture(scope="session", autouse=find_spec("haffmpeg") is not None) def prevent_ffmpeg_subprocess() -> Generator[None]: """If installed, prevent ffmpeg from creating a subprocess.""" diff --git a/tests/components/counter/test_condition.py b/tests/components/counter/test_condition.py index c25695edbfb6e0..36d89889f86bfd 100644 --- a/tests/components/counter/test_condition.py +++ b/tests/components/counter/test_condition.py @@ -11,6 +11,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -31,6 +32,33 @@ async def test_counter_condition_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, "counter.is_value") +_PLAIN_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("counter.is_value", _PLAIN_THRESHOLD, True, False), + ], +) +async def test_counter_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that counter conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/cover/test_condition.py b/tests/components/cover/test_condition.py index 2ee5f034e82e85..5d74a0dc0d2517 100644 --- a/tests/components/cover/test_condition.py +++ b/tests/components/cover/test_condition.py @@ -13,6 +13,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -50,6 +51,39 @@ async def test_cover_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("cover.awning_is_closed", {}, True, True), + ("cover.awning_is_open", {}, True, True), + ("cover.blind_is_closed", {}, True, True), + ("cover.blind_is_open", {}, True, True), + ("cover.curtain_is_closed", {}, True, True), + ("cover.curtain_is_open", {}, True, True), + ("cover.shade_is_closed", {}, True, True), + ("cover.shade_is_open", {}, True, True), + ("cover.shutter_is_closed", {}, True, True), + ("cover.shutter_is_open", {}, True, True), + ], +) +async def test_cover_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that cover conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), @@ -251,7 +285,7 @@ async def test_cover_condition_excludes_non_matching_device_class( ) # Matching entity in matching state - condition should be True - assert condition_any(hass) is True + assert condition_any.async_check() is True # Set matching entity to non-matching state hass.states.async_set( @@ -262,4 +296,4 @@ async def test_cover_condition_excludes_non_matching_device_class( await hass.async_block_till_done() # Wrong device class entity still in matching state, but should be excluded - assert condition_any(hass) is False + assert condition_any.async_check() is False diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index 29337d5d369870..ca107e93816ce4 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -213,6 +213,7 @@ async def setup_tests( assert state is not None assert round(float(state.state), config["sensor"]["round"]) == expected_state + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT return state diff --git a/tests/components/diagnostics/test_init.py b/tests/components/diagnostics/test_init.py index 98686432f2e1d4..bbfb4abfe332e9 100644 --- a/tests/components/diagnostics/test_init.py +++ b/tests/components/diagnostics/test_init.py @@ -331,6 +331,22 @@ async def test_download_diagnostics( } +async def test_download_diagnostics_requires_admin( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_read_only_access_token: str, +) -> None: + """Test diagnostics download is restricted to admin users.""" + config_entry = MockConfigEntry(domain="fake_integration") + config_entry.add_to_hass(hass) + + client = await hass_client(hass_read_only_access_token) + response = await client.get( + f"/api/diagnostics/config_entry/{config_entry.entry_id}" + ) + assert response.status == HTTPStatus.UNAUTHORIZED + + async def test_failure_scenarios( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: diff --git a/tests/components/dnsip/test_init.py b/tests/components/dnsip/test_init.py index 8d408b82156a82..ffaae3eaaee410 100644 --- a/tests/components/dnsip/test_init.py +++ b/tests/components/dnsip/test_init.py @@ -91,12 +91,52 @@ async def test_port_migration( await hass.async_block_till_done() assert entry.version == 1 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert entry.options[CONF_PORT] == DEFAULT_PORT assert entry.options[CONF_PORT_IPV6] == DEFAULT_PORT assert entry.state is ConfigEntryState.LOADED +async def test_remove_unique_id_migration( + hass: HomeAssistant, +) -> None: + """Test migration of the config entry removing the unique_id.""" + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={ + CONF_HOSTNAME: "home-assistant.io", + CONF_NAME: "home-assistant.io", + CONF_IPV4: True, + CONF_IPV6: True, + }, + options={ + CONF_RESOLVER: "208.67.222.222", + CONF_RESOLVER_IPV6: "2620:119:53::53", + CONF_PORT: DEFAULT_PORT, + CONF_PORT_IPV6: DEFAULT_PORT, + }, + entry_id="1", + unique_id="home-assistant.io", + version=1, + minor_version=2, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + return_value=RetrieveDNS(), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 1 + assert entry.minor_version == 3 + assert entry.unique_id is None + assert entry.state is ConfigEntryState.LOADED + + async def test_migrate_error_from_future(hass: HomeAssistant) -> None: """Test a future version isn't migrated.""" diff --git a/tests/components/door/test_condition.py b/tests/components/door/test_condition.py index 7c267a8df8baaf..22ed49afca00a3 100644 --- a/tests/components/door/test_condition.py +++ b/tests/components/door/test_condition.py @@ -13,6 +13,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -47,6 +48,31 @@ async def test_door_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("door.is_closed", {}, True, True), + ("door.is_open", {}, True, True), + ], +) +async def test_door_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that door conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + # --- binary_sensor tests --- @@ -346,7 +372,7 @@ async def test_door_condition_excludes_non_door_device_class( ) # Matching entities in matching state - condition should be True - assert condition_any(hass) is True + assert condition_any.async_check() is True # Set matching entities to non-matching state hass.states.async_set( @@ -360,4 +386,4 @@ async def test_door_condition_excludes_non_door_device_class( await hass.async_block_till_done() # Wrong device class entities still in matching state, but should be excluded - assert condition_any(hass) is False + assert condition_any.async_check() is False diff --git a/tests/components/dsmr/test_diagnostics.py b/tests/components/dsmr/test_diagnostics.py index f2a475097ae6f1..457e05a0af8a6e 100644 --- a/tests/components/dsmr/test_diagnostics.py +++ b/tests/components/dsmr/test_diagnostics.py @@ -10,6 +10,7 @@ GAS_METER_READING, ) from dsmr_parser.objects import CosemObject, MBusObject, Telegram +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -19,6 +20,9 @@ from tests.typing import ClientSessionGenerator +@pytest.mark.xfail( + reason="Flaky due to Python 3.14.3 asyncio changes - see home-assistant/core#162263" +) async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index c67bdbfd444fd5..12db2ed17c0dff 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -51,6 +51,9 @@ from tests.common import MockConfigEntry, patch +@pytest.mark.xfail( + reason="Flaky due to Python 3.14.3 asyncio changes - see home-assistant/core#162263" +) async def test_default_setup( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/duco/conftest.py b/tests/components/duco/conftest.py index 00c793ea14a3d8..668d20a0150500 100644 --- a/tests/components/duco/conftest.py +++ b/tests/components/duco/conftest.py @@ -96,6 +96,7 @@ def mock_nodes() -> list[Node]: iaq_co2=None, rh=None, iaq_rh=None, + temp=27.9, ), ), Node( @@ -121,6 +122,7 @@ def mock_nodes() -> list[Node]: iaq_co2=80, rh=None, iaq_rh=None, + temp=19.8, ), ), Node( @@ -146,6 +148,7 @@ def mock_nodes() -> list[Node]: iaq_co2=None, rh=42.0, iaq_rh=85, + temp=27.9, ), ), Node( @@ -171,6 +174,7 @@ def mock_nodes() -> list[Node]: iaq_co2=None, rh=61.0, iaq_rh=90, + temp=22.5, ), ), ] @@ -184,6 +188,12 @@ def mock_duco_client( ) -> Generator[AsyncMock]: """Return a mocked DucoClient used by both the integration and config flow.""" with ( + patch( + "homeassistant.components.duco.build_ssl_context", + ), + patch( + "homeassistant.components.duco.config_flow.build_ssl_context", + ), patch( "homeassistant.components.duco.DucoClient", autospec=True, diff --git a/tests/components/duco/snapshots/test_diagnostics.ambr b/tests/components/duco/snapshots/test_diagnostics.ambr index fa1cea8cfc4eb5..029af1a1798c41 100644 --- a/tests/components/duco/snapshots/test_diagnostics.ambr +++ b/tests/components/duco/snapshots/test_diagnostics.ambr @@ -45,7 +45,7 @@ 'iaq_co2': None, 'iaq_rh': None, 'rh': None, - 'temp': None, + 'temp': 27.9, }), 'ventilation': dict({ 'flow_lvl_tgt': 0, @@ -71,7 +71,7 @@ 'iaq_co2': None, 'iaq_rh': 85, 'rh': 42.0, - 'temp': None, + 'temp': 27.9, }), 'ventilation': dict({ 'flow_lvl_tgt': None, @@ -97,7 +97,7 @@ 'iaq_co2': 80, 'iaq_rh': None, 'rh': None, - 'temp': None, + 'temp': 19.8, }), 'ventilation': dict({ 'flow_lvl_tgt': None, @@ -123,7 +123,7 @@ 'iaq_co2': None, 'iaq_rh': 90, 'rh': 61.0, - 'temp': None, + 'temp': 22.5, }), 'ventilation': dict({ 'flow_lvl_tgt': None, diff --git a/tests/components/duco/snapshots/test_sensor.ambr b/tests/components/duco/snapshots/test_sensor.ambr index 273138ad354df2..99816c86d8ce49 100644 --- a/tests/components/duco/snapshots/test_sensor.ambr +++ b/tests/components/duco/snapshots/test_sensor.ambr @@ -108,6 +108,64 @@ 'state': '85', }) # --- +# name: test_sensor_entities_state[sensor.bathroom_rh_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bathroom_rh_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'duco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_113_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_state[sensor.bathroom_rh_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Bathroom RH Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bathroom_rh_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.9', + }) +# --- # name: test_sensor_entities_state[sensor.kitchen_rh_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -217,6 +275,122 @@ 'state': '90', }) # --- +# name: test_sensor_entities_state[sensor.kitchen_rh_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_rh_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'duco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_50_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_state[sensor.kitchen_rh_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Kitchen RH Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.kitchen_rh_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.5', + }) +# --- +# name: test_sensor_entities_state[sensor.living_box_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.living_box_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Box temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Box temperature', + 'platform': 'duco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'box_temperature', + 'unique_id': 'aa:bb:cc:dd:ee:ff_1_box_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_state[sensor.living_box_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Living Box temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.living_box_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.9', + }) +# --- # name: test_sensor_entities_state[sensor.living_signal_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -471,3 +645,61 @@ 'state': '80', }) # --- +# name: test_sensor_entities_state[sensor.office_co2_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_co2_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'duco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_2_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_state[sensor.office_co2_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Office CO2 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_co2_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.8', + }) +# --- diff --git a/tests/components/duco/test_config_flow.py b/tests/components/duco/test_config_flow.py index a5973699c98134..758caa791a5f36 100644 --- a/tests/components/duco/test_config_flow.py +++ b/tests/components/duco/test_config_flow.py @@ -3,10 +3,11 @@ from __future__ import annotations from ipaddress import IPv4Address -from unittest.mock import AsyncMock +from ssl import SSLContext +from unittest.mock import ANY, AsyncMock, MagicMock, patch from duco.exceptions import DucoConnectionError, DucoError -from duco.models import LanInfo +from duco.models import BoardInfo, LanInfo import pytest from homeassistant.components.duco.const import DOMAIN @@ -454,3 +455,41 @@ async def test_dhcp_discovery_exception_recovery( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].unique_id == TEST_MAC + + +async def test_user_flow_builds_ssl_context_in_executor( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_board_info: BoardInfo, + mock_lan_info: LanInfo, +) -> None: + """Test that build_ssl_context runs in an executor and its result is passed to DucoClient.""" + mock_ssl_context = MagicMock(spec=SSLContext) + with ( + patch( + "homeassistant.components.duco.config_flow.build_ssl_context", + return_value=mock_ssl_context, + ) as mock_build, + patch( + "homeassistant.components.duco.config_flow.DucoClient", + autospec=True, + ) as mock_client_class, + ): + mock_client_class.return_value.async_get_board_info.return_value = ( + mock_board_info + ) + mock_client_class.return_value.async_get_lan_info.return_value = mock_lan_info + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + mock_build.assert_called_once() + mock_client_class.assert_called_once_with( + session=ANY, + host=TEST_HOST, + ssl_context=mock_ssl_context, + ) diff --git a/tests/components/duco/test_diagnostics.py b/tests/components/duco/test_diagnostics.py index 2b5671d8f7b0d6..8cfd8569546797 100644 --- a/tests/components/duco/test_diagnostics.py +++ b/tests/components/duco/test_diagnostics.py @@ -2,12 +2,16 @@ from __future__ import annotations +from http import HTTPStatus from unittest.mock import AsyncMock +from duco.exceptions import DucoConnectionError import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.diagnostics import DOMAIN as DIAGNOSTICS_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -27,3 +31,28 @@ async def test_diagnostics( await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) == snapshot ) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + "failing_method", + ["async_get_lan_info", "async_get_diagnostics", "async_get_write_req_remaining"], +) +async def test_diagnostics_connection_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + failing_method: str, +) -> None: + """Test that a connection error during diagnostics returns a 500 response.""" + getattr(mock_duco_client, failing_method).side_effect = DucoConnectionError( + "Server disconnected" + ) + assert await async_setup_component(hass, DIAGNOSTICS_DOMAIN, {}) + await hass.async_block_till_done() + client = await hass_client() + response = await client.get( + f"/api/diagnostics/config_entry/{mock_config_entry.entry_id}" + ) + assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR diff --git a/tests/components/duco/test_init.py b/tests/components/duco/test_init.py index cef7f759ef53b5..d8b80fc1e72be1 100644 --- a/tests/components/duco/test_init.py +++ b/tests/components/duco/test_init.py @@ -2,14 +2,18 @@ from __future__ import annotations -from unittest.mock import AsyncMock +from ssl import SSLContext +from unittest.mock import ANY, AsyncMock, MagicMock, patch from duco.exceptions import DucoConnectionError, DucoError +from duco.models import BoardInfo, DiagComponent, DiagStatus, LanInfo, Node import pytest from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from .conftest import TEST_HOST + from tests.common import MockConfigEntry @@ -70,3 +74,43 @@ async def test_unload_entry( await hass.async_block_till_done() assert init_integration.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_entry_builds_ssl_context_in_executor( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_board_info: BoardInfo, + mock_lan_info: LanInfo, + mock_nodes: list[Node], +) -> None: + """Test that build_ssl_context runs in an executor and its result is passed to DucoClient.""" + mock_ssl_context = MagicMock(spec=SSLContext) + with ( + patch( + "homeassistant.components.duco.build_ssl_context", + return_value=mock_ssl_context, + ) as mock_build, + patch( + "homeassistant.components.duco.DucoClient", + autospec=True, + ) as mock_client_class, + ): + mock_client_class.return_value.async_get_board_info.return_value = ( + mock_board_info + ) + mock_client_class.return_value.async_get_lan_info.return_value = mock_lan_info + mock_client_class.return_value.async_get_nodes.return_value = mock_nodes + mock_client_class.return_value.async_get_diagnostics.return_value = [ + DiagComponent(component="Ventilation", status=DiagStatus.OK) + ] + mock_client_class.return_value.async_get_write_req_remaining.return_value = 100 + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_build.assert_called_once() + mock_client_class.assert_called_once_with( + session=ANY, + host=TEST_HOST, + ssl_context=mock_ssl_context, + ) diff --git a/tests/components/duco/test_sensor.py b/tests/components/duco/test_sensor.py index b4933b0bc11454..2a55672504eaf6 100644 --- a/tests/components/duco/test_sensor.py +++ b/tests/components/duco/test_sensor.py @@ -71,7 +71,10 @@ async def test_diagnostic_sensor_entities_disabled_by_default( entity_registry: er.EntityRegistry, ) -> None: """Test that diagnostic sensor entities are disabled by default.""" - for entity_id in ("sensor.living_signal_strength",): + for entity_id in ( + "sensor.living_signal_strength", + "sensor.living_box_temperature", + ): entry = entity_registry.async_get(entity_id) assert entry is not None assert entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION @@ -168,6 +171,7 @@ async def test_new_node_added_dynamically( iaq_co2=None, rh=55.0, iaq_rh=70, + temp=21.0, ), ) mock_duco_client.async_get_nodes.return_value = [*mock_nodes, new_node] diff --git a/tests/components/easyenergy/conftest.py b/tests/components/easyenergy/conftest.py index f2ed2cf4dbc53a..4aa1f76e1ed088 100644 --- a/tests/components/easyenergy/conftest.py +++ b/tests/components/easyenergy/conftest.py @@ -9,7 +9,7 @@ from homeassistant.components.easyenergy.const import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_load_json_array_fixture +from tests.common import MockConfigEntry, async_load_json_object_fixture @pytest.fixture @@ -39,11 +39,18 @@ async def mock_easyenergy(hass: HomeAssistant) -> AsyncGenerator[MagicMock]: "homeassistant.components.easyenergy.coordinator.EasyEnergy", autospec=True ) as easyenergy_mock: client = easyenergy_mock.return_value + energy_data = await async_load_json_object_fixture( + hass, "today_energy.json", DOMAIN + ) client.energy_prices.return_value = Electricity.from_dict( - await async_load_json_array_fixture(hass, "today_energy.json", DOMAIN) + energy_data["prices"], + price_key="priceIncVat", + return_price_key="priceIncVat", ) + gas_data = await async_load_json_object_fixture(hass, "today_gas.json", DOMAIN) client.gas_prices.return_value = Gas.from_dict( - await async_load_json_array_fixture(hass, "today_gas.json", DOMAIN) + gas_data["prices"], + price_key="priceIncVat", ) yield client diff --git a/tests/components/easyenergy/fixtures/today_energy.json b/tests/components/easyenergy/fixtures/today_energy.json index 8e91a6244ac439..79725f9f5c7cf8 100644 --- a/tests/components/easyenergy/fixtures/today_energy.json +++ b/tests/components/easyenergy/fixtures/today_energy.json @@ -1,146 +1,317 @@ -[ - { - "Timestamp": "2023-01-18T23:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.1349513, - "TariffReturn": 0.11153 - }, - { - "Timestamp": "2023-01-19T00:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.1294458, - "TariffReturn": 0.10698 - }, - { - "Timestamp": "2023-01-19T01:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.1270137, - "TariffReturn": 0.10497 - }, - { - "Timestamp": "2023-01-19T02:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.1230812, - "TariffReturn": 0.10172 - }, - { - "Timestamp": "2023-01-19T03:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.1297483, - "TariffReturn": 0.10723 - }, - { - "Timestamp": "2023-01-19T04:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.1386902, - "TariffReturn": 0.11462 - }, - { - "Timestamp": "2023-01-19T05:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.1439174, - "TariffReturn": 0.11894 - }, - { - "Timestamp": "2023-01-19T06:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.193479, - "TariffReturn": 0.1599 - }, - { - "Timestamp": "2023-01-19T07:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.19844, - "TariffReturn": 0.164 - }, - { - "Timestamp": "2023-01-19T08:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.2077449, - "TariffReturn": 0.17169 - }, - { - "Timestamp": "2023-01-19T09:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.16819, - "TariffReturn": 0.139 - }, - { - "Timestamp": "2023-01-19T10:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.1649835, - "TariffReturn": 0.13635 - }, - { - "Timestamp": "2023-01-19T11:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.156816, - "TariffReturn": 0.1296 - }, - { - "Timestamp": "2023-01-19T12:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.1873927, - "TariffReturn": 0.15487 - }, - { - "Timestamp": "2023-01-19T13:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.1941929, - "TariffReturn": 0.16049 - }, - { - "Timestamp": "2023-01-19T14:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.2129116, - "TariffReturn": 0.17596 - }, - { - "Timestamp": "2023-01-19T15:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.2254109, - "TariffReturn": 0.18629 - }, - { - "Timestamp": "2023-01-19T16:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.2467674, - "TariffReturn": 0.20394 - }, - { - "Timestamp": "2023-01-19T17:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.2390597, - "TariffReturn": 0.19757 - }, - { - "Timestamp": "2023-01-19T18:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.2074303, - "TariffReturn": 0.17143 - }, - { - "Timestamp": "2023-01-19T19:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.1815, - "TariffReturn": 0.15 - }, - { - "Timestamp": "2023-01-19T20:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.1795761, - "TariffReturn": 0.14841 - }, - { - "Timestamp": "2023-01-19T21:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.1807014, - "TariffReturn": 0.14934 - }, - { - "Timestamp": "2023-01-19T22:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.16819, - "TariffReturn": 0.139 - } -] +{ + "providedAt": "2026-04-19T22:16:44.4866876Z", + "prices": [ + { + "from": "2026-04-19T00:00:00.0000000", + "until": "2026-04-19T01:00:00.0000000", + "price": 0.11549, + "unit": "kWh", + "priceIncVat": 0.13975, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.27238, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T01:00:00.0000000", + "until": "2026-04-19T02:00:00.0000000", + "price": 0.10522, + "unit": "kWh", + "priceIncVat": 0.12732, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.25995, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T02:00:00.0000000", + "until": "2026-04-19T03:00:00.0000000", + "price": 0.1092, + "unit": "kWh", + "priceIncVat": 0.13213, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.26476, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T03:00:00.0000000", + "until": "2026-04-19T04:00:00.0000000", + "price": 0.10325, + "unit": "kWh", + "priceIncVat": 0.12493, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.25756, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T04:00:00.0000000", + "until": "2026-04-19T05:00:00.0000000", + "price": 0.10346, + "unit": "kWh", + "priceIncVat": 0.12519, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.25782, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T05:00:00.0000000", + "until": "2026-04-19T06:00:00.0000000", + "price": 0.09795, + "unit": "kWh", + "priceIncVat": 0.11852, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.25115, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T06:00:00.0000000", + "until": "2026-04-19T07:00:00.0000000", + "price": 0.10071, + "unit": "kWh", + "priceIncVat": 0.12186, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.25449, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T07:00:00.0000000", + "until": "2026-04-19T08:00:00.0000000", + "price": 0.09793, + "unit": "kWh", + "priceIncVat": 0.1185, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.25113, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T08:00:00.0000000", + "until": "2026-04-19T09:00:00.0000000", + "price": 0.09725, + "unit": "kWh", + "priceIncVat": 0.11768, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.25031, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T09:00:00.0000000", + "until": "2026-04-19T10:00:00.0000000", + "price": 0.08766, + "unit": "kWh", + "priceIncVat": 0.10607, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.2387, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T10:00:00.0000000", + "until": "2026-04-19T11:00:00.0000000", + "price": 0.06132, + "unit": "kWh", + "priceIncVat": 0.07419, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.20682, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T11:00:00.0000000", + "until": "2026-04-19T12:00:00.0000000", + "price": 0.02832, + "unit": "kWh", + "priceIncVat": 0.03426, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.16689, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T12:00:00.0000000", + "until": "2026-04-19T13:00:00.0000000", + "price": 0.00806, + "unit": "kWh", + "priceIncVat": 0.00975, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.14238, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T13:00:00.0000000", + "until": "2026-04-19T14:00:00.0000000", + "price": -0.00085, + "unit": "kWh", + "priceIncVat": -0.00085, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.13178, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T14:00:00.0000000", + "until": "2026-04-19T15:00:00.0000000", + "price": -0.003, + "unit": "kWh", + "priceIncVat": -0.003, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.12963, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T15:00:00.0000000", + "until": "2026-04-19T16:00:00.0000000", + "price": -0.00226, + "unit": "kWh", + "priceIncVat": -0.00226, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.13037, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T16:00:00.0000000", + "until": "2026-04-19T17:00:00.0000000", + "price": 0.01348, + "unit": "kWh", + "priceIncVat": 0.01631, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.14894, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T17:00:00.0000000", + "until": "2026-04-19T18:00:00.0000000", + "price": 0.06533, + "unit": "kWh", + "priceIncVat": 0.07905, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.21168, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T18:00:00.0000000", + "until": "2026-04-19T19:00:00.0000000", + "price": 0.10385, + "unit": "kWh", + "priceIncVat": 0.12566, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.25829, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T19:00:00.0000000", + "until": "2026-04-19T20:00:00.0000000", + "price": 0.11681, + "unit": "kWh", + "priceIncVat": 0.14134, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.27397, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T20:00:00.0000000", + "until": "2026-04-19T21:00:00.0000000", + "price": 0.12464, + "unit": "kWh", + "priceIncVat": 0.15082, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.28345, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T21:00:00.0000000", + "until": "2026-04-19T22:00:00.0000000", + "price": 0.12214, + "unit": "kWh", + "priceIncVat": 0.14779, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.28042, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T22:00:00.0000000", + "until": "2026-04-19T23:00:00.0000000", + "price": 0.11906, + "unit": "kWh", + "priceIncVat": 0.14407, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.2767, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + }, + { + "from": "2026-04-19T23:00:00.0000000", + "until": "2026-04-20T00:00:00.0000000", + "price": 0.1113, + "unit": "kWh", + "priceIncVat": 0.13467, + "energyTax": 0.11085, + "purchasePrice": 0.02178, + "invoicePrice": 0.2673, + "average": 0.0786, + "averageInc": 0.09516, + "granularity": "hour" + } + ] +} diff --git a/tests/components/easyenergy/fixtures/today_gas.json b/tests/components/easyenergy/fixtures/today_gas.json index ed3e0106b06c36..b51b25d7dbffc3 100644 --- a/tests/components/easyenergy/fixtures/today_gas.json +++ b/tests/components/easyenergy/fixtures/today_gas.json @@ -1,146 +1,31 @@ -[ - { - "Timestamp": "2023-01-19T05:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-19T06:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-19T07:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-19T08:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-19T09:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-19T10:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-19T11:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-19T12:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-19T13:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-19T14:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-19T15:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-19T16:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-19T17:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-19T18:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-19T19:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-19T20:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-19T21:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-19T22:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-19T23:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-20T00:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-20T01:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-20T02:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-20T03:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - }, - { - "Timestamp": "2023-01-20T04:00:00+00:00", - "SupplierId": 0, - "TariffUsage": 0.7252982, - "TariffReturn": 0.7252982 - } -] +{ + "providedAt": "2026-04-19T22:16:43.4740091Z", + "prices": [ + { + "from": "2026-04-19T00:00:00.0000000", + "until": "2026-04-20T00:00:00.0000000", + "price": 0.50983, + "unit": "m3", + "priceIncVat": 0.6169, + "energyTax": 0.7268, + "purchasePrice": 0.079, + "invoicePrice": 1.4227, + "average": 0.49402, + "averageInc": 0.59776, + "granularity": "day" + }, + { + "from": "2026-04-20T00:00:00.0000000", + "until": "2026-04-21T00:00:00.0000000", + "price": 0.4782, + "unit": "m3", + "priceIncVat": 0.57862, + "energyTax": 0.7268, + "purchasePrice": 0.079, + "invoicePrice": 1.38442, + "average": 0.49402, + "averageInc": 0.59776, + "granularity": "day" + } + ] +} diff --git a/tests/components/easyenergy/snapshots/test_diagnostics.ambr b/tests/components/easyenergy/snapshots/test_diagnostics.ambr index 805846832aa7ff..50bc963d9e4f35 100644 --- a/tests/components/easyenergy/snapshots/test_diagnostics.ambr +++ b/tests/components/easyenergy/snapshots/test_diagnostics.ambr @@ -2,55 +2,55 @@ # name: test_diagnostics dict({ 'energy_return': dict({ - 'average_price': 0.14599, - 'current_hour_price': 0.18629, - 'highest_price_time': '2023-01-19T16:00:00+00:00', - 'lowest_price_time': '2023-01-19T02:00:00+00:00', - 'max_price': 0.20394, - 'min_price': 0.10172, - 'next_hour_price': 0.20394, - 'percentage_of_max': 91.35, + 'average_price': 0.09516, + 'current_hour_price': -0.00226, + 'highest_price_time': '2026-04-19T18:00:00+00:00', + 'lowest_price_time': '2026-04-19T12:00:00+00:00', + 'max_price': 0.15082, + 'min_price': -0.003, + 'next_hour_price': 0.01631, + 'percentage_of_max': -1.5, }), 'energy_usage': dict({ - 'average_price': 0.17665, - 'current_hour_price': 0.22541, - 'highest_price_time': '2023-01-19T16:00:00+00:00', - 'lowest_price_time': '2023-01-19T02:00:00+00:00', - 'max_price': 0.24677, - 'min_price': 0.12308, - 'next_hour_price': 0.24677, - 'percentage_of_max': 91.34, + 'average_price': 0.09516, + 'current_hour_price': -0.00226, + 'highest_price_time': '2026-04-19T18:00:00+00:00', + 'lowest_price_time': '2026-04-19T12:00:00+00:00', + 'max_price': 0.15082, + 'min_price': -0.003, + 'next_hour_price': 0.01631, + 'percentage_of_max': -1.5, }), 'entry': dict({ 'title': 'energy', }), 'gas': dict({ - 'current_hour_price': 0.7253, - 'next_hour_price': 0.7253, + 'current_hour_price': 0.6169, + 'next_hour_price': 0.6169, }), }) # --- # name: test_diagnostics_no_gas_today dict({ 'energy_return': dict({ - 'average_price': 0.14599, - 'current_hour_price': 0.18629, - 'highest_price_time': '2023-01-19T16:00:00+00:00', - 'lowest_price_time': '2023-01-19T02:00:00+00:00', - 'max_price': 0.20394, - 'min_price': 0.10172, - 'next_hour_price': 0.20394, - 'percentage_of_max': 91.35, + 'average_price': 0.09516, + 'current_hour_price': -0.00226, + 'highest_price_time': '2026-04-19T18:00:00+00:00', + 'lowest_price_time': '2026-04-19T12:00:00+00:00', + 'max_price': 0.15082, + 'min_price': -0.003, + 'next_hour_price': 0.01631, + 'percentage_of_max': -1.5, }), 'energy_usage': dict({ - 'average_price': 0.17665, - 'current_hour_price': 0.22541, - 'highest_price_time': '2023-01-19T16:00:00+00:00', - 'lowest_price_time': '2023-01-19T02:00:00+00:00', - 'max_price': 0.24677, - 'min_price': 0.12308, - 'next_hour_price': 0.24677, - 'percentage_of_max': 91.34, + 'average_price': 0.09516, + 'current_hour_price': -0.00226, + 'highest_price_time': '2026-04-19T18:00:00+00:00', + 'lowest_price_time': '2026-04-19T12:00:00+00:00', + 'max_price': 0.15082, + 'min_price': -0.003, + 'next_hour_price': 0.01631, + 'percentage_of_max': -1.5, }), 'entry': dict({ 'title': 'energy', diff --git a/tests/components/easyenergy/snapshots/test_services.ambr b/tests/components/easyenergy/snapshots/test_services.ambr index 3330e5cf03c0ce..4c197e66cf243e 100644 --- a/tests/components/easyenergy/snapshots/test_services.ambr +++ b/tests/components/easyenergy/snapshots/test_services.ambr @@ -3,100 +3,100 @@ dict({ 'prices': list([ dict({ - 'price': 0.11153, - 'timestamp': '2023-01-18 23:00:00+00:00', + 'price': 0.13975, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.10698, - 'timestamp': '2023-01-19 00:00:00+00:00', + 'price': 0.12732, + 'timestamp': '2026-04-18 23:00:00+00:00', }), dict({ - 'price': 0.10497, - 'timestamp': '2023-01-19 01:00:00+00:00', + 'price': 0.13213, + 'timestamp': '2026-04-19 00:00:00+00:00', }), dict({ - 'price': 0.10172, - 'timestamp': '2023-01-19 02:00:00+00:00', + 'price': 0.12493, + 'timestamp': '2026-04-19 01:00:00+00:00', }), dict({ - 'price': 0.10723, - 'timestamp': '2023-01-19 03:00:00+00:00', + 'price': 0.12519, + 'timestamp': '2026-04-19 02:00:00+00:00', }), dict({ - 'price': 0.11462, - 'timestamp': '2023-01-19 04:00:00+00:00', + 'price': 0.11852, + 'timestamp': '2026-04-19 03:00:00+00:00', }), dict({ - 'price': 0.11894, - 'timestamp': '2023-01-19 05:00:00+00:00', + 'price': 0.12186, + 'timestamp': '2026-04-19 04:00:00+00:00', }), dict({ - 'price': 0.1599, - 'timestamp': '2023-01-19 06:00:00+00:00', + 'price': 0.1185, + 'timestamp': '2026-04-19 05:00:00+00:00', }), dict({ - 'price': 0.164, - 'timestamp': '2023-01-19 07:00:00+00:00', + 'price': 0.11768, + 'timestamp': '2026-04-19 06:00:00+00:00', }), dict({ - 'price': 0.17169, - 'timestamp': '2023-01-19 08:00:00+00:00', + 'price': 0.10607, + 'timestamp': '2026-04-19 07:00:00+00:00', }), dict({ - 'price': 0.139, - 'timestamp': '2023-01-19 09:00:00+00:00', + 'price': 0.07419, + 'timestamp': '2026-04-19 08:00:00+00:00', }), dict({ - 'price': 0.13635, - 'timestamp': '2023-01-19 10:00:00+00:00', + 'price': 0.03426, + 'timestamp': '2026-04-19 09:00:00+00:00', }), dict({ - 'price': 0.1296, - 'timestamp': '2023-01-19 11:00:00+00:00', + 'price': 0.00975, + 'timestamp': '2026-04-19 10:00:00+00:00', }), dict({ - 'price': 0.15487, - 'timestamp': '2023-01-19 12:00:00+00:00', + 'price': -0.00085, + 'timestamp': '2026-04-19 11:00:00+00:00', }), dict({ - 'price': 0.16049, - 'timestamp': '2023-01-19 13:00:00+00:00', + 'price': -0.003, + 'timestamp': '2026-04-19 12:00:00+00:00', }), dict({ - 'price': 0.17596, - 'timestamp': '2023-01-19 14:00:00+00:00', + 'price': -0.00226, + 'timestamp': '2026-04-19 13:00:00+00:00', }), dict({ - 'price': 0.18629, - 'timestamp': '2023-01-19 15:00:00+00:00', + 'price': 0.01631, + 'timestamp': '2026-04-19 14:00:00+00:00', }), dict({ - 'price': 0.20394, - 'timestamp': '2023-01-19 16:00:00+00:00', + 'price': 0.07905, + 'timestamp': '2026-04-19 15:00:00+00:00', }), dict({ - 'price': 0.19757, - 'timestamp': '2023-01-19 17:00:00+00:00', + 'price': 0.12566, + 'timestamp': '2026-04-19 16:00:00+00:00', }), dict({ - 'price': 0.17143, - 'timestamp': '2023-01-19 18:00:00+00:00', + 'price': 0.14134, + 'timestamp': '2026-04-19 17:00:00+00:00', }), dict({ - 'price': 0.15, - 'timestamp': '2023-01-19 19:00:00+00:00', + 'price': 0.15082, + 'timestamp': '2026-04-19 18:00:00+00:00', }), dict({ - 'price': 0.14841, - 'timestamp': '2023-01-19 20:00:00+00:00', + 'price': 0.14779, + 'timestamp': '2026-04-19 19:00:00+00:00', }), dict({ - 'price': 0.14934, - 'timestamp': '2023-01-19 21:00:00+00:00', + 'price': 0.14407, + 'timestamp': '2026-04-19 20:00:00+00:00', }), dict({ - 'price': 0.139, - 'timestamp': '2023-01-19 22:00:00+00:00', + 'price': 0.13467, + 'timestamp': '2026-04-19 21:00:00+00:00', }), ]), }) @@ -105,100 +105,100 @@ dict({ 'prices': list([ dict({ - 'price': 0.13495, - 'timestamp': '2023-01-18 23:00:00+00:00', + 'price': 0.13975, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.12945, - 'timestamp': '2023-01-19 00:00:00+00:00', + 'price': 0.12732, + 'timestamp': '2026-04-18 23:00:00+00:00', }), dict({ - 'price': 0.12701, - 'timestamp': '2023-01-19 01:00:00+00:00', + 'price': 0.13213, + 'timestamp': '2026-04-19 00:00:00+00:00', }), dict({ - 'price': 0.12308, - 'timestamp': '2023-01-19 02:00:00+00:00', + 'price': 0.12493, + 'timestamp': '2026-04-19 01:00:00+00:00', }), dict({ - 'price': 0.12975, - 'timestamp': '2023-01-19 03:00:00+00:00', + 'price': 0.12519, + 'timestamp': '2026-04-19 02:00:00+00:00', }), dict({ - 'price': 0.13869, - 'timestamp': '2023-01-19 04:00:00+00:00', + 'price': 0.11852, + 'timestamp': '2026-04-19 03:00:00+00:00', }), dict({ - 'price': 0.14392, - 'timestamp': '2023-01-19 05:00:00+00:00', + 'price': 0.12186, + 'timestamp': '2026-04-19 04:00:00+00:00', }), dict({ - 'price': 0.19348, - 'timestamp': '2023-01-19 06:00:00+00:00', + 'price': 0.1185, + 'timestamp': '2026-04-19 05:00:00+00:00', }), dict({ - 'price': 0.19844, - 'timestamp': '2023-01-19 07:00:00+00:00', + 'price': 0.11768, + 'timestamp': '2026-04-19 06:00:00+00:00', }), dict({ - 'price': 0.20774, - 'timestamp': '2023-01-19 08:00:00+00:00', + 'price': 0.10607, + 'timestamp': '2026-04-19 07:00:00+00:00', }), dict({ - 'price': 0.16819, - 'timestamp': '2023-01-19 09:00:00+00:00', + 'price': 0.07419, + 'timestamp': '2026-04-19 08:00:00+00:00', }), dict({ - 'price': 0.16498, - 'timestamp': '2023-01-19 10:00:00+00:00', + 'price': 0.03426, + 'timestamp': '2026-04-19 09:00:00+00:00', }), dict({ - 'price': 0.15682, - 'timestamp': '2023-01-19 11:00:00+00:00', + 'price': 0.00975, + 'timestamp': '2026-04-19 10:00:00+00:00', }), dict({ - 'price': 0.18739, - 'timestamp': '2023-01-19 12:00:00+00:00', + 'price': -0.00085, + 'timestamp': '2026-04-19 11:00:00+00:00', }), dict({ - 'price': 0.19419, - 'timestamp': '2023-01-19 13:00:00+00:00', + 'price': -0.003, + 'timestamp': '2026-04-19 12:00:00+00:00', }), dict({ - 'price': 0.21291, - 'timestamp': '2023-01-19 14:00:00+00:00', + 'price': -0.00226, + 'timestamp': '2026-04-19 13:00:00+00:00', }), dict({ - 'price': 0.22541, - 'timestamp': '2023-01-19 15:00:00+00:00', + 'price': 0.01631, + 'timestamp': '2026-04-19 14:00:00+00:00', }), dict({ - 'price': 0.24677, - 'timestamp': '2023-01-19 16:00:00+00:00', + 'price': 0.07905, + 'timestamp': '2026-04-19 15:00:00+00:00', }), dict({ - 'price': 0.23906, - 'timestamp': '2023-01-19 17:00:00+00:00', + 'price': 0.12566, + 'timestamp': '2026-04-19 16:00:00+00:00', }), dict({ - 'price': 0.20743, - 'timestamp': '2023-01-19 18:00:00+00:00', + 'price': 0.14134, + 'timestamp': '2026-04-19 17:00:00+00:00', }), dict({ - 'price': 0.1815, - 'timestamp': '2023-01-19 19:00:00+00:00', + 'price': 0.15082, + 'timestamp': '2026-04-19 18:00:00+00:00', }), dict({ - 'price': 0.17958, - 'timestamp': '2023-01-19 20:00:00+00:00', + 'price': 0.14779, + 'timestamp': '2026-04-19 19:00:00+00:00', }), dict({ - 'price': 0.1807, - 'timestamp': '2023-01-19 21:00:00+00:00', + 'price': 0.14407, + 'timestamp': '2026-04-19 20:00:00+00:00', }), dict({ - 'price': 0.16819, - 'timestamp': '2023-01-19 22:00:00+00:00', + 'price': 0.13467, + 'timestamp': '2026-04-19 21:00:00+00:00', }), ]), }) @@ -207,100 +207,12 @@ dict({ 'prices': list([ dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 05:00:00+00:00', + 'price': 0.6169, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 06:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 07:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 08:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 09:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 10:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 11:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 12:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 13:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 14:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 15:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 16:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 17:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 18:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 19:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 20:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 21:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 22:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 23:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 00:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 01:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 02:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 03:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 04:00:00+00:00', + 'price': 0.57862, + 'timestamp': '2026-04-19 22:00:00+00:00', }), ]), }) @@ -309,100 +221,100 @@ dict({ 'prices': list([ dict({ - 'price': 0.11153, - 'timestamp': '2023-01-18 23:00:00+00:00', + 'price': 0.13975, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.10698, - 'timestamp': '2023-01-19 00:00:00+00:00', + 'price': 0.12732, + 'timestamp': '2026-04-18 23:00:00+00:00', }), dict({ - 'price': 0.10497, - 'timestamp': '2023-01-19 01:00:00+00:00', + 'price': 0.13213, + 'timestamp': '2026-04-19 00:00:00+00:00', }), dict({ - 'price': 0.10172, - 'timestamp': '2023-01-19 02:00:00+00:00', + 'price': 0.12493, + 'timestamp': '2026-04-19 01:00:00+00:00', }), dict({ - 'price': 0.10723, - 'timestamp': '2023-01-19 03:00:00+00:00', + 'price': 0.12519, + 'timestamp': '2026-04-19 02:00:00+00:00', }), dict({ - 'price': 0.11462, - 'timestamp': '2023-01-19 04:00:00+00:00', + 'price': 0.11852, + 'timestamp': '2026-04-19 03:00:00+00:00', }), dict({ - 'price': 0.11894, - 'timestamp': '2023-01-19 05:00:00+00:00', + 'price': 0.12186, + 'timestamp': '2026-04-19 04:00:00+00:00', }), dict({ - 'price': 0.1599, - 'timestamp': '2023-01-19 06:00:00+00:00', + 'price': 0.1185, + 'timestamp': '2026-04-19 05:00:00+00:00', }), dict({ - 'price': 0.164, - 'timestamp': '2023-01-19 07:00:00+00:00', + 'price': 0.11768, + 'timestamp': '2026-04-19 06:00:00+00:00', }), dict({ - 'price': 0.17169, - 'timestamp': '2023-01-19 08:00:00+00:00', + 'price': 0.10607, + 'timestamp': '2026-04-19 07:00:00+00:00', }), dict({ - 'price': 0.139, - 'timestamp': '2023-01-19 09:00:00+00:00', + 'price': 0.07419, + 'timestamp': '2026-04-19 08:00:00+00:00', }), dict({ - 'price': 0.13635, - 'timestamp': '2023-01-19 10:00:00+00:00', + 'price': 0.03426, + 'timestamp': '2026-04-19 09:00:00+00:00', }), dict({ - 'price': 0.1296, - 'timestamp': '2023-01-19 11:00:00+00:00', + 'price': 0.00975, + 'timestamp': '2026-04-19 10:00:00+00:00', }), dict({ - 'price': 0.15487, - 'timestamp': '2023-01-19 12:00:00+00:00', + 'price': -0.00085, + 'timestamp': '2026-04-19 11:00:00+00:00', }), dict({ - 'price': 0.16049, - 'timestamp': '2023-01-19 13:00:00+00:00', + 'price': -0.003, + 'timestamp': '2026-04-19 12:00:00+00:00', }), dict({ - 'price': 0.17596, - 'timestamp': '2023-01-19 14:00:00+00:00', + 'price': -0.00226, + 'timestamp': '2026-04-19 13:00:00+00:00', }), dict({ - 'price': 0.18629, - 'timestamp': '2023-01-19 15:00:00+00:00', + 'price': 0.01631, + 'timestamp': '2026-04-19 14:00:00+00:00', }), dict({ - 'price': 0.20394, - 'timestamp': '2023-01-19 16:00:00+00:00', + 'price': 0.07905, + 'timestamp': '2026-04-19 15:00:00+00:00', }), dict({ - 'price': 0.19757, - 'timestamp': '2023-01-19 17:00:00+00:00', + 'price': 0.12566, + 'timestamp': '2026-04-19 16:00:00+00:00', }), dict({ - 'price': 0.17143, - 'timestamp': '2023-01-19 18:00:00+00:00', + 'price': 0.14134, + 'timestamp': '2026-04-19 17:00:00+00:00', }), dict({ - 'price': 0.15, - 'timestamp': '2023-01-19 19:00:00+00:00', + 'price': 0.15082, + 'timestamp': '2026-04-19 18:00:00+00:00', }), dict({ - 'price': 0.14841, - 'timestamp': '2023-01-19 20:00:00+00:00', + 'price': 0.14779, + 'timestamp': '2026-04-19 19:00:00+00:00', }), dict({ - 'price': 0.14934, - 'timestamp': '2023-01-19 21:00:00+00:00', + 'price': 0.14407, + 'timestamp': '2026-04-19 20:00:00+00:00', }), dict({ - 'price': 0.139, - 'timestamp': '2023-01-19 22:00:00+00:00', + 'price': 0.13467, + 'timestamp': '2026-04-19 21:00:00+00:00', }), ]), }) @@ -411,100 +323,100 @@ dict({ 'prices': list([ dict({ - 'price': 0.13495, - 'timestamp': '2023-01-18 23:00:00+00:00', + 'price': 0.13975, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.12945, - 'timestamp': '2023-01-19 00:00:00+00:00', + 'price': 0.12732, + 'timestamp': '2026-04-18 23:00:00+00:00', }), dict({ - 'price': 0.12701, - 'timestamp': '2023-01-19 01:00:00+00:00', + 'price': 0.13213, + 'timestamp': '2026-04-19 00:00:00+00:00', }), dict({ - 'price': 0.12308, - 'timestamp': '2023-01-19 02:00:00+00:00', + 'price': 0.12493, + 'timestamp': '2026-04-19 01:00:00+00:00', }), dict({ - 'price': 0.12975, - 'timestamp': '2023-01-19 03:00:00+00:00', + 'price': 0.12519, + 'timestamp': '2026-04-19 02:00:00+00:00', }), dict({ - 'price': 0.13869, - 'timestamp': '2023-01-19 04:00:00+00:00', + 'price': 0.11852, + 'timestamp': '2026-04-19 03:00:00+00:00', }), dict({ - 'price': 0.14392, - 'timestamp': '2023-01-19 05:00:00+00:00', + 'price': 0.12186, + 'timestamp': '2026-04-19 04:00:00+00:00', }), dict({ - 'price': 0.19348, - 'timestamp': '2023-01-19 06:00:00+00:00', + 'price': 0.1185, + 'timestamp': '2026-04-19 05:00:00+00:00', }), dict({ - 'price': 0.19844, - 'timestamp': '2023-01-19 07:00:00+00:00', + 'price': 0.11768, + 'timestamp': '2026-04-19 06:00:00+00:00', }), dict({ - 'price': 0.20774, - 'timestamp': '2023-01-19 08:00:00+00:00', + 'price': 0.10607, + 'timestamp': '2026-04-19 07:00:00+00:00', }), dict({ - 'price': 0.16819, - 'timestamp': '2023-01-19 09:00:00+00:00', + 'price': 0.07419, + 'timestamp': '2026-04-19 08:00:00+00:00', }), dict({ - 'price': 0.16498, - 'timestamp': '2023-01-19 10:00:00+00:00', + 'price': 0.03426, + 'timestamp': '2026-04-19 09:00:00+00:00', }), dict({ - 'price': 0.15682, - 'timestamp': '2023-01-19 11:00:00+00:00', + 'price': 0.00975, + 'timestamp': '2026-04-19 10:00:00+00:00', }), dict({ - 'price': 0.18739, - 'timestamp': '2023-01-19 12:00:00+00:00', + 'price': -0.00085, + 'timestamp': '2026-04-19 11:00:00+00:00', }), dict({ - 'price': 0.19419, - 'timestamp': '2023-01-19 13:00:00+00:00', + 'price': -0.003, + 'timestamp': '2026-04-19 12:00:00+00:00', }), dict({ - 'price': 0.21291, - 'timestamp': '2023-01-19 14:00:00+00:00', + 'price': -0.00226, + 'timestamp': '2026-04-19 13:00:00+00:00', }), dict({ - 'price': 0.22541, - 'timestamp': '2023-01-19 15:00:00+00:00', + 'price': 0.01631, + 'timestamp': '2026-04-19 14:00:00+00:00', }), dict({ - 'price': 0.24677, - 'timestamp': '2023-01-19 16:00:00+00:00', + 'price': 0.07905, + 'timestamp': '2026-04-19 15:00:00+00:00', }), dict({ - 'price': 0.23906, - 'timestamp': '2023-01-19 17:00:00+00:00', + 'price': 0.12566, + 'timestamp': '2026-04-19 16:00:00+00:00', }), dict({ - 'price': 0.20743, - 'timestamp': '2023-01-19 18:00:00+00:00', + 'price': 0.14134, + 'timestamp': '2026-04-19 17:00:00+00:00', }), dict({ - 'price': 0.1815, - 'timestamp': '2023-01-19 19:00:00+00:00', + 'price': 0.15082, + 'timestamp': '2026-04-19 18:00:00+00:00', }), dict({ - 'price': 0.17958, - 'timestamp': '2023-01-19 20:00:00+00:00', + 'price': 0.14779, + 'timestamp': '2026-04-19 19:00:00+00:00', }), dict({ - 'price': 0.1807, - 'timestamp': '2023-01-19 21:00:00+00:00', + 'price': 0.14407, + 'timestamp': '2026-04-19 20:00:00+00:00', }), dict({ - 'price': 0.16819, - 'timestamp': '2023-01-19 22:00:00+00:00', + 'price': 0.13467, + 'timestamp': '2026-04-19 21:00:00+00:00', }), ]), }) @@ -513,100 +425,12 @@ dict({ 'prices': list([ dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 05:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 06:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 07:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 08:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 09:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 10:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 11:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 12:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 13:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 14:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 15:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 16:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 17:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 18:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 19:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 20:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 21:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 22:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 23:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 00:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 01:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 02:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 03:00:00+00:00', + 'price': 0.6169, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 04:00:00+00:00', + 'price': 0.57862, + 'timestamp': '2026-04-19 22:00:00+00:00', }), ]), }) @@ -615,100 +439,100 @@ dict({ 'prices': list([ dict({ - 'price': 0.11153, - 'timestamp': '2023-01-18 23:00:00+00:00', + 'price': 0.13975, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.10698, - 'timestamp': '2023-01-19 00:00:00+00:00', + 'price': 0.12732, + 'timestamp': '2026-04-18 23:00:00+00:00', }), dict({ - 'price': 0.10497, - 'timestamp': '2023-01-19 01:00:00+00:00', + 'price': 0.13213, + 'timestamp': '2026-04-19 00:00:00+00:00', }), dict({ - 'price': 0.10172, - 'timestamp': '2023-01-19 02:00:00+00:00', + 'price': 0.12493, + 'timestamp': '2026-04-19 01:00:00+00:00', }), dict({ - 'price': 0.10723, - 'timestamp': '2023-01-19 03:00:00+00:00', + 'price': 0.12519, + 'timestamp': '2026-04-19 02:00:00+00:00', }), dict({ - 'price': 0.11462, - 'timestamp': '2023-01-19 04:00:00+00:00', + 'price': 0.11852, + 'timestamp': '2026-04-19 03:00:00+00:00', }), dict({ - 'price': 0.11894, - 'timestamp': '2023-01-19 05:00:00+00:00', + 'price': 0.12186, + 'timestamp': '2026-04-19 04:00:00+00:00', }), dict({ - 'price': 0.1599, - 'timestamp': '2023-01-19 06:00:00+00:00', + 'price': 0.1185, + 'timestamp': '2026-04-19 05:00:00+00:00', }), dict({ - 'price': 0.164, - 'timestamp': '2023-01-19 07:00:00+00:00', + 'price': 0.11768, + 'timestamp': '2026-04-19 06:00:00+00:00', }), dict({ - 'price': 0.17169, - 'timestamp': '2023-01-19 08:00:00+00:00', + 'price': 0.10607, + 'timestamp': '2026-04-19 07:00:00+00:00', }), dict({ - 'price': 0.139, - 'timestamp': '2023-01-19 09:00:00+00:00', + 'price': 0.07419, + 'timestamp': '2026-04-19 08:00:00+00:00', }), dict({ - 'price': 0.13635, - 'timestamp': '2023-01-19 10:00:00+00:00', + 'price': 0.03426, + 'timestamp': '2026-04-19 09:00:00+00:00', }), dict({ - 'price': 0.1296, - 'timestamp': '2023-01-19 11:00:00+00:00', + 'price': 0.00975, + 'timestamp': '2026-04-19 10:00:00+00:00', }), dict({ - 'price': 0.15487, - 'timestamp': '2023-01-19 12:00:00+00:00', + 'price': -0.00085, + 'timestamp': '2026-04-19 11:00:00+00:00', }), dict({ - 'price': 0.16049, - 'timestamp': '2023-01-19 13:00:00+00:00', + 'price': -0.003, + 'timestamp': '2026-04-19 12:00:00+00:00', }), dict({ - 'price': 0.17596, - 'timestamp': '2023-01-19 14:00:00+00:00', + 'price': -0.00226, + 'timestamp': '2026-04-19 13:00:00+00:00', }), dict({ - 'price': 0.18629, - 'timestamp': '2023-01-19 15:00:00+00:00', + 'price': 0.01631, + 'timestamp': '2026-04-19 14:00:00+00:00', }), dict({ - 'price': 0.20394, - 'timestamp': '2023-01-19 16:00:00+00:00', + 'price': 0.07905, + 'timestamp': '2026-04-19 15:00:00+00:00', }), dict({ - 'price': 0.19757, - 'timestamp': '2023-01-19 17:00:00+00:00', + 'price': 0.12566, + 'timestamp': '2026-04-19 16:00:00+00:00', }), dict({ - 'price': 0.17143, - 'timestamp': '2023-01-19 18:00:00+00:00', + 'price': 0.14134, + 'timestamp': '2026-04-19 17:00:00+00:00', }), dict({ - 'price': 0.15, - 'timestamp': '2023-01-19 19:00:00+00:00', + 'price': 0.15082, + 'timestamp': '2026-04-19 18:00:00+00:00', }), dict({ - 'price': 0.14841, - 'timestamp': '2023-01-19 20:00:00+00:00', + 'price': 0.14779, + 'timestamp': '2026-04-19 19:00:00+00:00', }), dict({ - 'price': 0.14934, - 'timestamp': '2023-01-19 21:00:00+00:00', + 'price': 0.14407, + 'timestamp': '2026-04-19 20:00:00+00:00', }), dict({ - 'price': 0.139, - 'timestamp': '2023-01-19 22:00:00+00:00', + 'price': 0.13467, + 'timestamp': '2026-04-19 21:00:00+00:00', }), ]), }) @@ -717,100 +541,100 @@ dict({ 'prices': list([ dict({ - 'price': 0.13495, - 'timestamp': '2023-01-18 23:00:00+00:00', + 'price': 0.13975, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.12945, - 'timestamp': '2023-01-19 00:00:00+00:00', + 'price': 0.12732, + 'timestamp': '2026-04-18 23:00:00+00:00', }), dict({ - 'price': 0.12701, - 'timestamp': '2023-01-19 01:00:00+00:00', + 'price': 0.13213, + 'timestamp': '2026-04-19 00:00:00+00:00', }), dict({ - 'price': 0.12308, - 'timestamp': '2023-01-19 02:00:00+00:00', + 'price': 0.12493, + 'timestamp': '2026-04-19 01:00:00+00:00', }), dict({ - 'price': 0.12975, - 'timestamp': '2023-01-19 03:00:00+00:00', + 'price': 0.12519, + 'timestamp': '2026-04-19 02:00:00+00:00', }), dict({ - 'price': 0.13869, - 'timestamp': '2023-01-19 04:00:00+00:00', + 'price': 0.11852, + 'timestamp': '2026-04-19 03:00:00+00:00', }), dict({ - 'price': 0.14392, - 'timestamp': '2023-01-19 05:00:00+00:00', + 'price': 0.12186, + 'timestamp': '2026-04-19 04:00:00+00:00', }), dict({ - 'price': 0.19348, - 'timestamp': '2023-01-19 06:00:00+00:00', + 'price': 0.1185, + 'timestamp': '2026-04-19 05:00:00+00:00', }), dict({ - 'price': 0.19844, - 'timestamp': '2023-01-19 07:00:00+00:00', + 'price': 0.11768, + 'timestamp': '2026-04-19 06:00:00+00:00', }), dict({ - 'price': 0.20774, - 'timestamp': '2023-01-19 08:00:00+00:00', + 'price': 0.10607, + 'timestamp': '2026-04-19 07:00:00+00:00', }), dict({ - 'price': 0.16819, - 'timestamp': '2023-01-19 09:00:00+00:00', + 'price': 0.07419, + 'timestamp': '2026-04-19 08:00:00+00:00', }), dict({ - 'price': 0.16498, - 'timestamp': '2023-01-19 10:00:00+00:00', + 'price': 0.03426, + 'timestamp': '2026-04-19 09:00:00+00:00', }), dict({ - 'price': 0.15682, - 'timestamp': '2023-01-19 11:00:00+00:00', + 'price': 0.00975, + 'timestamp': '2026-04-19 10:00:00+00:00', }), dict({ - 'price': 0.18739, - 'timestamp': '2023-01-19 12:00:00+00:00', + 'price': -0.00085, + 'timestamp': '2026-04-19 11:00:00+00:00', }), dict({ - 'price': 0.19419, - 'timestamp': '2023-01-19 13:00:00+00:00', + 'price': -0.003, + 'timestamp': '2026-04-19 12:00:00+00:00', }), dict({ - 'price': 0.21291, - 'timestamp': '2023-01-19 14:00:00+00:00', + 'price': -0.00226, + 'timestamp': '2026-04-19 13:00:00+00:00', }), dict({ - 'price': 0.22541, - 'timestamp': '2023-01-19 15:00:00+00:00', + 'price': 0.01631, + 'timestamp': '2026-04-19 14:00:00+00:00', }), dict({ - 'price': 0.24677, - 'timestamp': '2023-01-19 16:00:00+00:00', + 'price': 0.07905, + 'timestamp': '2026-04-19 15:00:00+00:00', }), dict({ - 'price': 0.23906, - 'timestamp': '2023-01-19 17:00:00+00:00', + 'price': 0.12566, + 'timestamp': '2026-04-19 16:00:00+00:00', }), dict({ - 'price': 0.20743, - 'timestamp': '2023-01-19 18:00:00+00:00', + 'price': 0.14134, + 'timestamp': '2026-04-19 17:00:00+00:00', }), dict({ - 'price': 0.1815, - 'timestamp': '2023-01-19 19:00:00+00:00', + 'price': 0.15082, + 'timestamp': '2026-04-19 18:00:00+00:00', }), dict({ - 'price': 0.17958, - 'timestamp': '2023-01-19 20:00:00+00:00', + 'price': 0.14779, + 'timestamp': '2026-04-19 19:00:00+00:00', }), dict({ - 'price': 0.1807, - 'timestamp': '2023-01-19 21:00:00+00:00', + 'price': 0.14407, + 'timestamp': '2026-04-19 20:00:00+00:00', }), dict({ - 'price': 0.16819, - 'timestamp': '2023-01-19 22:00:00+00:00', + 'price': 0.13467, + 'timestamp': '2026-04-19 21:00:00+00:00', }), ]), }) @@ -819,100 +643,12 @@ dict({ 'prices': list([ dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 05:00:00+00:00', + 'price': 0.6169, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 06:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 07:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 08:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 09:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 10:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 11:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 12:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 13:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 14:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 15:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 16:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 17:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 18:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 19:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 20:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 21:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 22:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 23:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 00:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 01:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 02:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 03:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 04:00:00+00:00', + 'price': 0.57862, + 'timestamp': '2026-04-19 22:00:00+00:00', }), ]), }) @@ -921,100 +657,100 @@ dict({ 'prices': list([ dict({ - 'price': 0.11153, - 'timestamp': '2023-01-18 23:00:00+00:00', + 'price': 0.13975, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.10698, - 'timestamp': '2023-01-19 00:00:00+00:00', + 'price': 0.12732, + 'timestamp': '2026-04-18 23:00:00+00:00', }), dict({ - 'price': 0.10497, - 'timestamp': '2023-01-19 01:00:00+00:00', + 'price': 0.13213, + 'timestamp': '2026-04-19 00:00:00+00:00', }), dict({ - 'price': 0.10172, - 'timestamp': '2023-01-19 02:00:00+00:00', + 'price': 0.12493, + 'timestamp': '2026-04-19 01:00:00+00:00', }), dict({ - 'price': 0.10723, - 'timestamp': '2023-01-19 03:00:00+00:00', + 'price': 0.12519, + 'timestamp': '2026-04-19 02:00:00+00:00', }), dict({ - 'price': 0.11462, - 'timestamp': '2023-01-19 04:00:00+00:00', + 'price': 0.11852, + 'timestamp': '2026-04-19 03:00:00+00:00', }), dict({ - 'price': 0.11894, - 'timestamp': '2023-01-19 05:00:00+00:00', + 'price': 0.12186, + 'timestamp': '2026-04-19 04:00:00+00:00', }), dict({ - 'price': 0.1599, - 'timestamp': '2023-01-19 06:00:00+00:00', + 'price': 0.1185, + 'timestamp': '2026-04-19 05:00:00+00:00', }), dict({ - 'price': 0.164, - 'timestamp': '2023-01-19 07:00:00+00:00', + 'price': 0.11768, + 'timestamp': '2026-04-19 06:00:00+00:00', }), dict({ - 'price': 0.17169, - 'timestamp': '2023-01-19 08:00:00+00:00', + 'price': 0.10607, + 'timestamp': '2026-04-19 07:00:00+00:00', }), dict({ - 'price': 0.139, - 'timestamp': '2023-01-19 09:00:00+00:00', + 'price': 0.07419, + 'timestamp': '2026-04-19 08:00:00+00:00', }), dict({ - 'price': 0.13635, - 'timestamp': '2023-01-19 10:00:00+00:00', + 'price': 0.03426, + 'timestamp': '2026-04-19 09:00:00+00:00', }), dict({ - 'price': 0.1296, - 'timestamp': '2023-01-19 11:00:00+00:00', + 'price': 0.00975, + 'timestamp': '2026-04-19 10:00:00+00:00', }), dict({ - 'price': 0.15487, - 'timestamp': '2023-01-19 12:00:00+00:00', + 'price': -0.00085, + 'timestamp': '2026-04-19 11:00:00+00:00', }), dict({ - 'price': 0.16049, - 'timestamp': '2023-01-19 13:00:00+00:00', + 'price': -0.003, + 'timestamp': '2026-04-19 12:00:00+00:00', }), dict({ - 'price': 0.17596, - 'timestamp': '2023-01-19 14:00:00+00:00', + 'price': -0.00226, + 'timestamp': '2026-04-19 13:00:00+00:00', }), dict({ - 'price': 0.18629, - 'timestamp': '2023-01-19 15:00:00+00:00', + 'price': 0.01631, + 'timestamp': '2026-04-19 14:00:00+00:00', }), dict({ - 'price': 0.20394, - 'timestamp': '2023-01-19 16:00:00+00:00', + 'price': 0.07905, + 'timestamp': '2026-04-19 15:00:00+00:00', }), dict({ - 'price': 0.19757, - 'timestamp': '2023-01-19 17:00:00+00:00', + 'price': 0.12566, + 'timestamp': '2026-04-19 16:00:00+00:00', }), dict({ - 'price': 0.17143, - 'timestamp': '2023-01-19 18:00:00+00:00', + 'price': 0.14134, + 'timestamp': '2026-04-19 17:00:00+00:00', }), dict({ - 'price': 0.15, - 'timestamp': '2023-01-19 19:00:00+00:00', + 'price': 0.15082, + 'timestamp': '2026-04-19 18:00:00+00:00', }), dict({ - 'price': 0.14841, - 'timestamp': '2023-01-19 20:00:00+00:00', + 'price': 0.14779, + 'timestamp': '2026-04-19 19:00:00+00:00', }), dict({ - 'price': 0.14934, - 'timestamp': '2023-01-19 21:00:00+00:00', + 'price': 0.14407, + 'timestamp': '2026-04-19 20:00:00+00:00', }), dict({ - 'price': 0.139, - 'timestamp': '2023-01-19 22:00:00+00:00', + 'price': 0.13467, + 'timestamp': '2026-04-19 21:00:00+00:00', }), ]), }) @@ -1023,100 +759,100 @@ dict({ 'prices': list([ dict({ - 'price': 0.13495, - 'timestamp': '2023-01-18 23:00:00+00:00', + 'price': 0.13975, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.12945, - 'timestamp': '2023-01-19 00:00:00+00:00', + 'price': 0.12732, + 'timestamp': '2026-04-18 23:00:00+00:00', }), dict({ - 'price': 0.12701, - 'timestamp': '2023-01-19 01:00:00+00:00', + 'price': 0.13213, + 'timestamp': '2026-04-19 00:00:00+00:00', }), dict({ - 'price': 0.12308, - 'timestamp': '2023-01-19 02:00:00+00:00', + 'price': 0.12493, + 'timestamp': '2026-04-19 01:00:00+00:00', }), dict({ - 'price': 0.12975, - 'timestamp': '2023-01-19 03:00:00+00:00', + 'price': 0.12519, + 'timestamp': '2026-04-19 02:00:00+00:00', }), dict({ - 'price': 0.13869, - 'timestamp': '2023-01-19 04:00:00+00:00', + 'price': 0.11852, + 'timestamp': '2026-04-19 03:00:00+00:00', }), dict({ - 'price': 0.14392, - 'timestamp': '2023-01-19 05:00:00+00:00', + 'price': 0.12186, + 'timestamp': '2026-04-19 04:00:00+00:00', }), dict({ - 'price': 0.19348, - 'timestamp': '2023-01-19 06:00:00+00:00', + 'price': 0.1185, + 'timestamp': '2026-04-19 05:00:00+00:00', }), dict({ - 'price': 0.19844, - 'timestamp': '2023-01-19 07:00:00+00:00', + 'price': 0.11768, + 'timestamp': '2026-04-19 06:00:00+00:00', }), dict({ - 'price': 0.20774, - 'timestamp': '2023-01-19 08:00:00+00:00', + 'price': 0.10607, + 'timestamp': '2026-04-19 07:00:00+00:00', }), dict({ - 'price': 0.16819, - 'timestamp': '2023-01-19 09:00:00+00:00', + 'price': 0.07419, + 'timestamp': '2026-04-19 08:00:00+00:00', }), dict({ - 'price': 0.16498, - 'timestamp': '2023-01-19 10:00:00+00:00', + 'price': 0.03426, + 'timestamp': '2026-04-19 09:00:00+00:00', }), dict({ - 'price': 0.15682, - 'timestamp': '2023-01-19 11:00:00+00:00', + 'price': 0.00975, + 'timestamp': '2026-04-19 10:00:00+00:00', }), dict({ - 'price': 0.18739, - 'timestamp': '2023-01-19 12:00:00+00:00', + 'price': -0.00085, + 'timestamp': '2026-04-19 11:00:00+00:00', }), dict({ - 'price': 0.19419, - 'timestamp': '2023-01-19 13:00:00+00:00', + 'price': -0.003, + 'timestamp': '2026-04-19 12:00:00+00:00', }), dict({ - 'price': 0.21291, - 'timestamp': '2023-01-19 14:00:00+00:00', + 'price': -0.00226, + 'timestamp': '2026-04-19 13:00:00+00:00', }), dict({ - 'price': 0.22541, - 'timestamp': '2023-01-19 15:00:00+00:00', + 'price': 0.01631, + 'timestamp': '2026-04-19 14:00:00+00:00', }), dict({ - 'price': 0.24677, - 'timestamp': '2023-01-19 16:00:00+00:00', + 'price': 0.07905, + 'timestamp': '2026-04-19 15:00:00+00:00', }), dict({ - 'price': 0.23906, - 'timestamp': '2023-01-19 17:00:00+00:00', + 'price': 0.12566, + 'timestamp': '2026-04-19 16:00:00+00:00', }), dict({ - 'price': 0.20743, - 'timestamp': '2023-01-19 18:00:00+00:00', + 'price': 0.14134, + 'timestamp': '2026-04-19 17:00:00+00:00', }), dict({ - 'price': 0.1815, - 'timestamp': '2023-01-19 19:00:00+00:00', + 'price': 0.15082, + 'timestamp': '2026-04-19 18:00:00+00:00', }), dict({ - 'price': 0.17958, - 'timestamp': '2023-01-19 20:00:00+00:00', + 'price': 0.14779, + 'timestamp': '2026-04-19 19:00:00+00:00', }), dict({ - 'price': 0.1807, - 'timestamp': '2023-01-19 21:00:00+00:00', + 'price': 0.14407, + 'timestamp': '2026-04-19 20:00:00+00:00', }), dict({ - 'price': 0.16819, - 'timestamp': '2023-01-19 22:00:00+00:00', + 'price': 0.13467, + 'timestamp': '2026-04-19 21:00:00+00:00', }), ]), }) @@ -1125,100 +861,12 @@ dict({ 'prices': list([ dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 05:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 06:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 07:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 08:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 09:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 10:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 11:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 12:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 13:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 14:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 15:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 16:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 17:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 18:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 19:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 20:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 21:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 22:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 23:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 00:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 01:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 02:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 03:00:00+00:00', + 'price': 0.6169, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 04:00:00+00:00', + 'price': 0.57862, + 'timestamp': '2026-04-19 22:00:00+00:00', }), ]), }) @@ -1227,100 +875,100 @@ dict({ 'prices': list([ dict({ - 'price': 0.11153, - 'timestamp': '2023-01-18 23:00:00+00:00', + 'price': 0.13975, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.10698, - 'timestamp': '2023-01-19 00:00:00+00:00', + 'price': 0.12732, + 'timestamp': '2026-04-18 23:00:00+00:00', }), dict({ - 'price': 0.10497, - 'timestamp': '2023-01-19 01:00:00+00:00', + 'price': 0.13213, + 'timestamp': '2026-04-19 00:00:00+00:00', }), dict({ - 'price': 0.10172, - 'timestamp': '2023-01-19 02:00:00+00:00', + 'price': 0.12493, + 'timestamp': '2026-04-19 01:00:00+00:00', }), dict({ - 'price': 0.10723, - 'timestamp': '2023-01-19 03:00:00+00:00', + 'price': 0.12519, + 'timestamp': '2026-04-19 02:00:00+00:00', }), dict({ - 'price': 0.11462, - 'timestamp': '2023-01-19 04:00:00+00:00', + 'price': 0.11852, + 'timestamp': '2026-04-19 03:00:00+00:00', }), dict({ - 'price': 0.11894, - 'timestamp': '2023-01-19 05:00:00+00:00', + 'price': 0.12186, + 'timestamp': '2026-04-19 04:00:00+00:00', }), dict({ - 'price': 0.1599, - 'timestamp': '2023-01-19 06:00:00+00:00', + 'price': 0.1185, + 'timestamp': '2026-04-19 05:00:00+00:00', }), dict({ - 'price': 0.164, - 'timestamp': '2023-01-19 07:00:00+00:00', + 'price': 0.11768, + 'timestamp': '2026-04-19 06:00:00+00:00', }), dict({ - 'price': 0.17169, - 'timestamp': '2023-01-19 08:00:00+00:00', + 'price': 0.10607, + 'timestamp': '2026-04-19 07:00:00+00:00', }), dict({ - 'price': 0.139, - 'timestamp': '2023-01-19 09:00:00+00:00', + 'price': 0.07419, + 'timestamp': '2026-04-19 08:00:00+00:00', }), dict({ - 'price': 0.13635, - 'timestamp': '2023-01-19 10:00:00+00:00', + 'price': 0.03426, + 'timestamp': '2026-04-19 09:00:00+00:00', }), dict({ - 'price': 0.1296, - 'timestamp': '2023-01-19 11:00:00+00:00', + 'price': 0.00975, + 'timestamp': '2026-04-19 10:00:00+00:00', }), dict({ - 'price': 0.15487, - 'timestamp': '2023-01-19 12:00:00+00:00', + 'price': -0.00085, + 'timestamp': '2026-04-19 11:00:00+00:00', }), dict({ - 'price': 0.16049, - 'timestamp': '2023-01-19 13:00:00+00:00', + 'price': -0.003, + 'timestamp': '2026-04-19 12:00:00+00:00', }), dict({ - 'price': 0.17596, - 'timestamp': '2023-01-19 14:00:00+00:00', + 'price': -0.00226, + 'timestamp': '2026-04-19 13:00:00+00:00', }), dict({ - 'price': 0.18629, - 'timestamp': '2023-01-19 15:00:00+00:00', + 'price': 0.01631, + 'timestamp': '2026-04-19 14:00:00+00:00', }), dict({ - 'price': 0.20394, - 'timestamp': '2023-01-19 16:00:00+00:00', + 'price': 0.07905, + 'timestamp': '2026-04-19 15:00:00+00:00', }), dict({ - 'price': 0.19757, - 'timestamp': '2023-01-19 17:00:00+00:00', + 'price': 0.12566, + 'timestamp': '2026-04-19 16:00:00+00:00', }), dict({ - 'price': 0.17143, - 'timestamp': '2023-01-19 18:00:00+00:00', + 'price': 0.14134, + 'timestamp': '2026-04-19 17:00:00+00:00', }), dict({ - 'price': 0.15, - 'timestamp': '2023-01-19 19:00:00+00:00', + 'price': 0.15082, + 'timestamp': '2026-04-19 18:00:00+00:00', }), dict({ - 'price': 0.14841, - 'timestamp': '2023-01-19 20:00:00+00:00', + 'price': 0.14779, + 'timestamp': '2026-04-19 19:00:00+00:00', }), dict({ - 'price': 0.14934, - 'timestamp': '2023-01-19 21:00:00+00:00', + 'price': 0.14407, + 'timestamp': '2026-04-19 20:00:00+00:00', }), dict({ - 'price': 0.139, - 'timestamp': '2023-01-19 22:00:00+00:00', + 'price': 0.13467, + 'timestamp': '2026-04-19 21:00:00+00:00', }), ]), }) @@ -1329,100 +977,100 @@ dict({ 'prices': list([ dict({ - 'price': 0.13495, - 'timestamp': '2023-01-18 23:00:00+00:00', + 'price': 0.13975, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.12945, - 'timestamp': '2023-01-19 00:00:00+00:00', + 'price': 0.12732, + 'timestamp': '2026-04-18 23:00:00+00:00', }), dict({ - 'price': 0.12701, - 'timestamp': '2023-01-19 01:00:00+00:00', + 'price': 0.13213, + 'timestamp': '2026-04-19 00:00:00+00:00', }), dict({ - 'price': 0.12308, - 'timestamp': '2023-01-19 02:00:00+00:00', + 'price': 0.12493, + 'timestamp': '2026-04-19 01:00:00+00:00', }), dict({ - 'price': 0.12975, - 'timestamp': '2023-01-19 03:00:00+00:00', + 'price': 0.12519, + 'timestamp': '2026-04-19 02:00:00+00:00', }), dict({ - 'price': 0.13869, - 'timestamp': '2023-01-19 04:00:00+00:00', + 'price': 0.11852, + 'timestamp': '2026-04-19 03:00:00+00:00', }), dict({ - 'price': 0.14392, - 'timestamp': '2023-01-19 05:00:00+00:00', + 'price': 0.12186, + 'timestamp': '2026-04-19 04:00:00+00:00', }), dict({ - 'price': 0.19348, - 'timestamp': '2023-01-19 06:00:00+00:00', + 'price': 0.1185, + 'timestamp': '2026-04-19 05:00:00+00:00', }), dict({ - 'price': 0.19844, - 'timestamp': '2023-01-19 07:00:00+00:00', + 'price': 0.11768, + 'timestamp': '2026-04-19 06:00:00+00:00', }), dict({ - 'price': 0.20774, - 'timestamp': '2023-01-19 08:00:00+00:00', + 'price': 0.10607, + 'timestamp': '2026-04-19 07:00:00+00:00', }), dict({ - 'price': 0.16819, - 'timestamp': '2023-01-19 09:00:00+00:00', + 'price': 0.07419, + 'timestamp': '2026-04-19 08:00:00+00:00', }), dict({ - 'price': 0.16498, - 'timestamp': '2023-01-19 10:00:00+00:00', + 'price': 0.03426, + 'timestamp': '2026-04-19 09:00:00+00:00', }), dict({ - 'price': 0.15682, - 'timestamp': '2023-01-19 11:00:00+00:00', + 'price': 0.00975, + 'timestamp': '2026-04-19 10:00:00+00:00', }), dict({ - 'price': 0.18739, - 'timestamp': '2023-01-19 12:00:00+00:00', + 'price': -0.00085, + 'timestamp': '2026-04-19 11:00:00+00:00', }), dict({ - 'price': 0.19419, - 'timestamp': '2023-01-19 13:00:00+00:00', + 'price': -0.003, + 'timestamp': '2026-04-19 12:00:00+00:00', }), dict({ - 'price': 0.21291, - 'timestamp': '2023-01-19 14:00:00+00:00', + 'price': -0.00226, + 'timestamp': '2026-04-19 13:00:00+00:00', }), dict({ - 'price': 0.22541, - 'timestamp': '2023-01-19 15:00:00+00:00', + 'price': 0.01631, + 'timestamp': '2026-04-19 14:00:00+00:00', }), dict({ - 'price': 0.24677, - 'timestamp': '2023-01-19 16:00:00+00:00', + 'price': 0.07905, + 'timestamp': '2026-04-19 15:00:00+00:00', }), dict({ - 'price': 0.23906, - 'timestamp': '2023-01-19 17:00:00+00:00', + 'price': 0.12566, + 'timestamp': '2026-04-19 16:00:00+00:00', }), dict({ - 'price': 0.20743, - 'timestamp': '2023-01-19 18:00:00+00:00', + 'price': 0.14134, + 'timestamp': '2026-04-19 17:00:00+00:00', }), dict({ - 'price': 0.1815, - 'timestamp': '2023-01-19 19:00:00+00:00', + 'price': 0.15082, + 'timestamp': '2026-04-19 18:00:00+00:00', }), dict({ - 'price': 0.17958, - 'timestamp': '2023-01-19 20:00:00+00:00', + 'price': 0.14779, + 'timestamp': '2026-04-19 19:00:00+00:00', }), dict({ - 'price': 0.1807, - 'timestamp': '2023-01-19 21:00:00+00:00', + 'price': 0.14407, + 'timestamp': '2026-04-19 20:00:00+00:00', }), dict({ - 'price': 0.16819, - 'timestamp': '2023-01-19 22:00:00+00:00', + 'price': 0.13467, + 'timestamp': '2026-04-19 21:00:00+00:00', }), ]), }) @@ -1431,100 +1079,12 @@ dict({ 'prices': list([ dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 05:00:00+00:00', + 'price': 0.6169, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 06:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 07:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 08:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 09:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 10:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 11:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 12:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 13:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 14:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 15:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 16:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 17:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 18:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 19:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 20:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 21:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 22:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 23:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 00:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 01:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 02:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 03:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 04:00:00+00:00', + 'price': 0.57862, + 'timestamp': '2026-04-19 22:00:00+00:00', }), ]), }) @@ -1533,100 +1093,100 @@ dict({ 'prices': list([ dict({ - 'price': 0.11153, - 'timestamp': '2023-01-18 23:00:00+00:00', + 'price': 0.13975, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.10698, - 'timestamp': '2023-01-19 00:00:00+00:00', + 'price': 0.12732, + 'timestamp': '2026-04-18 23:00:00+00:00', }), dict({ - 'price': 0.10497, - 'timestamp': '2023-01-19 01:00:00+00:00', + 'price': 0.13213, + 'timestamp': '2026-04-19 00:00:00+00:00', }), dict({ - 'price': 0.10172, - 'timestamp': '2023-01-19 02:00:00+00:00', + 'price': 0.12493, + 'timestamp': '2026-04-19 01:00:00+00:00', }), dict({ - 'price': 0.10723, - 'timestamp': '2023-01-19 03:00:00+00:00', + 'price': 0.12519, + 'timestamp': '2026-04-19 02:00:00+00:00', }), dict({ - 'price': 0.11462, - 'timestamp': '2023-01-19 04:00:00+00:00', + 'price': 0.11852, + 'timestamp': '2026-04-19 03:00:00+00:00', }), dict({ - 'price': 0.11894, - 'timestamp': '2023-01-19 05:00:00+00:00', + 'price': 0.12186, + 'timestamp': '2026-04-19 04:00:00+00:00', }), dict({ - 'price': 0.1599, - 'timestamp': '2023-01-19 06:00:00+00:00', + 'price': 0.1185, + 'timestamp': '2026-04-19 05:00:00+00:00', }), dict({ - 'price': 0.164, - 'timestamp': '2023-01-19 07:00:00+00:00', + 'price': 0.11768, + 'timestamp': '2026-04-19 06:00:00+00:00', }), dict({ - 'price': 0.17169, - 'timestamp': '2023-01-19 08:00:00+00:00', + 'price': 0.10607, + 'timestamp': '2026-04-19 07:00:00+00:00', }), dict({ - 'price': 0.139, - 'timestamp': '2023-01-19 09:00:00+00:00', + 'price': 0.07419, + 'timestamp': '2026-04-19 08:00:00+00:00', }), dict({ - 'price': 0.13635, - 'timestamp': '2023-01-19 10:00:00+00:00', + 'price': 0.03426, + 'timestamp': '2026-04-19 09:00:00+00:00', }), dict({ - 'price': 0.1296, - 'timestamp': '2023-01-19 11:00:00+00:00', + 'price': 0.00975, + 'timestamp': '2026-04-19 10:00:00+00:00', }), dict({ - 'price': 0.15487, - 'timestamp': '2023-01-19 12:00:00+00:00', + 'price': -0.00085, + 'timestamp': '2026-04-19 11:00:00+00:00', }), dict({ - 'price': 0.16049, - 'timestamp': '2023-01-19 13:00:00+00:00', + 'price': -0.003, + 'timestamp': '2026-04-19 12:00:00+00:00', }), dict({ - 'price': 0.17596, - 'timestamp': '2023-01-19 14:00:00+00:00', + 'price': -0.00226, + 'timestamp': '2026-04-19 13:00:00+00:00', }), dict({ - 'price': 0.18629, - 'timestamp': '2023-01-19 15:00:00+00:00', + 'price': 0.01631, + 'timestamp': '2026-04-19 14:00:00+00:00', }), dict({ - 'price': 0.20394, - 'timestamp': '2023-01-19 16:00:00+00:00', + 'price': 0.07905, + 'timestamp': '2026-04-19 15:00:00+00:00', }), dict({ - 'price': 0.19757, - 'timestamp': '2023-01-19 17:00:00+00:00', + 'price': 0.12566, + 'timestamp': '2026-04-19 16:00:00+00:00', }), dict({ - 'price': 0.17143, - 'timestamp': '2023-01-19 18:00:00+00:00', + 'price': 0.14134, + 'timestamp': '2026-04-19 17:00:00+00:00', }), dict({ - 'price': 0.15, - 'timestamp': '2023-01-19 19:00:00+00:00', + 'price': 0.15082, + 'timestamp': '2026-04-19 18:00:00+00:00', }), dict({ - 'price': 0.14841, - 'timestamp': '2023-01-19 20:00:00+00:00', + 'price': 0.14779, + 'timestamp': '2026-04-19 19:00:00+00:00', }), dict({ - 'price': 0.14934, - 'timestamp': '2023-01-19 21:00:00+00:00', + 'price': 0.14407, + 'timestamp': '2026-04-19 20:00:00+00:00', }), dict({ - 'price': 0.139, - 'timestamp': '2023-01-19 22:00:00+00:00', + 'price': 0.13467, + 'timestamp': '2026-04-19 21:00:00+00:00', }), ]), }) @@ -1635,100 +1195,100 @@ dict({ 'prices': list([ dict({ - 'price': 0.13495, - 'timestamp': '2023-01-18 23:00:00+00:00', + 'price': 0.13975, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.12945, - 'timestamp': '2023-01-19 00:00:00+00:00', + 'price': 0.12732, + 'timestamp': '2026-04-18 23:00:00+00:00', }), dict({ - 'price': 0.12701, - 'timestamp': '2023-01-19 01:00:00+00:00', + 'price': 0.13213, + 'timestamp': '2026-04-19 00:00:00+00:00', }), dict({ - 'price': 0.12308, - 'timestamp': '2023-01-19 02:00:00+00:00', + 'price': 0.12493, + 'timestamp': '2026-04-19 01:00:00+00:00', }), dict({ - 'price': 0.12975, - 'timestamp': '2023-01-19 03:00:00+00:00', + 'price': 0.12519, + 'timestamp': '2026-04-19 02:00:00+00:00', }), dict({ - 'price': 0.13869, - 'timestamp': '2023-01-19 04:00:00+00:00', + 'price': 0.11852, + 'timestamp': '2026-04-19 03:00:00+00:00', }), dict({ - 'price': 0.14392, - 'timestamp': '2023-01-19 05:00:00+00:00', + 'price': 0.12186, + 'timestamp': '2026-04-19 04:00:00+00:00', }), dict({ - 'price': 0.19348, - 'timestamp': '2023-01-19 06:00:00+00:00', + 'price': 0.1185, + 'timestamp': '2026-04-19 05:00:00+00:00', }), dict({ - 'price': 0.19844, - 'timestamp': '2023-01-19 07:00:00+00:00', + 'price': 0.11768, + 'timestamp': '2026-04-19 06:00:00+00:00', }), dict({ - 'price': 0.20774, - 'timestamp': '2023-01-19 08:00:00+00:00', + 'price': 0.10607, + 'timestamp': '2026-04-19 07:00:00+00:00', }), dict({ - 'price': 0.16819, - 'timestamp': '2023-01-19 09:00:00+00:00', + 'price': 0.07419, + 'timestamp': '2026-04-19 08:00:00+00:00', }), dict({ - 'price': 0.16498, - 'timestamp': '2023-01-19 10:00:00+00:00', + 'price': 0.03426, + 'timestamp': '2026-04-19 09:00:00+00:00', }), dict({ - 'price': 0.15682, - 'timestamp': '2023-01-19 11:00:00+00:00', + 'price': 0.00975, + 'timestamp': '2026-04-19 10:00:00+00:00', }), dict({ - 'price': 0.18739, - 'timestamp': '2023-01-19 12:00:00+00:00', + 'price': -0.00085, + 'timestamp': '2026-04-19 11:00:00+00:00', }), dict({ - 'price': 0.19419, - 'timestamp': '2023-01-19 13:00:00+00:00', + 'price': -0.003, + 'timestamp': '2026-04-19 12:00:00+00:00', }), dict({ - 'price': 0.21291, - 'timestamp': '2023-01-19 14:00:00+00:00', + 'price': -0.00226, + 'timestamp': '2026-04-19 13:00:00+00:00', }), dict({ - 'price': 0.22541, - 'timestamp': '2023-01-19 15:00:00+00:00', + 'price': 0.01631, + 'timestamp': '2026-04-19 14:00:00+00:00', }), dict({ - 'price': 0.24677, - 'timestamp': '2023-01-19 16:00:00+00:00', + 'price': 0.07905, + 'timestamp': '2026-04-19 15:00:00+00:00', }), dict({ - 'price': 0.23906, - 'timestamp': '2023-01-19 17:00:00+00:00', + 'price': 0.12566, + 'timestamp': '2026-04-19 16:00:00+00:00', }), dict({ - 'price': 0.20743, - 'timestamp': '2023-01-19 18:00:00+00:00', + 'price': 0.14134, + 'timestamp': '2026-04-19 17:00:00+00:00', }), dict({ - 'price': 0.1815, - 'timestamp': '2023-01-19 19:00:00+00:00', + 'price': 0.15082, + 'timestamp': '2026-04-19 18:00:00+00:00', }), dict({ - 'price': 0.17958, - 'timestamp': '2023-01-19 20:00:00+00:00', + 'price': 0.14779, + 'timestamp': '2026-04-19 19:00:00+00:00', }), dict({ - 'price': 0.1807, - 'timestamp': '2023-01-19 21:00:00+00:00', + 'price': 0.14407, + 'timestamp': '2026-04-19 20:00:00+00:00', }), dict({ - 'price': 0.16819, - 'timestamp': '2023-01-19 22:00:00+00:00', + 'price': 0.13467, + 'timestamp': '2026-04-19 21:00:00+00:00', }), ]), }) @@ -1737,100 +1297,12 @@ dict({ 'prices': list([ dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 05:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 06:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 07:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 08:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 09:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 10:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 11:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 12:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 13:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 14:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 15:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 16:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 17:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 18:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 19:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 20:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 21:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 22:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 23:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 00:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 01:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 02:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 03:00:00+00:00', + 'price': 0.6169, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 04:00:00+00:00', + 'price': 0.57862, + 'timestamp': '2026-04-19 22:00:00+00:00', }), ]), }) @@ -1839,100 +1311,100 @@ dict({ 'prices': list([ dict({ - 'price': 0.11153, - 'timestamp': '2023-01-18 23:00:00+00:00', + 'price': 0.13975, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.10698, - 'timestamp': '2023-01-19 00:00:00+00:00', + 'price': 0.12732, + 'timestamp': '2026-04-18 23:00:00+00:00', }), dict({ - 'price': 0.10497, - 'timestamp': '2023-01-19 01:00:00+00:00', + 'price': 0.13213, + 'timestamp': '2026-04-19 00:00:00+00:00', }), dict({ - 'price': 0.10172, - 'timestamp': '2023-01-19 02:00:00+00:00', + 'price': 0.12493, + 'timestamp': '2026-04-19 01:00:00+00:00', }), dict({ - 'price': 0.10723, - 'timestamp': '2023-01-19 03:00:00+00:00', + 'price': 0.12519, + 'timestamp': '2026-04-19 02:00:00+00:00', }), dict({ - 'price': 0.11462, - 'timestamp': '2023-01-19 04:00:00+00:00', + 'price': 0.11852, + 'timestamp': '2026-04-19 03:00:00+00:00', }), dict({ - 'price': 0.11894, - 'timestamp': '2023-01-19 05:00:00+00:00', + 'price': 0.12186, + 'timestamp': '2026-04-19 04:00:00+00:00', }), dict({ - 'price': 0.1599, - 'timestamp': '2023-01-19 06:00:00+00:00', + 'price': 0.1185, + 'timestamp': '2026-04-19 05:00:00+00:00', }), dict({ - 'price': 0.164, - 'timestamp': '2023-01-19 07:00:00+00:00', + 'price': 0.11768, + 'timestamp': '2026-04-19 06:00:00+00:00', }), dict({ - 'price': 0.17169, - 'timestamp': '2023-01-19 08:00:00+00:00', + 'price': 0.10607, + 'timestamp': '2026-04-19 07:00:00+00:00', }), dict({ - 'price': 0.139, - 'timestamp': '2023-01-19 09:00:00+00:00', + 'price': 0.07419, + 'timestamp': '2026-04-19 08:00:00+00:00', }), dict({ - 'price': 0.13635, - 'timestamp': '2023-01-19 10:00:00+00:00', + 'price': 0.03426, + 'timestamp': '2026-04-19 09:00:00+00:00', }), dict({ - 'price': 0.1296, - 'timestamp': '2023-01-19 11:00:00+00:00', + 'price': 0.00975, + 'timestamp': '2026-04-19 10:00:00+00:00', }), dict({ - 'price': 0.15487, - 'timestamp': '2023-01-19 12:00:00+00:00', + 'price': -0.00085, + 'timestamp': '2026-04-19 11:00:00+00:00', }), dict({ - 'price': 0.16049, - 'timestamp': '2023-01-19 13:00:00+00:00', + 'price': -0.003, + 'timestamp': '2026-04-19 12:00:00+00:00', }), dict({ - 'price': 0.17596, - 'timestamp': '2023-01-19 14:00:00+00:00', + 'price': -0.00226, + 'timestamp': '2026-04-19 13:00:00+00:00', }), dict({ - 'price': 0.18629, - 'timestamp': '2023-01-19 15:00:00+00:00', + 'price': 0.01631, + 'timestamp': '2026-04-19 14:00:00+00:00', }), dict({ - 'price': 0.20394, - 'timestamp': '2023-01-19 16:00:00+00:00', + 'price': 0.07905, + 'timestamp': '2026-04-19 15:00:00+00:00', }), dict({ - 'price': 0.19757, - 'timestamp': '2023-01-19 17:00:00+00:00', + 'price': 0.12566, + 'timestamp': '2026-04-19 16:00:00+00:00', }), dict({ - 'price': 0.17143, - 'timestamp': '2023-01-19 18:00:00+00:00', + 'price': 0.14134, + 'timestamp': '2026-04-19 17:00:00+00:00', }), dict({ - 'price': 0.15, - 'timestamp': '2023-01-19 19:00:00+00:00', + 'price': 0.15082, + 'timestamp': '2026-04-19 18:00:00+00:00', }), dict({ - 'price': 0.14841, - 'timestamp': '2023-01-19 20:00:00+00:00', + 'price': 0.14779, + 'timestamp': '2026-04-19 19:00:00+00:00', }), dict({ - 'price': 0.14934, - 'timestamp': '2023-01-19 21:00:00+00:00', + 'price': 0.14407, + 'timestamp': '2026-04-19 20:00:00+00:00', }), dict({ - 'price': 0.139, - 'timestamp': '2023-01-19 22:00:00+00:00', + 'price': 0.13467, + 'timestamp': '2026-04-19 21:00:00+00:00', }), ]), }) @@ -1941,100 +1413,100 @@ dict({ 'prices': list([ dict({ - 'price': 0.13495, - 'timestamp': '2023-01-18 23:00:00+00:00', + 'price': 0.13975, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.12945, - 'timestamp': '2023-01-19 00:00:00+00:00', + 'price': 0.12732, + 'timestamp': '2026-04-18 23:00:00+00:00', }), dict({ - 'price': 0.12701, - 'timestamp': '2023-01-19 01:00:00+00:00', + 'price': 0.13213, + 'timestamp': '2026-04-19 00:00:00+00:00', }), dict({ - 'price': 0.12308, - 'timestamp': '2023-01-19 02:00:00+00:00', + 'price': 0.12493, + 'timestamp': '2026-04-19 01:00:00+00:00', }), dict({ - 'price': 0.12975, - 'timestamp': '2023-01-19 03:00:00+00:00', + 'price': 0.12519, + 'timestamp': '2026-04-19 02:00:00+00:00', }), dict({ - 'price': 0.13869, - 'timestamp': '2023-01-19 04:00:00+00:00', + 'price': 0.11852, + 'timestamp': '2026-04-19 03:00:00+00:00', }), dict({ - 'price': 0.14392, - 'timestamp': '2023-01-19 05:00:00+00:00', + 'price': 0.12186, + 'timestamp': '2026-04-19 04:00:00+00:00', }), dict({ - 'price': 0.19348, - 'timestamp': '2023-01-19 06:00:00+00:00', + 'price': 0.1185, + 'timestamp': '2026-04-19 05:00:00+00:00', }), dict({ - 'price': 0.19844, - 'timestamp': '2023-01-19 07:00:00+00:00', + 'price': 0.11768, + 'timestamp': '2026-04-19 06:00:00+00:00', }), dict({ - 'price': 0.20774, - 'timestamp': '2023-01-19 08:00:00+00:00', + 'price': 0.10607, + 'timestamp': '2026-04-19 07:00:00+00:00', }), dict({ - 'price': 0.16819, - 'timestamp': '2023-01-19 09:00:00+00:00', + 'price': 0.07419, + 'timestamp': '2026-04-19 08:00:00+00:00', }), dict({ - 'price': 0.16498, - 'timestamp': '2023-01-19 10:00:00+00:00', + 'price': 0.03426, + 'timestamp': '2026-04-19 09:00:00+00:00', }), dict({ - 'price': 0.15682, - 'timestamp': '2023-01-19 11:00:00+00:00', + 'price': 0.00975, + 'timestamp': '2026-04-19 10:00:00+00:00', }), dict({ - 'price': 0.18739, - 'timestamp': '2023-01-19 12:00:00+00:00', + 'price': -0.00085, + 'timestamp': '2026-04-19 11:00:00+00:00', }), dict({ - 'price': 0.19419, - 'timestamp': '2023-01-19 13:00:00+00:00', + 'price': -0.003, + 'timestamp': '2026-04-19 12:00:00+00:00', }), dict({ - 'price': 0.21291, - 'timestamp': '2023-01-19 14:00:00+00:00', + 'price': -0.00226, + 'timestamp': '2026-04-19 13:00:00+00:00', }), dict({ - 'price': 0.22541, - 'timestamp': '2023-01-19 15:00:00+00:00', + 'price': 0.01631, + 'timestamp': '2026-04-19 14:00:00+00:00', }), dict({ - 'price': 0.24677, - 'timestamp': '2023-01-19 16:00:00+00:00', + 'price': 0.07905, + 'timestamp': '2026-04-19 15:00:00+00:00', }), dict({ - 'price': 0.23906, - 'timestamp': '2023-01-19 17:00:00+00:00', + 'price': 0.12566, + 'timestamp': '2026-04-19 16:00:00+00:00', }), dict({ - 'price': 0.20743, - 'timestamp': '2023-01-19 18:00:00+00:00', + 'price': 0.14134, + 'timestamp': '2026-04-19 17:00:00+00:00', }), dict({ - 'price': 0.1815, - 'timestamp': '2023-01-19 19:00:00+00:00', + 'price': 0.15082, + 'timestamp': '2026-04-19 18:00:00+00:00', }), dict({ - 'price': 0.17958, - 'timestamp': '2023-01-19 20:00:00+00:00', + 'price': 0.14779, + 'timestamp': '2026-04-19 19:00:00+00:00', }), dict({ - 'price': 0.1807, - 'timestamp': '2023-01-19 21:00:00+00:00', + 'price': 0.14407, + 'timestamp': '2026-04-19 20:00:00+00:00', }), dict({ - 'price': 0.16819, - 'timestamp': '2023-01-19 22:00:00+00:00', + 'price': 0.13467, + 'timestamp': '2026-04-19 21:00:00+00:00', }), ]), }) @@ -2043,100 +1515,12 @@ dict({ 'prices': list([ dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 05:00:00+00:00', + 'price': 0.6169, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 06:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 07:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 08:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 09:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 10:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 11:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 12:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 13:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 14:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 15:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 16:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 17:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 18:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 19:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 20:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 21:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 22:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 23:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 00:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 01:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 02:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 03:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 04:00:00+00:00', + 'price': 0.57862, + 'timestamp': '2026-04-19 22:00:00+00:00', }), ]), }) @@ -2145,100 +1529,100 @@ dict({ 'prices': list([ dict({ - 'price': 0.11153, - 'timestamp': '2023-01-18 23:00:00+00:00', + 'price': 0.13975, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.10698, - 'timestamp': '2023-01-19 00:00:00+00:00', + 'price': 0.12732, + 'timestamp': '2026-04-18 23:00:00+00:00', }), dict({ - 'price': 0.10497, - 'timestamp': '2023-01-19 01:00:00+00:00', + 'price': 0.13213, + 'timestamp': '2026-04-19 00:00:00+00:00', }), dict({ - 'price': 0.10172, - 'timestamp': '2023-01-19 02:00:00+00:00', + 'price': 0.12493, + 'timestamp': '2026-04-19 01:00:00+00:00', }), dict({ - 'price': 0.10723, - 'timestamp': '2023-01-19 03:00:00+00:00', + 'price': 0.12519, + 'timestamp': '2026-04-19 02:00:00+00:00', }), dict({ - 'price': 0.11462, - 'timestamp': '2023-01-19 04:00:00+00:00', + 'price': 0.11852, + 'timestamp': '2026-04-19 03:00:00+00:00', }), dict({ - 'price': 0.11894, - 'timestamp': '2023-01-19 05:00:00+00:00', + 'price': 0.12186, + 'timestamp': '2026-04-19 04:00:00+00:00', }), dict({ - 'price': 0.1599, - 'timestamp': '2023-01-19 06:00:00+00:00', + 'price': 0.1185, + 'timestamp': '2026-04-19 05:00:00+00:00', }), dict({ - 'price': 0.164, - 'timestamp': '2023-01-19 07:00:00+00:00', + 'price': 0.11768, + 'timestamp': '2026-04-19 06:00:00+00:00', }), dict({ - 'price': 0.17169, - 'timestamp': '2023-01-19 08:00:00+00:00', + 'price': 0.10607, + 'timestamp': '2026-04-19 07:00:00+00:00', }), dict({ - 'price': 0.139, - 'timestamp': '2023-01-19 09:00:00+00:00', + 'price': 0.07419, + 'timestamp': '2026-04-19 08:00:00+00:00', }), dict({ - 'price': 0.13635, - 'timestamp': '2023-01-19 10:00:00+00:00', + 'price': 0.03426, + 'timestamp': '2026-04-19 09:00:00+00:00', }), dict({ - 'price': 0.1296, - 'timestamp': '2023-01-19 11:00:00+00:00', + 'price': 0.00975, + 'timestamp': '2026-04-19 10:00:00+00:00', }), dict({ - 'price': 0.15487, - 'timestamp': '2023-01-19 12:00:00+00:00', + 'price': -0.00085, + 'timestamp': '2026-04-19 11:00:00+00:00', }), dict({ - 'price': 0.16049, - 'timestamp': '2023-01-19 13:00:00+00:00', + 'price': -0.003, + 'timestamp': '2026-04-19 12:00:00+00:00', }), dict({ - 'price': 0.17596, - 'timestamp': '2023-01-19 14:00:00+00:00', + 'price': -0.00226, + 'timestamp': '2026-04-19 13:00:00+00:00', }), dict({ - 'price': 0.18629, - 'timestamp': '2023-01-19 15:00:00+00:00', + 'price': 0.01631, + 'timestamp': '2026-04-19 14:00:00+00:00', }), dict({ - 'price': 0.20394, - 'timestamp': '2023-01-19 16:00:00+00:00', + 'price': 0.07905, + 'timestamp': '2026-04-19 15:00:00+00:00', }), dict({ - 'price': 0.19757, - 'timestamp': '2023-01-19 17:00:00+00:00', + 'price': 0.12566, + 'timestamp': '2026-04-19 16:00:00+00:00', }), dict({ - 'price': 0.17143, - 'timestamp': '2023-01-19 18:00:00+00:00', + 'price': 0.14134, + 'timestamp': '2026-04-19 17:00:00+00:00', }), dict({ - 'price': 0.15, - 'timestamp': '2023-01-19 19:00:00+00:00', + 'price': 0.15082, + 'timestamp': '2026-04-19 18:00:00+00:00', }), dict({ - 'price': 0.14841, - 'timestamp': '2023-01-19 20:00:00+00:00', + 'price': 0.14779, + 'timestamp': '2026-04-19 19:00:00+00:00', }), dict({ - 'price': 0.14934, - 'timestamp': '2023-01-19 21:00:00+00:00', + 'price': 0.14407, + 'timestamp': '2026-04-19 20:00:00+00:00', }), dict({ - 'price': 0.139, - 'timestamp': '2023-01-19 22:00:00+00:00', + 'price': 0.13467, + 'timestamp': '2026-04-19 21:00:00+00:00', }), ]), }) @@ -2247,100 +1631,100 @@ dict({ 'prices': list([ dict({ - 'price': 0.13495, - 'timestamp': '2023-01-18 23:00:00+00:00', + 'price': 0.13975, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.12945, - 'timestamp': '2023-01-19 00:00:00+00:00', + 'price': 0.12732, + 'timestamp': '2026-04-18 23:00:00+00:00', }), dict({ - 'price': 0.12701, - 'timestamp': '2023-01-19 01:00:00+00:00', + 'price': 0.13213, + 'timestamp': '2026-04-19 00:00:00+00:00', }), dict({ - 'price': 0.12308, - 'timestamp': '2023-01-19 02:00:00+00:00', + 'price': 0.12493, + 'timestamp': '2026-04-19 01:00:00+00:00', }), dict({ - 'price': 0.12975, - 'timestamp': '2023-01-19 03:00:00+00:00', + 'price': 0.12519, + 'timestamp': '2026-04-19 02:00:00+00:00', }), dict({ - 'price': 0.13869, - 'timestamp': '2023-01-19 04:00:00+00:00', + 'price': 0.11852, + 'timestamp': '2026-04-19 03:00:00+00:00', }), dict({ - 'price': 0.14392, - 'timestamp': '2023-01-19 05:00:00+00:00', + 'price': 0.12186, + 'timestamp': '2026-04-19 04:00:00+00:00', }), dict({ - 'price': 0.19348, - 'timestamp': '2023-01-19 06:00:00+00:00', + 'price': 0.1185, + 'timestamp': '2026-04-19 05:00:00+00:00', }), dict({ - 'price': 0.19844, - 'timestamp': '2023-01-19 07:00:00+00:00', + 'price': 0.11768, + 'timestamp': '2026-04-19 06:00:00+00:00', }), dict({ - 'price': 0.20774, - 'timestamp': '2023-01-19 08:00:00+00:00', + 'price': 0.10607, + 'timestamp': '2026-04-19 07:00:00+00:00', }), dict({ - 'price': 0.16819, - 'timestamp': '2023-01-19 09:00:00+00:00', + 'price': 0.07419, + 'timestamp': '2026-04-19 08:00:00+00:00', }), dict({ - 'price': 0.16498, - 'timestamp': '2023-01-19 10:00:00+00:00', + 'price': 0.03426, + 'timestamp': '2026-04-19 09:00:00+00:00', }), dict({ - 'price': 0.15682, - 'timestamp': '2023-01-19 11:00:00+00:00', + 'price': 0.00975, + 'timestamp': '2026-04-19 10:00:00+00:00', }), dict({ - 'price': 0.18739, - 'timestamp': '2023-01-19 12:00:00+00:00', + 'price': -0.00085, + 'timestamp': '2026-04-19 11:00:00+00:00', }), dict({ - 'price': 0.19419, - 'timestamp': '2023-01-19 13:00:00+00:00', + 'price': -0.003, + 'timestamp': '2026-04-19 12:00:00+00:00', }), dict({ - 'price': 0.21291, - 'timestamp': '2023-01-19 14:00:00+00:00', + 'price': -0.00226, + 'timestamp': '2026-04-19 13:00:00+00:00', }), dict({ - 'price': 0.22541, - 'timestamp': '2023-01-19 15:00:00+00:00', + 'price': 0.01631, + 'timestamp': '2026-04-19 14:00:00+00:00', }), dict({ - 'price': 0.24677, - 'timestamp': '2023-01-19 16:00:00+00:00', + 'price': 0.07905, + 'timestamp': '2026-04-19 15:00:00+00:00', }), dict({ - 'price': 0.23906, - 'timestamp': '2023-01-19 17:00:00+00:00', + 'price': 0.12566, + 'timestamp': '2026-04-19 16:00:00+00:00', }), dict({ - 'price': 0.20743, - 'timestamp': '2023-01-19 18:00:00+00:00', + 'price': 0.14134, + 'timestamp': '2026-04-19 17:00:00+00:00', }), dict({ - 'price': 0.1815, - 'timestamp': '2023-01-19 19:00:00+00:00', + 'price': 0.15082, + 'timestamp': '2026-04-19 18:00:00+00:00', }), dict({ - 'price': 0.17958, - 'timestamp': '2023-01-19 20:00:00+00:00', + 'price': 0.14779, + 'timestamp': '2026-04-19 19:00:00+00:00', }), dict({ - 'price': 0.1807, - 'timestamp': '2023-01-19 21:00:00+00:00', + 'price': 0.14407, + 'timestamp': '2026-04-19 20:00:00+00:00', }), dict({ - 'price': 0.16819, - 'timestamp': '2023-01-19 22:00:00+00:00', + 'price': 0.13467, + 'timestamp': '2026-04-19 21:00:00+00:00', }), ]), }) @@ -2349,100 +1733,12 @@ dict({ 'prices': list([ dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 05:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 06:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 07:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 08:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 09:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 10:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 11:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 12:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 13:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 14:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 15:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 16:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 17:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 18:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 19:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 20:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 21:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 22:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-19 23:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 00:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 01:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 02:00:00+00:00', - }), - dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 03:00:00+00:00', + 'price': 0.6169, + 'timestamp': '2026-04-18 22:00:00+00:00', }), dict({ - 'price': 0.7253, - 'timestamp': '2023-01-20 04:00:00+00:00', + 'price': 0.57862, + 'timestamp': '2026-04-19 22:00:00+00:00', }), ]), }) diff --git a/tests/components/easyenergy/test_diagnostics.py b/tests/components/easyenergy/test_diagnostics.py index 3820078a00bd83..f493487e44dce2 100644 --- a/tests/components/easyenergy/test_diagnostics.py +++ b/tests/components/easyenergy/test_diagnostics.py @@ -19,7 +19,7 @@ from tests.typing import ClientSessionGenerator -@pytest.mark.freeze_time("2023-01-19 15:00:00") +@pytest.mark.freeze_time("2026-04-19 13:00:00+00:00") async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -33,7 +33,7 @@ async def test_diagnostics( ) -@pytest.mark.freeze_time("2023-01-19 15:00:00") +@pytest.mark.freeze_time("2026-04-19 13:00:00+00:00") async def test_diagnostics_no_gas_today( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/easyenergy/test_sensor.py b/tests/components/easyenergy/test_sensor.py index 5ca4740dc6f804..268b3d2b6f68f8 100644 --- a/tests/components/easyenergy/test_sensor.py +++ b/tests/components/easyenergy/test_sensor.py @@ -33,7 +33,7 @@ from tests.common import MockConfigEntry -@pytest.mark.freeze_time("2023-01-19 15:00:00") +@pytest.mark.freeze_time("2026-04-19 13:00:00+00:00") async def test_energy_usage_today( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -51,7 +51,7 @@ async def test_energy_usage_today( assert entry assert state assert entry.unique_id == f"{entry_id}_today_energy_usage_current_hour_price" - assert state.state == "0.22541" + assert state.state == "-0.00226" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy market price - Usage Current hour" @@ -72,7 +72,7 @@ async def test_energy_usage_today( assert entry assert state assert entry.unique_id == f"{entry_id}_today_energy_usage_average_price" - assert state.state == "0.17665" + assert state.state == "0.09516" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy market price - Usage Average - today" @@ -90,7 +90,7 @@ async def test_energy_usage_today( assert entry assert state assert entry.unique_id == f"{entry_id}_today_energy_usage_max_price" - assert state.state == "0.24677" + assert state.state == "0.15082" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy market price - Usage Highest price - today" @@ -110,7 +110,7 @@ async def test_energy_usage_today( assert entry assert state assert entry.unique_id == f"{entry_id}_today_energy_usage_highest_price_time" - assert state.state == "2023-01-19T16:00:00+00:00" + assert state.state == "2026-04-19T18:00:00+00:00" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy market price - Usage Time of highest price - today" @@ -140,7 +140,7 @@ async def test_energy_usage_today( assert ( entry.unique_id == f"{entry_id}_today_energy_usage_hours_priced_equal_or_lower" ) - assert state.state == "21" + assert state.state == "2" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy market price - Usage Hours priced equal or lower than current - today" @@ -148,7 +148,7 @@ async def test_energy_usage_today( assert ATTR_DEVICE_CLASS not in state.attributes -@pytest.mark.freeze_time("2023-01-19 15:00:00") +@pytest.mark.freeze_time("2026-04-19 13:00:00+00:00") async def test_energy_return_today( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -166,7 +166,7 @@ async def test_energy_return_today( assert entry assert state assert entry.unique_id == f"{entry_id}_today_energy_return_current_hour_price" - assert state.state == "0.18629" + assert state.state == "-0.00226" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy market price - Return Current hour" @@ -187,7 +187,7 @@ async def test_energy_return_today( assert entry assert state assert entry.unique_id == f"{entry_id}_today_energy_return_average_price" - assert state.state == "0.14599" + assert state.state == "0.09516" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy market price - Return Average - today" @@ -205,7 +205,7 @@ async def test_energy_return_today( assert entry assert state assert entry.unique_id == f"{entry_id}_today_energy_return_max_price" - assert state.state == "0.20394" + assert state.state == "0.15082" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy market price - Return Highest price - today" @@ -225,7 +225,7 @@ async def test_energy_return_today( assert entry assert state assert entry.unique_id == f"{entry_id}_today_energy_return_highest_price_time" - assert state.state == "2023-01-19T16:00:00+00:00" + assert state.state == "2026-04-19T18:00:00+00:00" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy market price - Return Time of highest price - today" @@ -256,7 +256,7 @@ async def test_energy_return_today( entry.unique_id == f"{entry_id}_today_energy_return_hours_priced_equal_or_higher" ) - assert state.state == "3" + assert state.state == "23" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy market price - Return Hours priced equal or higher than current - today" @@ -264,7 +264,7 @@ async def test_energy_return_today( assert ATTR_DEVICE_CLASS not in state.attributes -@pytest.mark.freeze_time("2023-01-19 10:00:00") +@pytest.mark.freeze_time("2026-04-19 10:00:00+00:00") async def test_gas_today( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -280,7 +280,7 @@ async def test_gas_today( assert entry assert state assert entry.unique_id == f"{entry_id}_today_gas_current_hour_price" - assert state.state == "0.7253" + assert state.state == "0.6169" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Gas market price Current hour" assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -301,7 +301,7 @@ async def test_gas_today( assert not device_entry.sw_version -@pytest.mark.freeze_time("2023-01-19 15:00:00") +@pytest.mark.freeze_time("2026-04-19 13:00:00+00:00") async def test_no_gas_today( hass: HomeAssistant, mock_easyenergy: MagicMock, init_integration: MockConfigEntry ) -> None: diff --git a/tests/components/easyenergy/test_services.py b/tests/components/easyenergy/test_services.py index aaaf9b6d9694e5..4df513a77f28b4 100644 --- a/tests/components/easyenergy/test_services.py +++ b/tests/components/easyenergy/test_services.py @@ -1,5 +1,9 @@ """Tests for the services provided by the easyEnergy integration.""" +from datetime import date +from unittest.mock import MagicMock + +from easyenergy import VatOption import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol @@ -37,8 +41,8 @@ async def test_has_services( ], ) @pytest.mark.parametrize("incl_vat", [{"incl_vat": False}, {"incl_vat": True}]) -@pytest.mark.parametrize("start", [{"start": "2023-01-01 00:00:00"}, {}]) -@pytest.mark.parametrize("end", [{"end": "2023-01-01 00:00:00"}, {}]) +@pytest.mark.parametrize("start", [{"start": "2023-01-01"}, {}]) +@pytest.mark.parametrize("end", [{"end": "2023-01-01"}, {}]) async def test_service( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -48,8 +52,10 @@ async def test_service( start: dict[str, str], end: dict[str, str], ) -> None: - """Test the EnergyZero Service.""" + """Test the easyEnergy service.""" entry = {ATTR_CONFIG_ENTRY: mock_config_entry.entry_id} + if service == ENERGY_RETURN_SERVICE_NAME: + incl_vat = {} data = entry | incl_vat | start | end @@ -62,6 +68,75 @@ async def test_service( ) +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("service", "expected_prices"), + [ + ( + GAS_SERVICE_NAME, + [ + {"timestamp": "2026-04-18 22:00:00+00:00", "price": 0.6169}, + ], + ), + ( + ENERGY_USAGE_SERVICE_NAME, + [ + {"timestamp": "2026-04-19 00:00:00+00:00", "price": 0.13213}, + {"timestamp": "2026-04-19 01:00:00+00:00", "price": 0.12493}, + {"timestamp": "2026-04-19 02:00:00+00:00", "price": 0.12519}, + ], + ), + ( + ENERGY_RETURN_SERVICE_NAME, + [ + {"timestamp": "2026-04-19 00:00:00+00:00", "price": 0.13213}, + {"timestamp": "2026-04-19 01:00:00+00:00", "price": 0.12493}, + {"timestamp": "2026-04-19 02:00:00+00:00", "price": 0.12519}, + ], + ), + ], +) +async def test_service_filters_datetime_range( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_easyenergy: MagicMock, + service: str, + expected_prices: list[dict[str, str | float]], +) -> None: + """Test easyEnergy services filter returned day data to a datetime range.""" + service_data: dict[str, str | bool] = { + ATTR_CONFIG_ENTRY: mock_config_entry.entry_id, + "start": "2026-04-19 02:00:00+02:00", + "end": "2026-04-19 05:00:00+02:00", + } + if service != ENERGY_RETURN_SERVICE_NAME: + service_data["incl_vat"] = True + + mock_easyenergy.reset_mock() + + response = await hass.services.async_call( + DOMAIN, + service, + service_data, + blocking=True, + return_response=True, + ) + + assert response == {"prices": expected_prices} + + expected_call = { + "start_date": date(2026, 4, 19), + "end_date": date(2026, 4, 19), + "vat": VatOption.INCLUDE, + } + if service == GAS_SERVICE_NAME: + mock_easyenergy.gas_prices.assert_called_once_with(**expected_call) + mock_easyenergy.energy_prices.assert_not_called() + else: + mock_easyenergy.energy_prices.assert_called_once_with(**expected_call) + mock_easyenergy.gas_prices.assert_not_called() + + @pytest.fixture def config_entry_data( mock_config_entry: MockConfigEntry, request: pytest.FixtureRequest @@ -83,65 +158,22 @@ def config_entry_data( ], ) @pytest.mark.parametrize( - ("config_entry_data", "service_data", "error", "error_message"), + ("config_entry_data", "service_data", "error_message"), [ - ({}, {}, vol.er.Error, "required key not provided .+"), - ( - {"config_entry": True}, - {}, - vol.er.Error, - "required key not provided .+", - ), - ( - {}, - {"incl_vat": True}, - vol.er.Error, - "required key not provided .+", - ), - ( - {"config_entry": True}, - {"incl_vat": "incorrect vat"}, - vol.er.Error, - "expected bool for dictionary value .+", - ), - ( - {"config_entry": "incorrect entry"}, - {"incl_vat": True}, - ServiceValidationError, - "config entry with ID incorrect entry was not found", - ), - ( - {"config_entry": True}, - { - "incl_vat": True, - "start": "incorrect date", - }, - ServiceValidationError, - "Invalid datetime provided.", - ), - ( - {"config_entry": True}, - { - "incl_vat": True, - "end": "incorrect date", - }, - ServiceValidationError, - "Invalid datetime provided.", - ), + ({}, {}, "required key not provided .+"), ], indirect=["config_entry_data"], ) -async def test_service_validation( +async def test_service_schema_validation( hass: HomeAssistant, service: str, config_entry_data: dict[str, str], service_data: dict[str, str | bool], - error: type[Exception], error_message: str, ) -> None: - """Test the easyEnergy Service.""" + """Test easyEnergy service schema validation.""" - with pytest.raises(error, match=error_message): + with pytest.raises(vol.er.Error, match=error_message): await hass.services.async_call( DOMAIN, service, @@ -149,3 +181,122 @@ async def test_service_validation( blocking=True, return_response=True, ) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_USAGE_SERVICE_NAME]) +@pytest.mark.parametrize( + ("service_data", "error_message"), + [ + ({}, "required key not provided .+"), + ({"incl_vat": "incorrect vat"}, "expected bool for dictionary value .+"), + ], +) +async def test_service_schema_validation_vat( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + service: str, + service_data: dict[str, str | bool], + error_message: str, +) -> None: + """Test easyEnergy service schema validation for VAT.""" + + with pytest.raises(vol.er.Error, match=error_message): + await hass.services.async_call( + DOMAIN, + service, + {ATTR_CONFIG_ENTRY: mock_config_entry.entry_id} | service_data, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_service_schema_validation_return_vat( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test return prices do not accept VAT selection.""" + + with pytest.raises(vol.er.Error, match="extra keys not allowed .+"): + await hass.services.async_call( + DOMAIN, + ENERGY_RETURN_SERVICE_NAME, + {ATTR_CONFIG_ENTRY: mock_config_entry.entry_id, "incl_vat": True}, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + "service", + [ + GAS_SERVICE_NAME, + ENERGY_USAGE_SERVICE_NAME, + ENERGY_RETURN_SERVICE_NAME, + ], +) +async def test_service_validation_config_entry_not_found( + hass: HomeAssistant, + service: str, +) -> None: + """Test config entry validation for easyEnergy services.""" + service_data: dict[str, str | bool] = {ATTR_CONFIG_ENTRY: "incorrect entry"} + if service != ENERGY_RETURN_SERVICE_NAME: + service_data["incl_vat"] = True + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + service, + service_data, + blocking=True, + return_response=True, + ) + + assert err.value.translation_key == "service_config_entry_not_found" + assert err.value.translation_placeholders == { + "domain": DOMAIN, + "entry_id": "incorrect entry", + } + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + "service", + [ + GAS_SERVICE_NAME, + ENERGY_USAGE_SERVICE_NAME, + ENERGY_RETURN_SERVICE_NAME, + ], +) +@pytest.mark.parametrize("date_field", ["start", "end"]) +@pytest.mark.parametrize("date_value", ["incorrect date"]) +async def test_service_validation_invalid_date( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + service: str, + date_field: str, + date_value: str, +) -> None: + """Test invalid date validation for easyEnergy services.""" + service_data: dict[str, str | bool] = { + ATTR_CONFIG_ENTRY: mock_config_entry.entry_id, + date_field: date_value, + } + if service != ENERGY_RETURN_SERVICE_NAME: + service_data["incl_vat"] = True + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + service, + service_data, + blocking=True, + return_response=True, + ) + + assert str(err.value) == f"Invalid date provided. Got {date_value}" + assert err.value.translation_key == "invalid_date" + assert err.value.translation_placeholders == {"date": date_value} diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index ffdf754e38d935..dd15b891a9bf4b 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -1427,6 +1427,92 @@ async def test_power_sensor_manager_creation( state = hass.states.get("sensor.battery_power_inverted") assert state is not None assert float(state.state) == -100.0 + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfPower.WATT + + +async def test_power_sensor_inverted_propagates_unit( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test inverted power sensor copies unit from the source state.""" + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + + # Use a non-default unit to prove we copy from the source rather than + # hard-coding Watts. + hass.states.async_set( + "sensor.battery_power", + "1.5", + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT}, + ) + await hass.async_block_till_done() + + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": { + "stat_rate_inverted": "sensor.battery_power", + }, + } + ], + } + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.battery_power_inverted") + assert state is not None + assert float(state.state) == -1.5 + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfPower.KILO_WATT + + # Source switches to Watts — the inverted sensor should follow. + hass.states.async_set( + "sensor.battery_power", + "200.0", + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.battery_power_inverted") + assert state is not None + assert float(state.state) == -200.0 + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfPower.WATT + + +async def test_power_sensor_inverted_source_without_unit( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test inverted sensor reports no unit when source has none.""" + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + + hass.states.async_set("sensor.battery_power", "100.0") + await hass.async_block_till_done() + + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": { + "stat_rate_inverted": "sensor.battery_power", + }, + } + ], + } + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.battery_power_inverted") + assert state is not None + assert float(state.state) == -100.0 + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes async def test_power_sensor_manager_cleanup( diff --git a/tests/components/esphome/test_radio_frequency.py b/tests/components/esphome/test_radio_frequency.py new file mode 100644 index 00000000000000..b6c4b82953bce6 --- /dev/null +++ b/tests/components/esphome/test_radio_frequency.py @@ -0,0 +1,208 @@ +"""Test ESPHome radio frequency platform.""" + +from aioesphomeapi import ( + APIClient, + APIConnectionError, + RadioFrequencyCapability, + RadioFrequencyInfo, + RadioFrequencyModulation, +) +import pytest +from rf_protocols import ModulationType, OOKCommand + +from homeassistant.components import radio_frequency +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .conftest import MockESPHomeDevice, MockESPHomeDeviceType + +ENTITY_ID = "radio_frequency.test_rf" + + +async def _mock_rf_device( + mock_esphome_device: MockESPHomeDeviceType, + mock_client: APIClient, + capabilities: RadioFrequencyCapability = RadioFrequencyCapability.TRANSMITTER, + frequency_min: int = 433_000_000, + frequency_max: int = 434_000_000, + supported_modulations: int = 1, +) -> MockESPHomeDevice: + entity_info = [ + RadioFrequencyInfo( + object_id="rf", + key=1, + name="RF", + capabilities=capabilities, + frequency_min=frequency_min, + frequency_max=frequency_max, + supported_modulations=supported_modulations, + ) + ] + return await mock_esphome_device( + mock_client=mock_client, entity_info=entity_info, states=[] + ) + + +@pytest.mark.parametrize( + ("capabilities", "entity_created"), + [ + (RadioFrequencyCapability.TRANSMITTER, True), + (RadioFrequencyCapability.RECEIVER, False), + ( + RadioFrequencyCapability.TRANSMITTER | RadioFrequencyCapability.RECEIVER, + True, + ), + (RadioFrequencyCapability(0), False), + ], +) +async def test_radio_frequency_entity_transmitter( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + capabilities: RadioFrequencyCapability, + entity_created: bool, +) -> None: + """Test radio frequency entity with transmitter capability is created.""" + await _mock_rf_device(mock_esphome_device, mock_client, capabilities) + + state = hass.states.get(ENTITY_ID) + assert (state is not None) == entity_created + + +async def test_radio_frequency_multiple_entities_mixed_capabilities( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test multiple radio frequency entities with mixed capabilities.""" + entity_info = [ + RadioFrequencyInfo( + object_id="rf_transmitter", + key=1, + name="RF Transmitter", + capabilities=RadioFrequencyCapability.TRANSMITTER, + ), + RadioFrequencyInfo( + object_id="rf_receiver", + key=2, + name="RF Receiver", + capabilities=RadioFrequencyCapability.RECEIVER, + ), + RadioFrequencyInfo( + object_id="rf_transceiver", + key=3, + name="RF Transceiver", + capabilities=( + RadioFrequencyCapability.TRANSMITTER | RadioFrequencyCapability.RECEIVER + ), + ), + ] + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + states=[], + ) + + # Only transmitter and transceiver should be created + assert hass.states.get("radio_frequency.test_rf_transmitter") is not None + assert hass.states.get("radio_frequency.test_rf_receiver") is None + assert hass.states.get("radio_frequency.test_rf_transceiver") is not None + + +async def test_radio_frequency_send_command_success( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test sending RF command successfully.""" + await _mock_rf_device(mock_esphome_device, mock_client) + + command = OOKCommand( + frequency=433_920_000, + timings=[350, -1050, 350, -350], + ) + await radio_frequency.async_send_command(hass, ENTITY_ID, command) + + mock_client.radio_frequency_transmit_raw_timings.assert_called_once() + call_args = mock_client.radio_frequency_transmit_raw_timings.call_args + assert call_args[0][0] == 1 # key + assert call_args[1]["frequency"] == 433_920_000 + assert call_args[1]["modulation"] == RadioFrequencyModulation.OOK + assert call_args[1]["repeat_count"] == 1 + assert call_args[1]["device_id"] == 0 + assert call_args[1]["timings"] == [350, -1050, 350, -350] + + +async def test_radio_frequency_send_command_failure( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test sending RF command with APIConnectionError raises HomeAssistantError.""" + await _mock_rf_device(mock_esphome_device, mock_client) + + mock_client.radio_frequency_transmit_raw_timings.side_effect = APIConnectionError( + "Connection lost" + ) + + command = OOKCommand( + frequency=433_920_000, + timings=[350, -1050], + ) + + with pytest.raises(HomeAssistantError) as exc_info: + await radio_frequency.async_send_command(hass, ENTITY_ID, command) + assert exc_info.value.translation_domain == "esphome" + assert exc_info.value.translation_key == "error_communicating_with_device" + + +async def test_radio_frequency_entity_availability( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test radio frequency entity becomes available after device reconnects.""" + mock_device = await _mock_rf_device(mock_esphome_device, mock_client) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + await mock_device.mock_disconnect(False) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + await mock_device.mock_connect() + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + +async def test_radio_frequency_supported_frequency_ranges( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test supported frequency ranges are exposed from device info.""" + await _mock_rf_device( + mock_esphome_device, + mock_client, + frequency_min=433_000_000, + frequency_max=434_000_000, + ) + + transmitters = radio_frequency.async_get_transmitters( + hass, 433_920_000, ModulationType.OOK + ) + assert len(transmitters) == 1 + + transmitters = radio_frequency.async_get_transmitters( + hass, 868_000_000, ModulationType.OOK + ) + assert len(transmitters) == 0 diff --git a/tests/components/eurotronic_cometblue/snapshots/test_climate.ambr b/tests/components/eurotronic_cometblue/snapshots/test_climate.ambr index 88b82ad1a83536..74311182fc2e5d 100644 --- a/tests/components/eurotronic_cometblue/snapshots/test_climate.ambr +++ b/tests/components/eurotronic_cometblue/snapshots/test_climate.ambr @@ -46,7 +46,7 @@ 'platform': 'eurotronic_cometblue', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff', 'unit_of_measurement': None, @@ -72,9 +72,7 @@ 'away', 'none', ]), - 'supported_features': , - 'target_temp_high': 21.0, - 'target_temp_low': 17.0, + 'supported_features': , 'target_temp_step': 0.5, 'temperature': 20.0, }), diff --git a/tests/components/fan/test_condition.py b/tests/components/fan/test_condition.py index 425af847667427..d3cde640af8b10 100644 --- a/tests/components/fan/test_condition.py +++ b/tests/components/fan/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -39,6 +40,31 @@ async def test_fan_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("fan.is_off", {}, True, True), + ("fan.is_on", {}, True, True), + ], +) +async def test_fan_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that fan conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index 5d2ac1a440624b..573529245a82d5 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -85,6 +85,7 @@ async def test_storage_data_writing( assert await async_setup_config_entry( hass, VALID_CONFIG_DEFAULT, return_value=feed_one_event ) + await hass.async_block_till_done() # one new event assert len(events) == 1 diff --git a/tests/components/flume/conftest.py b/tests/components/flume/conftest.py index 6173db1e2b9564..49d403a659b8d5 100644 --- a/tests/components/flume/conftest.py +++ b/tests/components/flume/conftest.py @@ -41,6 +41,7 @@ "type": 2, # Sensor "location": { "name": "Sensor Location", + "tz": "America/New_York", }, "name": "Flume Sensor", "connected": True, diff --git a/tests/components/flume/snapshots/test_sensor.ambr b/tests/components/flume/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..17c7716e072c23 --- /dev/null +++ b/tests/components/flume/snapshots/test_sensor.ambr @@ -0,0 +1,413 @@ +# serializer version: 1 +# name: test_sensors[sensor.flume_sensor_sensor_location_24_hours-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flume_sensor_sensor_location_24_hours', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': '24 hours', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '24 hours', + 'platform': 'flume', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_24_hrs', + 'unique_id': 'last_24_hrs_1234', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_24_hours-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Flume API', + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Flume Sensor Sensor Location 24 hours', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_24_hours', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.4', + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_30_days-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flume_sensor_sensor_location_30_days', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': '30 days', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '30 days', + 'platform': 'flume', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_30_days', + 'unique_id': 'last_30_days_1234', + 'unit_of_measurement': 'gal/mo', + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_30_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Flume API', + 'friendly_name': 'Flume Sensor Sensor Location 30 days', + 'state_class': , + 'unit_of_measurement': 'gal/mo', + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_30_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '150.8', + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_60_minutes-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flume_sensor_sensor_location_60_minutes', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': '60 minutes', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '60 minutes', + 'platform': 'flume', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_60_min', + 'unique_id': 'last_60_min_1234', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_60_minutes-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Flume API', + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Flume Sensor Sensor Location 60 minutes', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_60_minutes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.5', + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flume_sensor_sensor_location_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'flume', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_interval', + 'unique_id': 'current_interval_1234', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Flume API', + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Flume Sensor Sensor Location Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.23', + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_current_day-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flume_sensor_sensor_location_current_day', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current day', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current day', + 'platform': 'flume', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'today', + 'unique_id': 'today_1234', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_current_day-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Flume API', + 'device_class': 'water', + 'friendly_name': 'Flume Sensor Sensor Location Current day', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_current_day', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.2', + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_current_month-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flume_sensor_sensor_location_current_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current month', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current month', + 'platform': 'flume', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'month_to_date', + 'unique_id': 'month_to_date_1234', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_current_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Flume API', + 'device_class': 'water', + 'friendly_name': 'Flume Sensor Sensor Location Current month', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_current_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.1', + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_current_week-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flume_sensor_sensor_location_current_week', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current week', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current week', + 'platform': 'flume', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'week_to_date', + 'unique_id': 'week_to_date_1234', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_current_week-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Flume API', + 'device_class': 'water', + 'friendly_name': 'Flume Sensor Sensor Location Current week', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_current_week', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.5', + }) +# --- diff --git a/tests/components/flume/test_sensor.py b/tests/components/flume/test_sensor.py new file mode 100644 index 00000000000000..6d541de479fbe6 --- /dev/null +++ b/tests/components/flume/test_sensor.py @@ -0,0 +1,49 @@ +"""Test the flume sensor.""" + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def platforms_fixture(): + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.flume.PLATFORMS", [Platform.SENSOR]): + yield + + +@pytest.mark.usefixtures("access_token", "device_list") +async def test_sensors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test sensors.""" + hass.config.units = US_CUSTOMARY_SYSTEM + + flume_values = { + "current_interval": 1.23, + "month_to_date": 100.1, + "week_to_date": 50.5, + "today": 10.2, + "last_60_min": 5.5, + "last_24_hrs": 20.4, + "last_30_days": 150.8, + } + + with patch("homeassistant.components.flume.sensor.FlumeData") as mock_flume_data: + mock_flume_data.return_value.values = flume_values + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/forecast_solar/test_config_flow.py b/tests/components/forecast_solar/test_config_flow.py index d560fe0dc16960..c4a3756589b257 100644 --- a/tests/components/forecast_solar/test_config_flow.py +++ b/tests/components/forecast_solar/test_config_flow.py @@ -19,7 +19,7 @@ SOURCE_USER, ConfigSubentryData, ) -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -38,7 +38,6 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_NAME: "Name", CONF_LATITUDE: 52.42, CONF_LONGITUDE: 4.42, CONF_AZIMUTH: 142, @@ -50,7 +49,7 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No assert result["type"] is FlowResultType.CREATE_ENTRY config_entry = result["result"] - assert config_entry.title == "Name" + assert config_entry.title == "" assert config_entry.unique_id is None assert config_entry.data == { CONF_LATITUDE: 52.42, diff --git a/tests/components/fritz/snapshots/test_sensor.ambr b/tests/components/fritz/snapshots/test_sensor.ambr index d820dda43ee9e1..48ed12dec8fec1 100644 --- a/tests/components/fritz/snapshots/test_sensor.ambr +++ b/tests/components/fritz/snapshots/test_sensor.ambr @@ -24,7 +24,7 @@ 'object_id_base': 'Connection uptime', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Connection uptime', 'platform': 'fritz', @@ -39,7 +39,7 @@ # name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_connection_uptime-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Mock Title Connection uptime', }), 'context': , @@ -349,7 +349,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Last restart', 'platform': 'fritz', @@ -364,7 +364,7 @@ # name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_last_restart-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Mock Title Last restart', }), 'context': , @@ -882,7 +882,7 @@ 'object_id_base': 'Connection uptime', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Connection uptime', 'platform': 'fritz', @@ -897,7 +897,7 @@ # name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_connection_uptime-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Mock Title Connection uptime', }), 'context': , @@ -1207,7 +1207,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Last restart', 'platform': 'fritz', @@ -1222,7 +1222,7 @@ # name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_last_restart-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Mock Title Last restart', }), 'context': , @@ -1740,7 +1740,7 @@ 'object_id_base': 'Connection uptime', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Connection uptime', 'platform': 'fritz', @@ -1755,7 +1755,7 @@ # name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_connection_uptime-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Mock Title Connection uptime', }), 'context': , @@ -2065,7 +2065,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Last restart', 'platform': 'fritz', @@ -2080,7 +2080,7 @@ # name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_last_restart-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Mock Title Last restart', }), 'context': , @@ -2598,7 +2598,7 @@ 'object_id_base': 'Connection uptime', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Connection uptime', 'platform': 'fritz', @@ -2613,7 +2613,7 @@ # name: test_sensor_setup[sensor.mock_title_connection_uptime-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Mock Title Connection uptime', }), 'context': , @@ -2981,7 +2981,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Last restart', 'platform': 'fritz', @@ -2996,7 +2996,7 @@ # name: test_sensor_setup[sensor.mock_title_last_restart-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Mock Title Last restart', }), 'context': , diff --git a/tests/components/fritz/test_button.py b/tests/components/fritz/test_button.py index d5d5606b993c29..a26eccd82b2627 100644 --- a/tests/components/fritz/test_button.py +++ b/tests/components/fritz/test_button.py @@ -284,3 +284,23 @@ async def test_cleanup_button_deprecation_issue( issue_registry = ir.async_get(hass) assert issue_registry.async_get_issue(DOMAIN, "deprecated_cleanup_button") + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_firmware_update_button_deprecation_issue( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + fc_class_mock, + fh_class_mock, + fs_class_mock, +) -> None: + """Test deprecation issue is created when legacy firmware update button is enabled.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED + + issue_registry = ir.async_get(hass) + assert issue_registry.async_get_issue(DOMAIN, "deprecated_firmware_update_button") diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py index d00327994d633c..94cb74f63a0e91 100644 --- a/tests/components/fritz/test_sensor.py +++ b/tests/components/fritz/test_sensor.py @@ -11,7 +11,7 @@ from requests.exceptions import RequestException from syrupy.assertion import SnapshotAssertion -from homeassistant.components.fritz.const import DOMAIN, SCAN_INTERVAL, UPTIME_DEVIATION +from homeassistant.components.fritz.const import DOMAIN, SCAN_INTERVAL from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant @@ -95,13 +95,13 @@ async def test_sensor_uptime_spike( assert (state := hass.states.get(entity_id)) assert state.state == "2026-01-16T06:00:21+00:00" - # Simulate uptime spike by setting uptime to a value between - # the previous one and a delta smaller than UPTIME_DEVIATION + # Simulate uptime spike by setting uptime to a value that shifts + # the resulting timestamp only by 1 second. base_uptime = MOCK_FB_SERVICES["DeviceInfo1"]["GetInfo"]["NewUpTime"] update_uptime = { "DeviceInfo1": { "GetInfo": { - "NewUpTime": base_uptime + SCAN_INTERVAL - UPTIME_DEVIATION + 1, + "NewUpTime": base_uptime + SCAN_INTERVAL + 1, }, }, } diff --git a/tests/components/fumis/const.py b/tests/components/fumis/const.py new file mode 100644 index 00000000000000..035b0e9c158277 --- /dev/null +++ b/tests/components/fumis/const.py @@ -0,0 +1,3 @@ +"""Constants for the Fumis integration tests.""" + +UNIQUE_ID = "aa:bb:cc:dd:ee:ff" diff --git a/tests/components/fumis/fixtures/info_error_alert.json b/tests/components/fumis/fixtures/info_error_alert.json new file mode 100644 index 00000000000000..0c95ce0daefd46 --- /dev/null +++ b/tests/components/fumis/fixtures/info_error_alert.json @@ -0,0 +1,1209 @@ +{ + "apiVersion": "1.3", + "unit": { + "id": "AABBCCDDEEFF", + "type": 3, + "version": "2.5.0", + "command": null, + "rssi": "-60", + "ip": "192.168.1.2", + "timezone": null, + "temperature": 27 + }, + "controller": { + "type": 2, + "version": "2.6.0", + "command": 2, + "status": 30, + "heatingSlope": -0.1, + "stoveLastAvailability": 1776413799, + "mobileLastAvailability": 1776412827, + "currentTime": 1776413805, + "error": 101, + "alert": 1, + "timerEnable": false, + "fuelType": 1, + "timeToService": 2159, + "delayedStartAt": -1, + "delayedStopAt": -1, + "power": { + "setType": 1, + "actualType": 2, + "kw": 4.6, + "actualPower": 0, + "setPower": 5 + }, + "antifreeze": { + "temperature": 5, + "enable": true + }, + "statistic": { + "igniterStarts": 2, + "uptime": 86760, + "heatingTime": 4080, + "serviceTime": 4080, + "overheatings": 0, + "misfires": 0, + "fuelQuantityUsed": 0 + }, + "diagnostic": { + "parameters": [ + { + "id": 0, + "value": 25 + }, + { + "id": 1, + "value": 5 + }, + { + "id": 2, + "value": 0 + }, + { + "id": 3, + "value": 1 + }, + { + "id": 4, + "value": 100 + }, + { + "id": 5, + "value": 85 + }, + { + "id": 6, + "value": 20 + }, + { + "id": 7, + "value": 85 + }, + { + "id": 8, + "value": 25 + }, + { + "id": 9, + "value": 70 + }, + { + "id": 10, + "value": 25 + }, + { + "id": 11, + "value": 90 + }, + { + "id": 12, + "value": 22 + }, + { + "id": 13, + "value": 115 + }, + { + "id": 14, + "value": 31 + }, + { + "id": 15, + "value": 130 + }, + { + "id": 16, + "value": 39 + }, + { + "id": 17, + "value": 145 + }, + { + "id": 18, + "value": 50 + }, + { + "id": 19, + "value": 140 + }, + { + "id": 20, + "value": 200 + }, + { + "id": 21, + "value": 190 + }, + { + "id": 22, + "value": 215 + }, + { + "id": 23, + "value": 200 + }, + { + "id": 24, + "value": 125 + }, + { + "id": 25, + "value": 85 + }, + { + "id": 26, + "value": 108 + }, + { + "id": 27, + "value": 129 + }, + { + "id": 28, + "value": 150 + }, + { + "id": 29, + "value": 85 + }, + { + "id": 30, + "value": 85 + }, + { + "id": 31, + "value": 85 + }, + { + "id": 32, + "value": 85 + }, + { + "id": 33, + "value": 85 + }, + { + "id": 34, + "value": 85 + }, + { + "id": 35, + "value": 105 + }, + { + "id": 36, + "value": 125 + }, + { + "id": 37, + "value": 145 + }, + { + "id": 38, + "value": 165 + }, + { + "id": 39, + "value": 135 + }, + { + "id": 40, + "value": 120 + }, + { + "id": 41, + "value": 0 + }, + { + "id": 42, + "value": 0 + }, + { + "id": 43, + "value": 0 + }, + { + "id": 44, + "value": 0 + }, + { + "id": 45, + "value": 90 + }, + { + "id": 46, + "value": 105 + }, + { + "id": 47, + "value": 120 + }, + { + "id": 48, + "value": 135 + }, + { + "id": 49, + "value": 150 + }, + { + "id": 50, + "value": 5 + }, + { + "id": 51, + "value": 125 + }, + { + "id": 52, + "value": 6 + }, + { + "id": 53, + "value": 0 + }, + { + "id": 54, + "value": 40 + }, + { + "id": 55, + "value": 155 + }, + { + "id": 56, + "value": 35 + }, + { + "id": 57, + "value": 200 + }, + { + "id": 58, + "value": 50 + }, + { + "id": 59, + "value": 37 + }, + { + "id": 60, + "value": 45 + }, + { + "id": 61, + "value": 120 + }, + { + "id": 62, + "value": 230 + }, + { + "id": 63, + "value": 0 + }, + { + "id": 64, + "value": 0 + }, + { + "id": 65, + "value": 0 + }, + { + "id": 66, + "value": 0 + }, + { + "id": 67, + "value": 5 + }, + { + "id": 68, + "value": 40 + }, + { + "id": 69, + "value": 60 + }, + { + "id": 70, + "value": 125 + }, + { + "id": 71, + "value": 2 + }, + { + "id": 72, + "value": 4 + }, + { + "id": 73, + "value": 100 + }, + { + "id": 74, + "value": 100 + }, + { + "id": 75, + "value": 100 + }, + { + "id": 76, + "value": 1 + }, + { + "id": 77, + "value": 0 + }, + { + "id": 78, + "value": 0 + }, + { + "id": 79, + "value": 0 + }, + { + "id": 80, + "value": 0 + }, + { + "id": 81, + "value": 0 + }, + { + "id": 82, + "value": 200 + }, + { + "id": 83, + "value": 90 + }, + { + "id": 84, + "value": 70 + }, + { + "id": 85, + "value": 80 + }, + { + "id": 86, + "value": 90 + }, + { + "id": 87, + "value": 50 + }, + { + "id": 88, + "value": 100 + }, + { + "id": 89, + "value": 1 + }, + { + "id": 90, + "value": 0 + }, + { + "id": 91, + "value": 0 + }, + { + "id": 92, + "value": 2 + }, + { + "id": 93, + "value": 1 + }, + { + "id": 94, + "value": 90 + }, + { + "id": 95, + "value": 0 + }, + { + "id": 96, + "value": 4 + }, + { + "id": 97, + "value": 27 + }, + { + "id": 98, + "value": 0 + }, + { + "id": 99, + "value": 0 + }, + { + "id": 100, + "value": 0 + }, + { + "id": 101, + "value": 60 + }, + { + "id": 102, + "value": 50 + }, + { + "id": 103, + "value": 10 + }, + { + "id": 104, + "value": 5 + }, + { + "id": 105, + "value": 60 + } + ], + "variables": [ + { + "id": 0, + "value": 2 + }, + { + "id": 1, + "value": 17410 + }, + { + "id": 2, + "value": 68 + }, + { + "id": 3, + "value": 0 + }, + { + "id": 4, + "value": 108 + }, + { + "id": 5, + "value": 5 + }, + { + "id": 6, + "value": 0 + }, + { + "id": 7, + "value": 0 + }, + { + "id": 8, + "value": 0 + }, + { + "id": 9, + "value": 6 + }, + { + "id": 10, + "value": 0 + }, + { + "id": 11, + "value": 0 + }, + { + "id": 12, + "value": 0 + }, + { + "id": 13, + "value": 2 + }, + { + "id": 14, + "value": 6 + }, + { + "id": 15, + "value": 24 + }, + { + "id": 16, + "value": 8 + }, + { + "id": 17, + "value": 1 + }, + { + "id": 18, + "value": 8 + }, + { + "id": 19, + "value": 1 + }, + { + "id": 20, + "value": 0 + }, + { + "id": 21, + "value": 0 + }, + { + "id": 22, + "value": 207 + }, + { + "id": 23, + "value": 22 + }, + { + "id": 24, + "value": 8 + }, + { + "id": 25, + "value": 0 + }, + { + "id": 26, + "value": 2 + }, + { + "id": 27, + "value": 2 + }, + { + "id": 28, + "value": 1 + }, + { + "id": 29, + "value": 0 + }, + { + "id": 30, + "value": 1 + }, + { + "id": 31, + "value": 1 + }, + { + "id": 32, + "value": 0 + }, + { + "id": 33, + "value": 1 + }, + { + "id": 34, + "value": 1127 + }, + { + "id": 35, + "value": 675 + }, + { + "id": 36, + "value": 0 + }, + { + "id": 37, + "value": 241 + }, + { + "id": 38, + "value": 20000101 + }, + { + "id": 39, + "value": 8 + }, + { + "id": 40, + "value": 1 + }, + { + "id": 41, + "value": 241 + }, + { + "id": 42, + "value": 20000101 + }, + { + "id": 43, + "value": 10 + }, + { + "id": 44, + "value": 2 + }, + { + "id": 45, + "value": 241 + }, + { + "id": 46, + "value": 20000101 + }, + { + "id": 47, + "value": 2347 + }, + { + "id": 48, + "value": 3 + }, + { + "id": 49, + "value": 0 + }, + { + "id": 50, + "value": 0 + }, + { + "id": 51, + "value": 0 + }, + { + "id": 52, + "value": 4 + }, + { + "id": 53, + "value": 0 + }, + { + "id": 54, + "value": 0 + }, + { + "id": 55, + "value": 0 + }, + { + "id": 56, + "value": 5 + }, + { + "id": 57, + "value": 0 + }, + { + "id": 58, + "value": 0 + }, + { + "id": 59, + "value": 0 + }, + { + "id": 60, + "value": 6 + }, + { + "id": 61, + "value": 0 + }, + { + "id": 62, + "value": 0 + }, + { + "id": 63, + "value": 0 + }, + { + "id": 64, + "value": 7 + }, + { + "id": 65, + "value": 0 + }, + { + "id": 66, + "value": 0 + }, + { + "id": 67, + "value": 0 + }, + { + "id": 68, + "value": 8 + }, + { + "id": 69, + "value": 0 + }, + { + "id": 70, + "value": 0 + }, + { + "id": 71, + "value": 0 + }, + { + "id": 72, + "value": 9 + }, + { + "id": 73, + "value": 0 + }, + { + "id": 74, + "value": 0 + }, + { + "id": 75, + "value": 0 + }, + { + "id": 76, + "value": 10 + }, + { + "id": 77, + "value": 0 + }, + { + "id": 78, + "value": 0 + }, + { + "id": 79, + "value": 0 + }, + { + "id": 80, + "value": 11 + }, + { + "id": 81, + "value": 0 + }, + { + "id": 82, + "value": 0 + }, + { + "id": 83, + "value": 0 + }, + { + "id": 84, + "value": 12 + }, + { + "id": 85, + "value": 0 + }, + { + "id": 86, + "value": 0 + }, + { + "id": 87, + "value": 0 + }, + { + "id": 88, + "value": 13 + }, + { + "id": 89, + "value": 0 + }, + { + "id": 90, + "value": 0 + }, + { + "id": 91, + "value": 0 + }, + { + "id": 92, + "value": 14 + }, + { + "id": 93, + "value": 0 + }, + { + "id": 94, + "value": 0 + }, + { + "id": 95, + "value": 0 + }, + { + "id": 96, + "value": 211 + }, + { + "id": 97, + "value": 15 + }, + { + "id": 98, + "value": 0 + }, + { + "id": 99, + "value": 0 + } + ], + "timers": [ + { + "id": 0, + "value": 5 + }, + { + "id": 1, + "value": 45 + }, + { + "id": 2, + "value": 7 + }, + { + "id": 3, + "value": 30 + }, + { + "id": 4, + "value": 6 + }, + { + "id": 5, + "value": 15 + }, + { + "id": 6, + "value": 8 + }, + { + "id": 7, + "value": 15 + }, + { + "id": 8, + "value": 11 + }, + { + "id": 9, + "value": 30 + }, + { + "id": 10, + "value": 14 + }, + { + "id": 11, + "value": 0 + }, + { + "id": 12, + "value": 12 + }, + { + "id": 13, + "value": 0 + }, + { + "id": 14, + "value": 14 + }, + { + "id": 15, + "value": 30 + }, + { + "id": 16, + "value": 0 + }, + { + "id": 17, + "value": 0 + }, + { + "id": 18, + "value": 0 + }, + { + "id": 19, + "value": 0 + }, + { + "id": 20, + "value": 0 + }, + { + "id": 21, + "value": 0 + }, + { + "id": 22, + "value": 0 + }, + { + "id": 23, + "value": 0 + }, + { + "id": 24, + "value": 0 + }, + { + "id": 25, + "value": 0 + }, + { + "id": 26, + "value": 0 + }, + { + "id": 27, + "value": 0 + }, + { + "id": 28, + "value": 0 + }, + { + "id": 29, + "value": 0 + }, + { + "id": 30, + "value": 16 + }, + { + "id": 31, + "value": 0 + }, + { + "id": 32, + "value": 21 + }, + { + "id": 33, + "value": 0 + }, + { + "id": 34, + "value": 17 + }, + { + "id": 35, + "value": 30 + }, + { + "id": 36, + "value": 22 + }, + { + "id": 37, + "value": 30 + }, + { + "id": 38, + "value": 0 + }, + { + "id": 39, + "value": 0 + }, + { + "id": 40, + "value": 0 + }, + { + "id": 41, + "value": 0 + }, + { + "id": 42, + "value": 0 + }, + { + "id": 43, + "value": 0 + }, + { + "id": 44, + "value": 0 + }, + { + "id": 45, + "value": 160 + }, + { + "id": 46, + "value": 160 + }, + { + "id": 47, + "value": 160 + }, + { + "id": 48, + "value": 160 + }, + { + "id": 49, + "value": 160 + }, + { + "id": 50, + "value": 160 + } + ] + }, + "ecoMode": { + "ecoModeSetType": 1, + "ecoModeEnable": 0 + }, + "hybrid": { + "actualType": 1, + "operation": 0, + "state": 0 + }, + "fans": [ + { + "weight": 0, + "speedType": 0, + "speed": 0, + "id": 1 + } + ], + "fuels": [ + { + "name": null, + "quality": 2, + "qualityType": 0, + "qualityActual": null, + "quantitySetType": 2, + "quantityActualType": 2, + "quantity": 0.95, + "quantityDisplay": 1, + "id": 1 + } + ], + "temperatures": [ + { + "name": null, + "weight": 0, + "setType": 2, + "actualType": 1, + "onMainScreen": true, + "actual": 20.7, + "set": 25, + "id": 1 + }, + { + "name": null, + "weight": 0, + "setType": 0, + "actualType": 0, + "onMainScreen": false, + "actual": 0, + "set": 0, + "id": 2 + }, + { + "name": null, + "weight": 0, + "setType": 0, + "actualType": 0, + "onMainScreen": false, + "actual": 0, + "set": 0, + "id": 3 + }, + { + "name": null, + "weight": 0, + "setType": 0, + "actualType": 0, + "onMainScreen": false, + "actual": 0, + "set": 0, + "id": 4 + }, + { + "name": null, + "weight": 0, + "setType": 0, + "actualType": 0, + "onMainScreen": false, + "actual": 0, + "set": 0, + "id": 5 + }, + { + "name": null, + "weight": 0, + "setType": 0, + "actualType": 0, + "onMainScreen": false, + "actual": 0, + "set": 0, + "id": 6 + }, + { + "name": null, + "weight": 0, + "setType": 0, + "actualType": 10, + "onMainScreen": false, + "actual": 206, + "set": 0, + "id": 7 + }, + { + "name": null, + "weight": 0, + "setType": 1, + "actualType": 1, + "onMainScreen": false, + "actual": 0, + "set": 0.5, + "id": 8 + }, + { + "name": null, + "weight": 0, + "setType": 1, + "actualType": 1, + "onMainScreen": false, + "actual": 0, + "set": 0, + "id": 9 + } + ], + "timers": [] + } +} diff --git a/tests/components/fumis/snapshots/test_binary_sensor.ambr b/tests/components/fumis/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000000..dc7f23179fdf65 --- /dev/null +++ b/tests/components/fumis/snapshots/test_binary_sensor.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor][binary_sensor.clou_duo_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.clou_duo_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Door', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'fumis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor][binary_sensor.clou_duo_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Clou Duo Door', + }), + 'context': , + 'entity_id': 'binary_sensor.clou_duo_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/fumis/snapshots/test_button.ambr b/tests/components/fumis/snapshots/test_button.ambr new file mode 100644 index 00000000000000..56b9a91f1891e6 --- /dev/null +++ b/tests/components/fumis/snapshots/test_button.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_buttons[button][button.clou_duo_sync_clock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.clou_duo_sync_clock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sync clock', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sync clock', + 'platform': 'fumis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sync_clock', + 'unique_id': 'aa:bb:cc:dd:ee:ff_sync_clock', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button][button.clou_duo_sync_clock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Clou Duo Sync clock', + }), + 'context': , + 'entity_id': 'button.clou_duo_sync_clock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/fumis/snapshots/test_number.ambr b/tests/components/fumis/snapshots/test_number.ambr new file mode 100644 index 00000000000000..ba63b5145b9c11 --- /dev/null +++ b/tests/components/fumis/snapshots/test_number.ambr @@ -0,0 +1,119 @@ +# serializer version: 1 +# name: test_numbers[number][number.clou_duo_fan_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.clou_duo_fan_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Fan speed', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan speed', + 'platform': 'fumis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fan_speed', + 'unique_id': 'aa:bb:cc:dd:ee:ff_fan_speed', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[number][number.clou_duo_fan_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Clou Duo Fan speed', + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.clou_duo_fan_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_numbers[number][number.clou_duo_power_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.clou_duo_power_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power level', + 'platform': 'fumis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_level', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[number][number.clou_duo_power_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Clou Duo Power level', + 'max': 5, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.clou_duo_power_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- diff --git a/tests/components/fumis/snapshots/test_sensor.ambr b/tests/components/fumis/snapshots/test_sensor.ambr index 490a7dd7d77062..92515d535b84a3 100644 --- a/tests/components/fumis/snapshots/test_sensor.ambr +++ b/tests/components/fumis/snapshots/test_sensor.ambr @@ -1,4 +1,77 @@ # serializer version: 1 +# name: test_sensors[sensor][sensor.clou_duo_alert-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'low_fuel', + 'service_due', + 'flue_gas_warning', + 'low_battery', + 'speed_sensor_failure', + 'door_open', + 'airflow_malfunction', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.clou_duo_alert', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Alert', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alert', + 'platform': 'fumis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alert', + 'unique_id': 'aa:bb:cc:dd:ee:ff_alert', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor][sensor.clou_duo_alert-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'code': None, + 'device_class': 'enum', + 'friendly_name': 'Clou Duo Alert', + 'options': list([ + 'none', + 'low_fuel', + 'service_due', + 'flue_gas_warning', + 'low_battery', + 'speed_sensor_failure', + 'door_open', + 'airflow_malfunction', + ]), + }), + 'context': , + 'entity_id': 'sensor.clou_duo_alert', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- # name: test_sensors[sensor][sensor.clou_duo_burning_time-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -255,6 +328,113 @@ 'state': 'combustion', }) # --- +# name: test_sensors[sensor][sensor.clou_duo_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'ignition_failed', + 'chimney_dirty', + 'sensor_t02', + 'sensor_t03_t05', + 'sensor_t04', + 'safety_switch', + 'pressure_sensor_off', + 'sensor_t01_t02', + 'sensor_t01_t03', + 'flue_gas_overtemp', + 'fuel_ignition_timeout', + 'general_error', + 'mfdoor_alarm', + 'fire_error', + 'chimney_alarm', + 'grate_error', + 'ntc2_alarm', + 'ntc3_alarm', + 'door_alarm', + 'pressure_alarm', + 'ntc1_alarm', + 'tc1_alarm', + 'gas_alarm', + 'no_pellet_alarm', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.clou_duo_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Error', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Error', + 'platform': 'fumis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'error', + 'unique_id': 'aa:bb:cc:dd:ee:ff_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor][sensor.clou_duo_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'code': None, + 'device_class': 'enum', + 'friendly_name': 'Clou Duo Error', + 'options': list([ + 'none', + 'ignition_failed', + 'chimney_dirty', + 'sensor_t02', + 'sensor_t03_t05', + 'sensor_t04', + 'safety_switch', + 'pressure_sensor_off', + 'sensor_t01_t02', + 'sensor_t01_t03', + 'flue_gas_overtemp', + 'fuel_ignition_timeout', + 'general_error', + 'mfdoor_alarm', + 'fire_error', + 'chimney_alarm', + 'grate_error', + 'ntc2_alarm', + 'ntc3_alarm', + 'door_alarm', + 'pressure_alarm', + 'ntc1_alarm', + 'tc1_alarm', + 'gas_alarm', + 'no_pellet_alarm', + ]), + }), + 'context': , + 'entity_id': 'sensor.clou_duo_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- # name: test_sensors[sensor][sensor.clou_duo_fan_1_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1089,3 +1269,70 @@ 'state': '27.0', }) # --- +# name: test_sensors_active_error_and_alert[info_error_alert-sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'code': 'E101', + 'device_class': 'enum', + 'friendly_name': 'Clou Duo Error', + 'options': list([ + 'none', + 'ignition_failed', + 'chimney_dirty', + 'sensor_t02', + 'sensor_t03_t05', + 'sensor_t04', + 'safety_switch', + 'pressure_sensor_off', + 'sensor_t01_t02', + 'sensor_t01_t03', + 'flue_gas_overtemp', + 'fuel_ignition_timeout', + 'general_error', + 'mfdoor_alarm', + 'fire_error', + 'chimney_alarm', + 'grate_error', + 'ntc2_alarm', + 'ntc3_alarm', + 'door_alarm', + 'pressure_alarm', + 'ntc1_alarm', + 'tc1_alarm', + 'gas_alarm', + 'no_pellet_alarm', + ]), + }), + 'context': , + 'entity_id': 'sensor.clou_duo_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ignition_failed', + }) +# --- +# name: test_sensors_active_error_and_alert[info_error_alert-sensor].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'code': 'A001', + 'device_class': 'enum', + 'friendly_name': 'Clou Duo Alert', + 'options': list([ + 'none', + 'low_fuel', + 'service_due', + 'flue_gas_warning', + 'low_battery', + 'speed_sensor_failure', + 'door_open', + 'airflow_malfunction', + ]), + }), + 'context': , + 'entity_id': 'sensor.clou_duo_alert', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low_fuel', + }) +# --- diff --git a/tests/components/fumis/test_binary_sensor.py b/tests/components/fumis/test_binary_sensor.py new file mode 100644 index 00000000000000..d16a5c36e289af --- /dev/null +++ b/tests/components/fumis/test_binary_sensor.py @@ -0,0 +1,42 @@ +"""Tests for the Fumis binary sensor entities.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .const import UNIQUE_ID + +from tests.common import MockConfigEntry, snapshot_platform + +pytestmark = pytest.mark.parametrize( + "init_integration", [Platform.BINARY_SENSOR], indirect=True +) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_binary_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Fumis binary sensor entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize("device_fixture", ["info_minimal"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_binary_sensors_conditional_creation( + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test door binary sensor is not created when data is missing.""" + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + unique_ids = {entry.unique_id for entry in entity_entries} + + assert f"{UNIQUE_ID}_door" not in unique_ids diff --git a/tests/components/fumis/test_button.py b/tests/components/fumis/test_button.py new file mode 100644 index 00000000000000..5d8151cbba7974 --- /dev/null +++ b/tests/components/fumis/test_button.py @@ -0,0 +1,67 @@ +"""Tests for the Fumis button entities.""" + +from unittest.mock import MagicMock + +from fumis import FumisConnectionError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.fumis.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +pytestmark = pytest.mark.parametrize( + "init_integration", [Platform.BUTTON], indirect=True +) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_buttons( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Fumis button entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_sync_clock( + hass: HomeAssistant, + mock_fumis: MagicMock, +) -> None: + """Test pressing the sync clock button.""" + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.clou_duo_sync_clock"}, + blocking=True, + ) + + mock_fumis.set_clock.assert_called_once() + + +@pytest.mark.usefixtures("init_integration") +async def test_sync_clock_error_handling( + hass: HomeAssistant, + mock_fumis: MagicMock, +) -> None: + """Test error handling for button press.""" + mock_fumis.set_clock.side_effect = FumisConnectionError + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.clou_duo_sync_clock"}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "communication_error" diff --git a/tests/components/fumis/test_number.py b/tests/components/fumis/test_number.py new file mode 100644 index 00000000000000..8e642a70f6e403 --- /dev/null +++ b/tests/components/fumis/test_number.py @@ -0,0 +1,126 @@ +"""Tests for the Fumis number entities.""" + +from unittest.mock import MagicMock + +from fumis import FumisConnectionError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.fumis.const import DOMAIN +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .const import UNIQUE_ID + +from tests.common import MockConfigEntry, snapshot_platform + +pytestmark = pytest.mark.parametrize( + "init_integration", [Platform.NUMBER], indirect=True +) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_numbers( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Fumis number entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_set_power_level( + hass: HomeAssistant, + mock_fumis: MagicMock, +) -> None: + """Test setting the power level.""" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.clou_duo_power_level", ATTR_VALUE: 3}, + blocking=True, + ) + + mock_fumis.set_power.assert_called_once_with(3) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_set_fan_speed( + hass: HomeAssistant, + mock_fumis: MagicMock, +) -> None: + """Test setting the fan speed.""" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.clou_duo_fan_speed", ATTR_VALUE: 2}, + blocking=True, + ) + + mock_fumis.set_fan_speed.assert_called_once_with(2) + + +@pytest.mark.usefixtures("init_integration") +async def test_number_error_handling( + hass: HomeAssistant, + mock_fumis: MagicMock, +) -> None: + """Test error handling for number actions.""" + mock_fumis.set_power.side_effect = FumisConnectionError + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.clou_duo_power_level", ATTR_VALUE: 3}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "communication_error" + + +@pytest.mark.parametrize( + "unique_id", + [ + f"{UNIQUE_ID}_fan_speed", + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_numbers_disabled_by_default( + entity_registry: er.EntityRegistry, + unique_id: str, +) -> None: + """Test number entities that are disabled by default.""" + entry = entity_registry.async_get_entity_id("number", "fumis", unique_id) + assert entry is not None, f"Entity with unique_id {unique_id} not found" + assert (entity_entry := entity_registry.async_get(entry)) + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.parametrize("device_fixture", ["info_minimal"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_numbers_conditional_creation( + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test fan_speed number is not created when data is missing.""" + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + unique_ids = {entry.unique_id for entry in entity_entries} + + # Fan speed should NOT exist with the minimal fixture + assert f"{UNIQUE_ID}_fan_speed" not in unique_ids + + # Power level should still exist + assert f"{UNIQUE_ID}_power_level" in unique_ids diff --git a/tests/components/fumis/test_sensor.py b/tests/components/fumis/test_sensor.py index 3121259db71fd5..a24b383edc32aa 100644 --- a/tests/components/fumis/test_sensor.py +++ b/tests/components/fumis/test_sensor.py @@ -7,9 +7,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from .const import UNIQUE_ID -UNIQUE_ID_PREFIX = "aa:bb:cc:dd:ee:ff" +from tests.common import MockConfigEntry, snapshot_platform pytestmark = pytest.mark.parametrize( "init_integration", [Platform.SENSOR], indirect=True @@ -31,11 +31,11 @@ async def test_sensors( @pytest.mark.parametrize( "unique_id", [ - f"{UNIQUE_ID_PREFIX}_fan_1_speed", - f"{UNIQUE_ID_PREFIX}_fan_2_speed", - f"{UNIQUE_ID_PREFIX}_module_temperature", - f"{UNIQUE_ID_PREFIX}_pressure", - f"{UNIQUE_ID_PREFIX}_wifi_rssi", + f"{UNIQUE_ID}_fan_1_speed", + f"{UNIQUE_ID}_fan_2_speed", + f"{UNIQUE_ID}_module_temperature", + f"{UNIQUE_ID}_pressure", + f"{UNIQUE_ID}_wifi_rssi", ], ) @pytest.mark.usefixtures("init_integration") @@ -59,13 +59,40 @@ async def test_sensors_unknown_status( """Test sensor returns unknown when stove status is unmapped.""" for key in ("stove_status", "detailed_stove_status"): entry = entity_registry.async_get_entity_id( - "sensor", "fumis", f"{UNIQUE_ID_PREFIX}_{key}" + "sensor", "fumis", f"{UNIQUE_ID}_{key}" ) assert entry is not None assert (state := hass.states.get(entry)) assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize("device_fixture", ["info_error_alert"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_sensors_active_error_and_alert( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test error and alert sensors with active codes.""" + error_entity_id = entity_registry.async_get_entity_id( + "sensor", "fumis", f"{UNIQUE_ID}_error" + ) + assert error_entity_id is not None + assert (state := hass.states.get(error_entity_id)) + assert state == snapshot + assert state.state == "ignition_failed" + assert state.attributes["code"] == "E101" + + alert_entity_id = entity_registry.async_get_entity_id( + "sensor", "fumis", f"{UNIQUE_ID}_alert" + ) + assert alert_entity_id is not None + assert (state := hass.states.get(alert_entity_id)) + assert state == snapshot + assert state.state == "low_fuel" + assert state.attributes["code"] == "A001" + + @pytest.mark.parametrize("device_fixture", ["info_minimal"]) @pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") async def test_sensors_conditional_creation( @@ -89,14 +116,16 @@ async def test_sensors_conditional_creation( "temperature", "time_to_service", ): - assert f"{UNIQUE_ID_PREFIX}_{key}" not in unique_ids, key + assert f"{UNIQUE_ID}_{key}" not in unique_ids, key # These should still exist for key in ( + "alert", "detailed_stove_status", + "error", "power_output", "stove_status", "wifi_rssi", "wifi_signal_strength", ): - assert f"{UNIQUE_ID_PREFIX}_{key}" in unique_ids, key + assert f"{UNIQUE_ID}_{key}" in unique_ids, key diff --git a/tests/components/garage_door/test_condition.py b/tests/components/garage_door/test_condition.py index f85aa719f16236..e89d83cfc409e2 100644 --- a/tests/components/garage_door/test_condition.py +++ b/tests/components/garage_door/test_condition.py @@ -13,6 +13,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -47,6 +48,31 @@ async def test_garage_door_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("garage_door.is_closed", {}, True, True), + ("garage_door.is_open", {}, True, True), + ], +) +async def test_garage_door_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that garage_door conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + # --- binary_sensor tests --- @@ -348,7 +374,7 @@ async def test_garage_door_condition_excludes_non_garage_door_device_class( ) # Matching entities in matching state - condition should be True - assert condition_any(hass) is True + assert condition_any.async_check() is True # Set matching entities to non-matching state hass.states.async_set( @@ -364,4 +390,4 @@ async def test_garage_door_condition_excludes_non_garage_door_device_class( await hass.async_block_till_done() # Wrong device class entities still in matching state, but should be excluded - assert condition_any(hass) is False + assert condition_any.async_check() is False diff --git a/tests/components/gardena_bluetooth/test_config_flow.py b/tests/components/gardena_bluetooth/test_config_flow.py index 6b1dfa2eaba8da..bf1c9f88bb1852 100644 --- a/tests/components/gardena_bluetooth/test_config_flow.py +++ b/tests/components/gardena_bluetooth/test_config_flow.py @@ -146,6 +146,12 @@ async def test_timeout_manufacturer_data( inject_bluetooth_service_info(hass, MISSING_PRODUCT_SERVICE_INFO) + # The injected advertisement starts a bluetooth discovery flow which also + # calls async_get_manufacturer_data. Drain it first so it doesn't race + # with the user flow's own request. + await manufacturer_request_event.wait() + await scan_step() + await hass.async_block_till_done(wait_background_tasks=True) manufacturer_request_event.clear() async with asyncio.TaskGroup() as tg: diff --git a/tests/components/gate/test_condition.py b/tests/components/gate/test_condition.py index 85d072fca715cf..e128ba2cb4a2ea 100644 --- a/tests/components/gate/test_condition.py +++ b/tests/components/gate/test_condition.py @@ -13,6 +13,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -41,6 +42,31 @@ async def test_gate_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("gate.is_closed", {}, True, True), + ("gate.is_open", {}, True, True), + ], +) +async def test_gate_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that gate conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), @@ -221,7 +247,7 @@ async def test_gate_condition_excludes_non_gate_device_class( ) # Matching entity in matching state - condition should be True - assert condition_any(hass) is True + assert condition_any.async_check() is True # Set matching entity to non-matching state hass.states.async_set( @@ -232,4 +258,4 @@ async def test_gate_condition_excludes_non_gate_device_class( await hass.async_block_till_done() # Wrong device class entity still in matching state, but should be excluded - assert condition_any(hass) is False + assert condition_any.async_check() is False diff --git a/tests/components/go2rtc/__init__.py b/tests/components/go2rtc/__init__.py index 26a8c467c0db51..c7c07be1f77ca4 100644 --- a/tests/components/go2rtc/__init__.py +++ b/tests/components/go2rtc/__init__.py @@ -9,10 +9,11 @@ class MockCamera(Camera): _attr_name = "Test" _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM - def __init__(self) -> None: + def __init__(self, unique_id: str | None) -> None: """Initialize the mock entity.""" super().__init__() self._stream_source: str | None = "rtsp://stream" + self._attr_unique_id = unique_id def set_stream_source(self, stream_source: str | None) -> None: """Set the stream source.""" diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index 3186a0536f4506..12292a75221d60 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -185,10 +185,17 @@ def integration_config_entry(hass: HomeAssistant) -> ConfigEntry: return entry +@pytest.fixture +def camera_unique_id() -> str | None: + """Camera unique ID.""" + return "camera_unique_id" + + @pytest.fixture async def init_test_integration( hass: HomeAssistant, integration_config_entry: ConfigEntry, + camera_unique_id: str | None, ) -> MockCamera: """Initialize components.""" @@ -218,7 +225,7 @@ async def async_unload_entry_init( async_unload_entry=async_unload_entry_init, ), ) - test_camera = MockCamera() + test_camera = MockCamera(camera_unique_id) setup_test_component_platform( hass, CAMERA_DOMAIN, [test_camera], from_config_entry=True ) diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 63c9b31ef56ef2..7a5a37e60222d3 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -42,7 +42,10 @@ DOMAIN, RECOMMENDED_VERSION, ) -from homeassistant.components.go2rtc.util import get_go2rtc_unix_socket_path +from homeassistant.components.go2rtc.util import ( + get_camera_identifier, + get_go2rtc_unix_socket_path, +) from homeassistant.components.stream import Orientation from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME @@ -87,7 +90,7 @@ async def _test_setup_and_signaling( camera: MockCamera, ) -> None: """Test the go2rtc config entry.""" - entity_id = camera.entity_id + identifier = get_camera_identifier(camera) assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS} assert await async_setup_component(hass, DOMAIN, config) @@ -124,17 +127,17 @@ async def test(session: str) -> None: await test("sesion_1") rest_client.streams.add.assert_called_once_with( - entity_id, + identifier, [ "rtsp://stream", - f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + f"ffmpeg:{identifier}#audio=opus#query=log_level=debug", ], ) # Stream exists but the source is different rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - entity_id: Stream([Producer("rtsp://different")]) + identifier: Stream([Producer("rtsp://different")]) } receive_message_callback.reset_mock() @@ -142,17 +145,17 @@ async def test(session: str) -> None: await test("session_2") rest_client.streams.add.assert_called_once_with( - entity_id, + identifier, [ "rtsp://stream", - f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + f"ffmpeg:{identifier}#audio=opus#query=log_level=debug", ], ) # If the stream is already added, the stream should not be added again. rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - entity_id: Stream([Producer("rtsp://stream")]) + identifier: Stream([Producer("rtsp://stream")]) } receive_message_callback.reset_mock() @@ -192,6 +195,14 @@ async def test(session: str) -> None: "mock_is_docker_env", "mock_go2rtc_entry", ) +@pytest.mark.parametrize( + "camera_unique_id", + [ + "camera_unique_id", + None, + ], + indirect=True, +) @pytest.mark.parametrize( ("config", "ui_enabled", "expected_username", "expected_password"), [ @@ -283,6 +294,14 @@ def after_setup() -> None: @pytest.mark.usefixtures("mock_go2rtc_entry") +@pytest.mark.parametrize( + "camera_unique_id", + [ + "camera_unique_id", + None, + ], + indirect=True, +) @pytest.mark.parametrize( ("go2rtc_binary", "is_docker_env"), [ @@ -816,13 +835,14 @@ async def test_generic_workaround( image = await async_get_image(hass, camera.entity_id) assert image.content == image_bytes - rest_client.streams.add.assert_called_once_with( - camera.entity_id, - [ - "ffmpeg:https://my_stream_url.m3u8", - f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", - ], - ) + identifier = get_camera_identifier(camera) + rest_client.streams.add.assert_called_once_with( + identifier, + [ + "ffmpeg:https://my_stream_url.m3u8", + f"ffmpeg:{identifier}#audio=opus#query=log_level=debug", + ], + ) async def _test_camera_orientation( @@ -849,11 +869,12 @@ async def _test_camera_orientation( await camera_fn(hass, camera) # Verify the stream was configured correctly + identifier = get_camera_identifier(camera) rest_client.streams.add.assert_called_once_with( - camera.entity_id, + identifier, [ expected_stream_source, - f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + f"ffmpeg:{identifier}#audio=opus#query=log_level=debug", ], ) diff --git a/tests/components/go2rtc/test_util.py b/tests/components/go2rtc/test_util.py new file mode 100644 index 00000000000000..98b8089a07d450 --- /dev/null +++ b/tests/components/go2rtc/test_util.py @@ -0,0 +1,44 @@ +"""Go2rtc utility function tests.""" + +from unittest.mock import Mock + +import pytest + +from homeassistant.components.camera import Camera +from homeassistant.components.go2rtc.util import get_camera_identifier + + +@pytest.mark.parametrize( + ("unique_id", "entity_id", "expected"), + [ + # Prefer unique_id over entity_id + ("unique123", "camera.test", "test_unique123"), + # Fall back to entity_id when unique_id is None + (None, "camera.test", "camera.test"), + # Safe characters pass through + ("abc-def_ghi.123", "camera.test", "test_abc-def_ghi.123"), + # Special characters are percent-encoded + ("cam#1", "camera.test", "test_cam%231"), + ("cam:1", "camera.test", "test_cam%3A1"), + ("cam/1", "camera.test", "test_cam%2F1"), + ("cam?1", "camera.test", "test_cam%3F1"), + ("cam&1", "camera.test", "test_cam%261"), + ("cam=1", "camera.test", "test_cam%3D1"), + ("cam%1", "camera.test", "test_cam%251"), + ("cam 1", "camera.test", "test_cam%201"), + ("cam@1", "camera.test", "test_cam%401"), + ("cam_1", "camera.test", "test_cam_1"), + ("cam%231", "camera.test", "test_cam%25231"), + # Non-ASCII: UTF-8 byte-wise encoding (€ = E2 82 AC) + ("cam€1", "camera.test", "test_cam%E2%82%AC1"), + ], +) +def test_get_camera_identifier( + unique_id: str | None, entity_id: str, expected: str +) -> None: + """Test get_camera_identifier sanitizes and prefers unique_id.""" + camera = Mock(spec_set=Camera) + camera.platform.platform_name = "test" + camera.unique_id = unique_id + camera.entity_id = entity_id + assert get_camera_identifier(camera) == expected diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index a75eb9edcda50e..b868ae159843a7 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -361,6 +361,8 @@ async def _setup_func() -> bool: ClientCredential("client-id", "client-secret"), ) config_entry.add_to_hass(hass) - return await hass.config_entries.async_setup(config_entry.entry_id) + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + return result return _setup_func diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index c2568159c79905..2c9ec32bcd7664 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -73,15 +73,15 @@ 'identifiers': set({ tuple( 'google_generative_ai_conversation', - 'ulid-ai-task', + 'ulid-tts', ), }), 'labels': set({ }), 'manufacturer': 'Google', - 'model': 'gemini-2.5-flash', + 'model': 'gemini-2.5-flash-preview-tts', 'model_id': None, - 'name': 'Google AI Task', + 'name': 'Google AI TTS', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, @@ -102,15 +102,15 @@ 'identifiers': set({ tuple( 'google_generative_ai_conversation', - 'ulid-tts', + 'ulid-ai-task', ), }), 'labels': set({ }), 'manufacturer': 'Google', - 'model': 'gemini-2.5-flash-preview-tts', + 'model': 'gemini-2.5-flash', 'model_id': None, - 'name': 'Google AI TTS', + 'name': 'Google AI Task', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, @@ -119,79 +119,3 @@ }), ]) # --- -# name: test_generate_content_file_processing_succeeds - list([ - tuple( - '', - tuple( - ), - dict({ - 'contents': list([ - 'Describe this image from my doorbell camera', - File( - name='doorbell_snapshot.jpg', - state= - ), - File( - name='context.txt', - state= - ), - ]), - 'model': 'models/gemini-2.5-flash', - }), - ), - ]) -# --- -# name: test_generate_content_service_with_image - list([ - tuple( - '', - tuple( - ), - dict({ - 'contents': list([ - 'Describe this image from my doorbell camera', - File( - name='doorbell_snapshot.jpg', - state= - ), - File( - name='context.txt', - state= - ), - ]), - 'model': 'models/gemini-2.5-flash', - }), - ), - ]) -# --- -# name: test_generate_content_service_without_images - list([ - tuple( - '', - tuple( - ), - dict({ - 'contents': list([ - 'Write an opening speech for a Home Assistant release party', - ]), - 'model': 'models/gemini-2.5-flash', - }), - ), - ]) -# --- -# name: test_load_entry_with_unloaded_entries - list([ - tuple( - '', - tuple( - ), - dict({ - 'contents': list([ - 'Write an opening speech for a Home Assistant release party', - ]), - 'model': 'models/gemini-2.5-flash', - }), - ), - ]) -# --- diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 08e4be8005a4fe..add52edfa74311 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -13,6 +13,7 @@ AssistantContent, ToolResultContent, UserContent, + trace, ) from homeassistant.components.google_generative_ai_conversation.entity import ( ERROR_GETTING_RESPONSE, @@ -795,3 +796,72 @@ async def test_history_always_user_first_turn( == "Garage door left open, do you want to close it?" ) assert actual_history[1].role == "model" + + +@pytest.mark.usefixtures("mock_init_component") +async def test_token_stats_reported( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_chat_log: MockChatLog, # noqa: F811 + mock_send_message_stream: AsyncMock, +) -> None: + """Test that token stats are reported to the chat log.""" + trace.async_clear_traces() + + agent_id = "conversation.google_ai_conversation" + context = Context() + + messages = [ + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [{"text": "Hello! "}], + "role": "model", + }, + } + ], + ), + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [{"text": "How can I help you?"}], + "role": "model", + }, + "finish_reason": "STOP", + } + ], + usage_metadata={ + "prompt_token_count": 10, + "candidates_token_count": 20, + "cached_content_token_count": 5, + }, + ), + ], + ] + + mock_send_message_stream.return_value = messages + + await conversation.async_converse( + hass, + "Hello", + mock_chat_log.conversation_id, + context, + agent_id=agent_id, + ) + + traces = trace.async_get_traces() + trace_obj = next(iter(traces)) + events = trace_obj.as_dict().get("events", []) + stats = next( + e["data"]["stats"] + for e in events + if e.get("event_type") == "agent_detail" and e.get("data", {}).get("stats") + ) + assert stats == { + "input_tokens": 10, + "cached_input_tokens": 5, + "output_tokens": 20, + } diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 8098eed7f15bd2..34b44ddc9b0a33 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -1,9 +1,8 @@ """Tests for the Google Generative AI Conversation integration.""" from typing import Any -from unittest.mock import AsyncMock, Mock, mock_open, patch +from unittest.mock import AsyncMock, patch -from google.genai.types import File, FileState import pytest from requests.exceptions import Timeout from syrupy.assertion import SnapshotAssertion @@ -27,7 +26,6 @@ ) from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryDisabler from homeassistant.helpers.entity_registry import RegistryEntryDisabler @@ -37,299 +35,6 @@ from tests.common import MockConfigEntry -@pytest.mark.usefixtures("mock_init_component") -async def test_generate_content_service_without_images( - hass: HomeAssistant, snapshot: SnapshotAssertion -) -> None: - """Test generate content service.""" - stubbed_generated_content = ( - "I'm thrilled to welcome you all to the release " - "party for the latest version of Home Assistant!" - ) - - with patch( - "google.genai.models.AsyncModels.generate_content", - return_value=Mock( - text=stubbed_generated_content, - prompt_feedback=None, - candidates=[Mock()], - ), - ) as mock_generate: - response = await hass.services.async_call( - "google_generative_ai_conversation", - "generate_content", - {"prompt": "Write an opening speech for a Home Assistant release party"}, - blocking=True, - return_response=True, - ) - - assert response == { - "text": stubbed_generated_content, - } - assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot - - -@pytest.mark.usefixtures("mock_init_component") -async def test_generate_content_service_with_image( - hass: HomeAssistant, snapshot: SnapshotAssertion -) -> None: - """Test generate content service.""" - stubbed_generated_content = ( - "A mail carrier is at your front door delivering a package" - ) - - with ( - patch( - "google.genai.models.AsyncModels.generate_content", - return_value=Mock( - text=stubbed_generated_content, - prompt_feedback=None, - candidates=[Mock()], - ), - ) as mock_generate, - patch( - "google.genai.files.Files.upload", - side_effect=[ - File(name="doorbell_snapshot.jpg", state=FileState.ACTIVE), - File(name="context.txt", state=FileState.ACTIVE), - ], - ), - patch("pathlib.Path.exists", return_value=True), - patch.object(hass.config, "is_allowed_path", return_value=True), - patch("mimetypes.guess_type", return_value=["image/jpeg"]), - ): - response = await hass.services.async_call( - "google_generative_ai_conversation", - "generate_content", - { - "prompt": "Describe this image from my doorbell camera", - "filenames": ["doorbell_snapshot.jpg", "context.txt"], - }, - blocking=True, - return_response=True, - ) - - assert response == { - "text": stubbed_generated_content, - } - assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot - - -@pytest.mark.usefixtures("mock_init_component") -async def test_generate_content_file_processing_succeeds( - hass: HomeAssistant, snapshot: SnapshotAssertion -) -> None: - """Test generate content service.""" - stubbed_generated_content = ( - "A mail carrier is at your front door delivering a package" - ) - - with ( - patch( - "google.genai.models.AsyncModels.generate_content", - return_value=Mock( - text=stubbed_generated_content, - prompt_feedback=None, - candidates=[Mock()], - ), - ) as mock_generate, - patch("pathlib.Path.exists", return_value=True), - patch.object(hass.config, "is_allowed_path", return_value=True), - patch("builtins.open", mock_open(read_data="this is an image")), - patch("mimetypes.guess_type", return_value=["image/jpeg"]), - patch( - "google.genai.files.Files.upload", - side_effect=[ - File(name="doorbell_snapshot.jpg", state=FileState.ACTIVE), - File(name="context.txt", state=FileState.PROCESSING), - ], - ), - patch( - "google.genai.files.AsyncFiles.get", - side_effect=[ - File(name="context.txt", state=FileState.PROCESSING), - File(name="context.txt", state=FileState.ACTIVE), - ], - ), - ): - response = await hass.services.async_call( - "google_generative_ai_conversation", - "generate_content", - { - "prompt": "Describe this image from my doorbell camera", - "filenames": ["doorbell_snapshot.jpg", "context.txt"], - }, - blocking=True, - return_response=True, - ) - - assert response == { - "text": stubbed_generated_content, - } - assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot - - -@pytest.mark.usefixtures("mock_init_component") -async def test_generate_content_file_processing_fails( - hass: HomeAssistant, snapshot: SnapshotAssertion -) -> None: - """Test generate content service.""" - stubbed_generated_content = ( - "A mail carrier is at your front door delivering a package" - ) - - with ( - patch( - "google.genai.models.AsyncModels.generate_content", - return_value=Mock( - text=stubbed_generated_content, - prompt_feedback=None, - candidates=[Mock()], - ), - ), - patch("pathlib.Path.exists", return_value=True), - patch.object(hass.config, "is_allowed_path", return_value=True), - patch("builtins.open", mock_open(read_data="this is an image")), - patch("mimetypes.guess_type", return_value=["image/jpeg"]), - patch( - "google.genai.files.Files.upload", - side_effect=[ - File(name="doorbell_snapshot.jpg", state=FileState.ACTIVE), - File(name="context.txt", state=FileState.PROCESSING), - ], - ), - patch( - "google.genai.files.AsyncFiles.get", - side_effect=[ - File(name="context.txt", state=FileState.PROCESSING), - File( - name="context.txt", - state=FileState.FAILED, - error={"message": "File processing failed"}, - ), - ], - ), - pytest.raises( - HomeAssistantError, - match="File `context.txt` processing failed, reason: File processing failed", - ), - ): - await hass.services.async_call( - "google_generative_ai_conversation", - "generate_content", - { - "prompt": "Describe this image from my doorbell camera", - "filenames": ["doorbell_snapshot.jpg", "context.txt"], - }, - blocking=True, - return_response=True, - ) - - -@pytest.mark.usefixtures("mock_init_component") -async def test_generate_content_service_error( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test generate content service handles errors.""" - with ( - patch( - "google.genai.models.AsyncModels.generate_content", - side_effect=API_ERROR_500, - ), - pytest.raises( - HomeAssistantError, - match="Error generating content: 500 internal-error. {'message': 'Internal Server Error', 'status': 'internal-error'}", - ), - ): - await hass.services.async_call( - "google_generative_ai_conversation", - "generate_content", - {"prompt": "write a story about an epic fail"}, - blocking=True, - return_response=True, - ) - - -@pytest.mark.usefixtures("mock_init_component") -async def test_generate_content_response_has_empty_parts( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test generate content service handles response with empty parts.""" - with ( - patch( - "google.genai.models.AsyncModels.generate_content", - return_value=Mock( - prompt_feedback=None, - candidates=[Mock(content=Mock(parts=[]))], - ), - ), - pytest.raises(HomeAssistantError, match="Unknown error generating content"), - ): - await hass.services.async_call( - "google_generative_ai_conversation", - "generate_content", - {"prompt": "write a story about an epic fail"}, - blocking=True, - return_response=True, - ) - - -@pytest.mark.usefixtures("mock_init_component") -async def test_generate_content_service_with_image_not_allowed_path( - hass: HomeAssistant, -) -> None: - """Test generate content service with an image in a not allowed path.""" - with ( - patch("pathlib.Path.exists", return_value=True), - patch.object(hass.config, "is_allowed_path", return_value=False), - pytest.raises( - HomeAssistantError, - match=( - "Cannot read `doorbell_snapshot.jpg`, no access to path; " - "`allowlist_external_dirs` may need to be adjusted in " - "`configuration.yaml`" - ), - ), - ): - await hass.services.async_call( - "google_generative_ai_conversation", - "generate_content", - { - "prompt": "Describe this image from my doorbell camera", - "filenames": "doorbell_snapshot.jpg", - }, - blocking=True, - return_response=True, - ) - - -@pytest.mark.usefixtures("mock_init_component") -async def test_generate_content_service_with_image_not_exists( - hass: HomeAssistant, -) -> None: - """Test generate content service with an image that does not exist.""" - with ( - patch("pathlib.Path.exists", return_value=True), - patch.object(hass.config, "is_allowed_path", return_value=True), - patch("pathlib.Path.exists", return_value=False), - pytest.raises( - HomeAssistantError, match="`doorbell_snapshot.jpg` does not exist" - ), - ): - await hass.services.async_call( - "google_generative_ai_conversation", - "generate_content", - { - "prompt": "Describe this image from my doorbell camera", - "filenames": "doorbell_snapshot.jpg", - }, - blocking=True, - return_response=True, - ) - - @pytest.mark.parametrize( ("side_effect", "state", "reauth"), [ @@ -363,55 +68,6 @@ async def test_config_entry_error( assert any(mock_config_entry.async_get_active_flows(hass, {"reauth"})) == reauth -@pytest.mark.usefixtures("mock_init_component") -async def test_load_entry_with_unloaded_entries( - hass: HomeAssistant, snapshot: SnapshotAssertion -) -> None: - """Test loading an entry with unloaded entries.""" - config_entries = hass.config_entries.async_entries( - "google_generative_ai_conversation" - ) - runtime_data = config_entries[0].runtime_data - await hass.config_entries.async_unload(config_entries[0].entry_id) - - entry = MockConfigEntry( - domain="google_generative_ai_conversation", - title="Google Generative AI Conversation", - data={ - "api_key": "bla", - }, - state=ConfigEntryState.LOADED, - ) - entry.runtime_data = runtime_data - entry.add_to_hass(hass) - - stubbed_generated_content = ( - "I'm thrilled to welcome you all to the release " - "party for the latest version of Home Assistant!" - ) - - with patch( - "google.genai.models.AsyncModels.generate_content", - return_value=Mock( - text=stubbed_generated_content, - prompt_feedback=None, - candidates=[Mock()], - ), - ) as mock_generate: - response = await hass.services.async_call( - "google_generative_ai_conversation", - "generate_content", - {"prompt": "Write an opening speech for a Home Assistant release party"}, - blocking=True, - return_response=True, - ) - - assert response == { - "text": stubbed_generated_content, - } - assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot - - async def test_migration_from_v1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -1317,8 +973,9 @@ async def test_devices( snapshot: SnapshotAssertion, ) -> None: """Assert that devices are created correctly.""" - devices = dr.async_entries_for_config_entry( - device_registry, mock_config_entry.entry_id + devices = sorted( + dr.async_entries_for_config_entry(device_registry, mock_config_entry.entry_id), + key=lambda d: d.name, ) assert devices == snapshot diff --git a/tests/components/green_planet_energy/test_sensor.py b/tests/components/green_planet_energy/test_sensor.py index 0208bcaca9acaa..be2ce9a247b46b 100644 --- a/tests/components/green_planet_energy/test_sensor.py +++ b/tests/components/green_planet_energy/test_sensor.py @@ -1,10 +1,14 @@ """Test the Green Planet Energy sensor.""" +from datetime import timedelta +from unittest.mock import MagicMock + from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, snapshot_platform @@ -39,3 +43,84 @@ async def test_sensor_device_info( assert device is not None assert device.name == "Green Planet Energy" assert device.entry_type is dr.DeviceEntryType.SERVICE + + +async def test_lowest_price_day_uses_tomorrow_after_18( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """After 18:00 the lowest day-price sensors must switch to tomorrow's data.""" + # 2024-01-02 02:00:00 UTC = 2024-01-01 18:00:00 PST (UTC-8) + # so dt_util.now().hour == 18, triggering the tomorrow-switch. + freezer.move_to("2024-01-02 02:00:00+00:00") + + # Return tomorrow's cheapest day slot when current_hour >= 18 + def lowest_price_day_side_effect( + data: dict, current_hour: int | None = None + ) -> float: + if current_hour is not None and current_hour >= 18: + return 31.0 # 31 Cent/kWh — tomorrow's cheapest at hour 10 + return 26.0 + + def lowest_price_day_with_hour_side_effect( + data: dict, current_hour: int | None = None + ) -> tuple[float, int]: + if current_hour is not None and current_hour >= 18: + return (31.0, 10) + return (26.0, 6) + + mock_api.get_lowest_price_day.side_effect = lowest_price_day_side_effect + mock_api.get_lowest_price_day_with_hour.side_effect = ( + lowest_price_day_with_hour_side_effect + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Price sensor must reflect tomorrow's lowest day price + price_state = hass.states.get( + "sensor.green_planet_energy_lowest_price_day_06_00_18_00" + ) + assert price_state is not None + assert abs(float(price_state.state) - 0.31) < 1e-4 + + # Time sensor must point to tomorrow's date at hour 10 + time_state = hass.states.get( + "sensor.green_planet_energy_lowest_price_day_time_06_00_18_00" + ) + assert time_state is not None + state_dt = dt_util.parse_datetime(time_state.state) + assert state_dt is not None + local_dt = state_dt.astimezone(dt_util.DEFAULT_TIME_ZONE) + tomorrow_local = dt_util.start_of_local_day(dt_util.now() + timedelta(days=1)) + assert local_dt.date() == tomorrow_local.date() + assert local_dt.hour == 10 + + +async def test_lowest_price_night_time_uses_upcoming_night_after_06( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """After 06:00 the lowest night-price time sensor must switch to tonight.""" + freezer.move_to("2024-01-01 06:00:00") + + mock_api.get_lowest_price_night_with_hour.return_value = (29.0, 22) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + time_state = hass.states.get( + "sensor.green_planet_energy_lowest_price_night_time_18_00_06_00" + ) + assert time_state is not None + state_dt = dt_util.parse_datetime(time_state.state) + assert state_dt is not None + local_dt = state_dt.astimezone(dt_util.DEFAULT_TIME_ZONE) + assert local_dt.date() == dt_util.now().date() + assert local_dt.hour == 22 diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index a3a28df61b2f42..7ef4d32cd60ed9 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -17,7 +17,7 @@ from . import SUPERVISOR_TOKEN -from tests.typing import ClientSessionGenerator +from tests.typing import ClientSessionGenerator, WebSocketGenerator @pytest.fixture(autouse=True) @@ -65,6 +65,25 @@ async def hassio_client_supervisor( ) +@pytest.fixture +def hass_supervisor_ws_client( + hass_ws_client: WebSocketGenerator, + hass: HomeAssistant, +) -> WebSocketGenerator: + """Return a websocket client authenticated as the Supervisor user.""" + + async def create_client() -> WebSocketGenerator: + hassio_user_id = hass.data[DATA_CONFIG_STORE].data.hassio_user + hassio_user = await hass.auth.async_get_user(hassio_user_id) + assert hassio_user + assert hassio_user.refresh_tokens + refresh_token = next(iter(hassio_user.refresh_tokens.values())) + access_token = hass.auth.async_create_access_token(refresh_token) + return await hass_ws_client(hass, access_token=access_token) + + return create_client + + @pytest.fixture async def hassio_handler(hass: HomeAssistant) -> AsyncGenerator[HassIO]: """Create mock hassio handler.""" diff --git a/tests/components/hassio/test_addon_panel.py b/tests/components/hassio/test_addon_panel.py index e199ad66a7e23e..5f8a6aead4d1ad 100644 --- a/tests/components/hassio/test_addon_panel.py +++ b/tests/components/hassio/test_addon_panel.py @@ -9,6 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import MockUser from tests.typing import ClientSessionGenerator @@ -90,6 +91,42 @@ async def test_hassio_addon_panel_api( ) +@pytest.mark.usefixtures("hassio_env") +async def test_hassio_addon_panel_api_non_admin( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ingress_panels: AsyncMock, + hass_admin_user: MockUser, +) -> None: + """Test register panel api fails with non admin user.""" + ingress_panels.return_value = { + "test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), + } + + with patch( + "homeassistant.components.hassio.addon_panel._register_panel", + ) as mock_panel: + await async_setup_component(hass, "hassio", {}) + await hass.async_block_till_done() + + ingress_panels.assert_called_once() + mock_panel.assert_called_once() + + mock_panel.reset_mock() + hass_admin_user.groups = [] + hass_client = await hass_client() + + # Both should return unauthorized regardless of enabled as the endpoint requires + # admin and the user is not admin + resp = await hass_client.post("/api/hassio_push/panel/test2") + assert resp.status == HTTPStatus.UNAUTHORIZED + + resp = await hass_client.post("/api/hassio_push/panel/test1") + assert resp.status == HTTPStatus.UNAUTHORIZED + + mock_panel.assert_not_called() + + @pytest.mark.usefixtures("hassio_env") async def test_hassio_addon_panel_registration( hass: HomeAssistant, ingress_panels: AsyncMock @@ -118,3 +155,49 @@ async def test_hassio_addon_panel_registration( require_admin=True, config={"addon": "test_addon"}, ) + + +@pytest.mark.usefixtures("hassio_env") +async def test_hassio_addon_panel_api_delete( + hass: HomeAssistant, hass_client: ClientSessionGenerator, ingress_panels: AsyncMock +) -> None: + """Test panel api delete.""" + ingress_panels.return_value = { + "test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), + } + await async_setup_component(hass, "hassio", {}) + await hass.async_block_till_done() + + hass_client = await hass_client() + + with patch( + "homeassistant.components.hassio.addon_panel.frontend.async_remove_panel" + ) as mock_remove: + resp = await hass_client.delete("/api/hassio_push/panel/test1") + assert resp.status == HTTPStatus.OK + mock_remove.assert_called_once_with(hass, "test1") + + +@pytest.mark.usefixtures("hassio_env") +async def test_hassio_addon_panel_api_delete_non_admin( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ingress_panels: AsyncMock, + hass_admin_user: MockUser, +) -> None: + """Test panel api delete fails with non admin user.""" + ingress_panels.return_value = { + "test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), + } + await async_setup_component(hass, "hassio", {}) + await hass.async_block_till_done() + + hass_admin_user.groups = [] + hass_client = await hass_client() + + with patch( + "homeassistant.components.hassio.addon_panel.frontend.async_remove_panel" + ) as mock_remove: + resp = await hass_client.delete("/api/hassio_push/panel/test1") + assert resp.status == HTTPStatus.UNAUTHORIZED + mock_remove.assert_not_called() diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 34b8c76ccc9a69..81ef255e3a8edb 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -934,13 +934,13 @@ async def test_agent_delete_with_error( ) async def test_agents_notify_on_mount_added_removed( hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, event_data: dict[str, Any], mount_info_calls: int, ) -> None: """Test the listener is called when mounts are added or removed.""" - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() assert supervisor_client.mounts.info.call_count == 1 assert supervisor_client.mounts.info.call_args[0] == () supervisor_client.mounts.info.reset_mock() @@ -1019,14 +1019,14 @@ async def test_agents_notify_on_mount_added_removed( ) async def test_reader_writer_create( hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, extra_generate_options: dict[str, Any], expected_supervisor_options: supervisor_backups.PartialBackupOptions, ) -> None: """Test generating a backup.""" - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() freezer.move_to("2025-01-30 13:42:12.345678") supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS @@ -1112,7 +1112,7 @@ async def test_reader_writer_create( ) async def test_reader_writer_create_addon_folder_error( hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, addon_info_side_effect: list[Any], @@ -1121,7 +1121,7 @@ async def test_reader_writer_create_addon_folder_error( addon_info_side_effect[0].name = "Advanced SSH & Web Terminal" assert dt.datetime.__name__ == "HAFakeDatetime" assert dt.HAFakeDatetime.__name__ == "HAFakeDatetime" - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() freezer.move_to("2025-01-30 13:42:12.345678") supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS @@ -1242,12 +1242,12 @@ async def test_reader_writer_create_addon_folder_error( @pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_create_report_progress( hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, ) -> None: """Test generating a backup.""" - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() freezer.move_to("2025-01-30 13:42:12.345678") supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS @@ -1573,7 +1573,7 @@ async def test_reader_writer_create_job_done( ) async def test_reader_writer_create_per_agent_encryption( hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, commands: list[dict[str, Any]], @@ -1585,7 +1585,7 @@ async def test_reader_writer_create_per_agent_encryption( upload_locations: list[str | None], ) -> None: """Test generating a backup.""" - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() freezer.move_to("2025-01-30 13:42:12.345678") mounts = MountsInfo( default_backup_mount=None, @@ -1809,12 +1809,12 @@ async def test_reader_writer_create_partial_backup_error( @pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_create_missing_reference_error( hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, supervisor_event: dict[str, Any], ) -> None: """Test missing reference error when generating a backup.""" - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -1914,7 +1914,7 @@ async def test_reader_writer_create_missing_reference_error( ) async def test_reader_writer_create_download_remove_error( hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, exception: Exception, method: str, @@ -1923,7 +1923,7 @@ async def test_reader_writer_create_download_remove_error( expected_events_before_failed: list[dict[str, str]], ) -> None: """Test download and remove error when generating a backup.""" - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -2011,12 +2011,12 @@ async def test_reader_writer_create_download_remove_error( @pytest.mark.parametrize("exception", [SupervisorError("Boom!"), Exception("Boom!")]) async def test_reader_writer_create_info_error( hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, exception: Exception, ) -> None: """Test backup info error when generating a backup.""" - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.side_effect = exception supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -2087,12 +2087,12 @@ async def test_reader_writer_create_info_error( @pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_create_remote_backup( hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, ) -> None: """Test generating a backup which will be uploaded to a remote agent.""" - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() freezer.move_to("2025-01-30 13:42:12.345678") supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 @@ -2301,13 +2301,13 @@ async def test_agent_receive_remote_backup( @pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_restore( hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, get_job_result: supervisor_jobs.Job, supervisor_events: list[dict[str, Any]], ) -> None: """Test restoring a backup.""" - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS @@ -2368,11 +2368,11 @@ async def test_reader_writer_restore( @pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_restore_remote_backup( hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, ) -> None: """Test restoring a backup from a remote agent.""" - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.list.return_value = [TEST_BACKUP_5] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 @@ -2465,11 +2465,11 @@ async def test_reader_writer_restore_remote_backup( @pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_restore_report_progress( hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, ) -> None: """Test restoring a backup.""" - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS @@ -2646,11 +2646,11 @@ async def test_reader_writer_restore_error( @pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_restore_late_error( hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, ) -> None: """Test restoring a backup with error.""" - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS @@ -2838,7 +2838,7 @@ async def test_restore_progress_after_restart( @pytest.mark.usefixtures("hassio_client") async def test_restore_progress_after_restart_report_progress( hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, ) -> None: """Test restore backup progress after restart.""" @@ -2848,7 +2848,7 @@ async def test_restore_progress_after_restart_report_progress( with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() diff --git a/tests/components/hassio/test_binary_sensor.py b/tests/components/hassio/test_binary_sensor.py index e0da6ae5923228..e87004c9b4d620 100644 --- a/tests/components/hassio/test_binary_sensor.py +++ b/tests/components/hassio/test_binary_sensor.py @@ -210,7 +210,7 @@ async def test_mount_refresh_after_issue( hass: HomeAssistant, entity_registry: er.EntityRegistry, supervisor_client: AsyncMock, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, ) -> None: """Test hassio mount state is refreshed after an issue was send by the supervisor.""" # Add a mount. @@ -255,7 +255,7 @@ async def test_mount_refresh_after_issue( # Change mount state to failed, issue a repair, and verify entity's state. mock_mounts[0] = replace(mock_mounts[0], state=MountState.FAILED) - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() issue_uuid = uuid4().hex await client.send_json( { diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py index 486b76fbc4cfa2..70573a1e57e594 100644 --- a/tests/components/hassio/test_discovery.py +++ b/tests/components/hassio/test_discovery.py @@ -5,13 +5,14 @@ from unittest.mock import AsyncMock, patch from uuid import uuid4 -from aiohasupervisor import SupervisorError +from aiohasupervisor import SupervisorError, SupervisorNotFoundError from aiohasupervisor.models import Discovery from aiohttp.test_utils import TestClient import pytest from homeassistant import config_entries from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN +from homeassistant.config_entries import ConfigEntries from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant from homeassistant.helpers.discovery_flow import DiscoveryKey @@ -21,6 +22,7 @@ from tests.common import ( MockConfigEntry, MockModule, + MockUser, mock_config_flow, mock_integration, mock_platform, @@ -194,9 +196,156 @@ async def test_hassio_discovery_webhook( ) +async def test_hassio_discovery_webhook_non_admin( + hass: HomeAssistant, + hassio_client: TestClient, + mock_mqtt: type[config_entries.ConfigFlow], + addon_installed: AsyncMock, + get_discovery_message: AsyncMock, + hass_admin_user: MockUser, +) -> None: + """Test discovery webhook fails for non-admin users.""" + addon_installed.return_value.name = "Mosquitto Test" + + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + hass_admin_user.groups = [] + get_discovery_message.reset_mock() + uuid = uuid4() + + resp = await hassio_client.post( + f"/api/hassio_push/discovery/{uuid!s}", + json={"addon": "mosquitto", "service": "mqtt", "uuid": str(uuid)}, + ) + await hass.async_block_till_done() + + assert resp.status == HTTPStatus.UNAUTHORIZED + get_discovery_message.assert_not_called() + mock_mqtt.async_step_hassio.assert_not_called() + + TEST_UUID = str(uuid4()) +@pytest.mark.usefixtures("hassio_client", "addon_installed", "get_addon_discovery_info") +async def test_delete_hassio_discovery( + hass: HomeAssistant, get_discovery_message: AsyncMock, hassio_client: TestClient +) -> None: + """Test deleting a discovery item removes the config entry.""" + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + entry = MockConfigEntry( + domain=MQTT_DOMAIN, + discovery_keys={ + "hassio": (DiscoveryKey(domain="hassio", key=TEST_UUID, version=1),) + }, + unique_id=(uuid := uuid4()).hex, + state=config_entries.ConfigEntryState.LOADED, + source=config_entries.SOURCE_HASSIO, + ) + entry.add_to_hass(hass) + + get_discovery_message.side_effect = SupervisorNotFoundError() + + with patch.object(ConfigEntries, "async_remove") as mock_remove: + resp = await hassio_client.delete( + f"/api/hassio_push/discovery/{uuid.hex}", + json={"service": "mqtt", "uuid": uuid.hex}, + ) + await hass.async_block_till_done() + + assert resp.status == HTTPStatus.OK + get_discovery_message.assert_called_once_with(uuid) + mock_remove.assert_called_once_with(entry.entry_id) + + +@pytest.mark.usefixtures("hassio_client", "addon_installed", "get_addon_discovery_info") +async def test_delete_hassio_discovery_fails_when_discovery_exists( + hass: HomeAssistant, + get_discovery_message: AsyncMock, + hassio_client: TestClient, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test deleting a discovery item fails when discovery exists.""" + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + entry = MockConfigEntry( + domain=MQTT_DOMAIN, + discovery_keys={ + "hassio": (DiscoveryKey(domain="hassio", key=TEST_UUID, version=1),) + }, + unique_id=(uuid := uuid4()).hex, + state=config_entries.ConfigEntryState.LOADED, + source=config_entries.SOURCE_HASSIO, + ) + entry.add_to_hass(hass) + + get_discovery_message.return_value = Discovery( + addon="mosquitto", + service="mqtt", + uuid=(uuid := uuid4()), + config={ + "broker": "mock-broker", + "port": 1883, + "username": "mock-user", + "password": "mock-pass", + "protocol": "3.1.1", + }, + ) + + with patch.object(ConfigEntries, "async_remove") as mock_remove: + resp = await hassio_client.delete( + f"/api/hassio_push/discovery/{uuid.hex}", + json={"service": "mqtt", "uuid": uuid.hex}, + ) + await hass.async_block_till_done() + + assert resp.status == HTTPStatus.OK + get_discovery_message.assert_called_once_with(uuid) + mock_remove.assert_not_called() + assert "Retrieve wrong unload for mqtt" in caplog.text + + +@pytest.mark.usefixtures("hassio_client", "addon_installed", "get_addon_discovery_info") +async def test_delete_hassio_discovery_non_admin( + hass: HomeAssistant, + get_discovery_message: AsyncMock, + hassio_client: TestClient, + hass_admin_user: MockUser, +) -> None: + """Test deleting a discovery item fails for non-admin users.""" + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + entry = MockConfigEntry( + domain=MQTT_DOMAIN, + discovery_keys={ + "hassio": (DiscoveryKey(domain="hassio", key=TEST_UUID, version=1),) + }, + unique_id=(uuid := uuid4()).hex, + state=config_entries.ConfigEntryState.LOADED, + source=config_entries.SOURCE_HASSIO, + ) + entry.add_to_hass(hass) + + hass_admin_user.groups = [] + + with patch.object(ConfigEntries, "async_remove") as mock_remove: + resp = await hassio_client.delete( + f"/api/hassio_push/discovery/{uuid.hex}", + json={"service": "mqtt", "uuid": uuid.hex}, + ) + await hass.async_block_till_done() + + assert resp.status == HTTPStatus.UNAUTHORIZED + get_discovery_message.assert_not_called() + mock_remove.assert_not_called() + + @pytest.mark.parametrize( ( "entry_domain", diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index db9381a3fd1767..4126ae326dea95 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -1,8 +1,6 @@ """The tests for the hassio component.""" -from collections.abc import Generator from http import HTTPStatus -from unittest.mock import patch from aiohttp import StreamReader from aiohttp.test_utils import TestClient @@ -12,15 +10,6 @@ from tests.test_util.aiohttp import AiohttpClientMocker -@pytest.fixture -def mock_not_onboarded() -> Generator[None]: - """Mock that we're not onboarded.""" - with patch( - "homeassistant.components.hassio.http.async_is_onboarded", return_value=False - ): - yield - - @pytest.fixture def hassio_user_client( hassio_client: TestClient, hass_admin_user: MockUser @@ -166,115 +155,40 @@ async def test_forward_request_onboarded_noauth_unallowed_paths( assert len(aioclient_mock.mock_calls) == 0 -@pytest.mark.parametrize( - ("path", "authenticated"), - [ - ("addons/bl_b392/logo", False), - ("addons/bl_b392/icon", False), - ("backups/1234abcd/info", True), - ], -) -async def test_forward_request_not_onboarded_get( - hassio_noauth_client: TestClient, - aioclient_mock: AiohttpClientMocker, - path: str, - authenticated: bool, - mock_not_onboarded, -) -> None: - """Test fetching normal path.""" - aioclient_mock.get(f"http://127.0.0.1/{path}", text="response") - - resp = await hassio_noauth_client.get(f"/api/hassio/{path}") - - # Check we got right response - assert resp.status == HTTPStatus.OK - body = await resp.text() - assert body == "response" - - # Check we forwarded command - assert len(aioclient_mock.mock_calls) == 1 - expected_headers = { - "X-Hass-Source": "core.http", - } - if authenticated: - expected_headers["Authorization"] = "Bearer 123456" - - assert aioclient_mock.mock_calls[0][3] == expected_headers +BACKUP_NON_ADMIN_REJECTED_PARAMS = [ + ("GET", "backups/1234abcd/info"), + ("GET", "backups/1234abcd/download"), + ("POST", "backups/1234abcd/restore/full"), + ("POST", "backups/1234abcd/restore/partial"), + ("POST", "backups/new/upload"), +] -@pytest.mark.parametrize( - "path", - [ - "backups/new/upload", - "backups/1234abcd/restore/full", - "backups/1234abcd/restore/partial", - ], -) -async def test_forward_request_not_onboarded_post( +@pytest.mark.parametrize(("method", "path"), BACKUP_NON_ADMIN_REJECTED_PARAMS) +async def test_forward_request_backup_unauthenticated_rejected( hassio_noauth_client: TestClient, aioclient_mock: AiohttpClientMocker, + method: str, path: str, - mock_not_onboarded, ) -> None: - """Test fetching normal path.""" - aioclient_mock.get(f"http://127.0.0.1/{path}", text="response") + """Test backup endpoints reject unauthenticated requests.""" + resp = await hassio_noauth_client.request(method, f"/api/hassio/{path}") - resp = await hassio_noauth_client.get(f"/api/hassio/{path}") - - # Check we got right response - assert resp.status == HTTPStatus.OK - body = await resp.text() - assert body == "response" - - # Check we forwarded command - assert len(aioclient_mock.mock_calls) == 1 - # We only expect a single header. - assert aioclient_mock.mock_calls[0][3] == { - "X-Hass-Source": "core.http", - "Authorization": "Bearer 123456", - } - - -@pytest.mark.parametrize("method", ["POST", "PUT", "DELETE"]) -async def test_forward_request_not_onboarded_unallowed_methods( - hassio_noauth_client: TestClient, aioclient_mock: AiohttpClientMocker, method: str -) -> None: - """Test fetching normal path.""" - resp = await hassio_noauth_client.request(method, "/api/hassio/addons/bl_b392/icon") - - # Check we got right response - assert resp.status == HTTPStatus.METHOD_NOT_ALLOWED - - # Check we did not forward command + assert resp.status == HTTPStatus.UNAUTHORIZED assert len(aioclient_mock.mock_calls) == 0 -@pytest.mark.parametrize( - ("bad_path", "expected_status"), - [ - # Caught by bullshit filter - ("addons/bl_b392/%252E./icon", HTTPStatus.BAD_REQUEST), - # Unauthenticated path - ("supervisor/info", HTTPStatus.UNAUTHORIZED), - ("supervisor/logs", HTTPStatus.UNAUTHORIZED), - ("supervisor/logs/follow", HTTPStatus.UNAUTHORIZED), - ("addons/bl_b392/logs", HTTPStatus.UNAUTHORIZED), - ("addons/bl_b392/logs/follow", HTTPStatus.UNAUTHORIZED), - ], -) -async def test_forward_request_not_onboarded_unallowed_paths( - hassio_noauth_client: TestClient, +@pytest.mark.parametrize(("method", "path"), BACKUP_NON_ADMIN_REJECTED_PARAMS) +async def test_forward_request_backup_non_admin_rejected( + hassio_user_client: TestClient, aioclient_mock: AiohttpClientMocker, - bad_path: str, - expected_status: int, - mock_not_onboarded, + method: str, + path: str, ) -> None: - """Test fetching normal path.""" - resp = await hassio_noauth_client.get(f"/api/hassio/{bad_path}") + """Test backup endpoints reject authenticated non-admin requests.""" + resp = await hassio_user_client.request(method, f"/api/hassio/{path}") - # Check we got right response - assert resp.status == expected_status - # Check we didn't forward command + assert resp.status == HTTPStatus.UNAUTHORIZED assert len(aioclient_mock.mock_calls) == 0 @@ -402,7 +316,6 @@ async def test_bad_gateway_when_cannot_find_supervisor( async def test_backup_upload_headers( hassio_client: TestClient, aioclient_mock: AiohttpClientMocker, - mock_not_onboarded, ) -> None: """Test that we forward the full header for backup upload.""" content_type = "multipart/form-data; boundary='--webkit'" @@ -422,7 +335,7 @@ async def test_backup_upload_headers( async def test_backup_download_headers( - hassio_client: TestClient, aioclient_mock: AiohttpClientMocker, mock_not_onboarded + hassio_client: TestClient, aioclient_mock: AiohttpClientMocker ) -> None: """Test that we forward the full header for backup download.""" content_disposition = "attachment; filename=test.tar" diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index f96f22e6d6ffa3..a680bb442b5a6d 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -247,7 +247,7 @@ async def test_unsupported_reasons( async def test_unhealthy_issues_add_remove( hass: HomeAssistant, supervisor_client: AsyncMock, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, ) -> None: """Test unhealthy issues added and removed from dispatches.""" mock_resolution_info(supervisor_client) @@ -255,7 +255,7 @@ async def test_unhealthy_issues_add_remove( result = await async_setup_component(hass, "hassio", {}) assert result - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() await client.send_json( { @@ -304,7 +304,7 @@ async def test_unhealthy_issues_add_remove( async def test_unsupported_issues_add_remove( hass: HomeAssistant, supervisor_client: AsyncMock, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, ) -> None: """Test unsupported issues added and removed from dispatches.""" mock_resolution_info(supervisor_client) @@ -312,7 +312,7 @@ async def test_unsupported_issues_add_remove( result = await async_setup_component(hass, "hassio", {}) assert result - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() await client.send_json( { @@ -361,7 +361,7 @@ async def test_unsupported_issues_add_remove( async def test_reset_issues_supervisor_restart( hass: HomeAssistant, supervisor_client: AsyncMock, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, ) -> None: """All issues reset on supervisor restart.""" mock_resolution_info( @@ -392,7 +392,7 @@ async def test_reset_issues_supervisor_restart( result = await async_setup_component(hass, "hassio", {}) assert result - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() await client.send_json({"id": 1, "type": "repairs/list_issues"}) msg = await client.receive_json() @@ -435,7 +435,7 @@ async def test_reset_issues_supervisor_restart( async def test_no_reset_issues_supervisor_update_found( hass: HomeAssistant, supervisor_client: AsyncMock, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, ) -> None: """Issues do not reset because a supervisor update was found.""" mock_resolution_info( @@ -446,7 +446,7 @@ async def test_no_reset_issues_supervisor_update_found( result = await async_setup_component(hass, "hassio", {}) assert result - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() await client.send_json({"id": 1, "type": "repairs/list_issues"}) msg = await client.receive_json() @@ -479,7 +479,7 @@ async def test_no_reset_issues_supervisor_update_found( async def test_reasons_added_and_removed( hass: HomeAssistant, supervisor_client: AsyncMock, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, ) -> None: """Test an unsupported/unhealthy reasons being added and removed at same time.""" mock_resolution_info( @@ -491,7 +491,7 @@ async def test_reasons_added_and_removed( result = await async_setup_component(hass, "hassio", {}) assert result - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() await client.send_json({"id": 1, "type": "repairs/list_issues"}) msg = await client.receive_json() @@ -739,7 +739,7 @@ async def test_supervisor_issues_initial_failure( async def test_supervisor_issues_add_remove( hass: HomeAssistant, supervisor_client: AsyncMock, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, ) -> None: """Test supervisor issues added and removed from dispatches.""" mock_resolution_info(supervisor_client) @@ -747,7 +747,7 @@ async def test_supervisor_issues_add_remove( result = await async_setup_component(hass, "hassio", {}) assert result - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() await client.send_json( { @@ -850,7 +850,7 @@ async def test_supervisor_issues_suggestions_fail( async def test_supervisor_remove_missing_issue_without_error( hass: HomeAssistant, supervisor_client: AsyncMock, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, ) -> None: """Test HA skips message to remove issue that it didn't know about (sync issue).""" mock_resolution_info(supervisor_client) @@ -858,7 +858,7 @@ async def test_supervisor_remove_missing_issue_without_error( result = await async_setup_component(hass, "hassio", {}) assert result - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() await client.send_json( { @@ -902,7 +902,7 @@ async def test_system_is_not_ready( async def test_supervisor_issues_detached_addon_missing( hass: HomeAssistant, supervisor_client: AsyncMock, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, ) -> None: """Test supervisor issue for detached addon due to missing repository.""" mock_resolution_info(supervisor_client) @@ -910,7 +910,7 @@ async def test_supervisor_issues_detached_addon_missing( result = await async_setup_component(hass, "hassio", {}) assert result - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() await client.send_json( { @@ -953,7 +953,7 @@ async def test_supervisor_issues_detached_addon_missing( async def test_supervisor_issues_ntp_sync_failed( hass: HomeAssistant, supervisor_client: AsyncMock, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, ) -> None: """Test supervisor issue for NTP sync failed.""" mock_resolution_info(supervisor_client) @@ -961,7 +961,7 @@ async def test_supervisor_issues_ntp_sync_failed( result = await async_setup_component(hass, "hassio", {}) assert result - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() await client.send_json( { @@ -1008,7 +1008,7 @@ async def test_supervisor_issues_ntp_sync_failed( async def test_supervisor_issues_disk_lifetime( hass: HomeAssistant, supervisor_client: AsyncMock, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, ) -> None: """Test supervisor issue for disk lifetime nearly exceeded.""" mock_resolution_info(supervisor_client) @@ -1016,7 +1016,7 @@ async def test_supervisor_issues_disk_lifetime( result = await async_setup_component(hass, "hassio", {}) assert result - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() await client.send_json( { @@ -1055,7 +1055,7 @@ async def test_supervisor_issues_disk_lifetime( async def test_supervisor_issues_free_space( hass: HomeAssistant, supervisor_client: AsyncMock, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, ) -> None: """Test supervisor issue for too little free space remaining.""" mock_resolution_info(supervisor_client) @@ -1063,7 +1063,7 @@ async def test_supervisor_issues_free_space( result = await async_setup_component(hass, "hassio", {}) assert result - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() await client.send_json( { @@ -1106,7 +1106,7 @@ async def test_supervisor_issues_free_space( async def test_supervisor_issues_free_space_host_info_fail( hass: HomeAssistant, supervisor_client: AsyncMock, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, host_info: AsyncMock, ) -> None: """Test supervisor issue for too little free space remaining without host info.""" @@ -1116,7 +1116,7 @@ async def test_supervisor_issues_free_space_host_info_fail( result = await async_setup_component(hass, "hassio", {}) assert result - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() await client.send_json( { @@ -1162,7 +1162,7 @@ async def test_supervisor_issues_free_space_host_info_fail( async def test_supervisor_issues_addon_pwned( hass: HomeAssistant, supervisor_client: AsyncMock, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, ) -> None: """Test supervisor issue for pwned secret in an addon.""" mock_resolution_info(supervisor_client) @@ -1170,7 +1170,7 @@ async def test_supervisor_issues_addon_pwned( result = await async_setup_component(hass, "hassio", {}) assert result - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() await client.send_json( { diff --git a/tests/components/hassio/test_jobs.py b/tests/components/hassio/test_jobs.py index 909b7ff571e5dc..70a302b44d69e4 100644 --- a/tests/components/hassio/test_jobs.py +++ b/tests/components/hassio/test_jobs.py @@ -89,7 +89,9 @@ async def test_disconnect_on_config_entry_reload( @pytest.mark.usefixtures("all_setup_requests") async def test_job_manager_ws_updates( - hass: HomeAssistant, jobs_info: AsyncMock, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + jobs_info: AsyncMock, + hass_supervisor_ws_client: WebSocketGenerator, ) -> None: """Test job updates sync from Supervisor WS messages.""" result = await async_setup_component(hass, "hassio", {}) @@ -97,7 +99,7 @@ async def test_job_manager_ws_updates( jobs_info.assert_called_once() jobs_info.reset_mock() - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() data_coordinator: HassioMainDataUpdateCoordinator = hass.data[MAIN_COORDINATOR] assert not data_coordinator.jobs.current_jobs @@ -277,7 +279,9 @@ def mock_subcription_callback(job: Job) -> None: @pytest.mark.usefixtures("all_setup_requests") async def test_job_manager_reload_on_supervisor_restart( - hass: HomeAssistant, jobs_info: AsyncMock, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + jobs_info: AsyncMock, + hass_supervisor_ws_client: WebSocketGenerator, ) -> None: """Test job manager reloads cache on supervisor restart.""" jobs_info.return_value = JobsInfo( @@ -308,7 +312,7 @@ async def test_job_manager_reload_on_supervisor_restart( jobs_info.reset_mock() jobs_info.return_value = JobsInfo(ignore_conditions=[], jobs=[]) - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() # Make an example listener job_data: Job | None = None diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 2ac4040efe54ae..48d9e9c7d49a1d 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -176,7 +176,7 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non async def test_update_addon_progress( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_supervisor_ws_client: WebSocketGenerator ) -> None: """Test progress reporting for addon update.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) @@ -191,7 +191,7 @@ async def test_update_addon_progress( assert result await hass.async_block_till_done() - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() message_id = 0 job_uuid = uuid4().hex @@ -688,7 +688,7 @@ async def test_update_core(hass: HomeAssistant, supervisor_client: AsyncMock) -> async def test_update_core_progress( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_supervisor_ws_client: WebSocketGenerator ) -> None: """Test progress reporting for core update.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) @@ -703,7 +703,7 @@ async def test_update_core_progress( assert result await hass.async_block_till_done() - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() message_id = 0 job_uuid = uuid4().hex @@ -1101,9 +1101,11 @@ async def check_progress( async def test_update_addon_resets_progress_on_error( - hass: HomeAssistant, supervisor_client: AsyncMock + hass: HomeAssistant, + hass_supervisor_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, ) -> None: - """Test addon update resets in_progress to False when update fails.""" + """Test addon update resets in_progress and update_percentage on failure.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) config_entry.add_to_hass(hass) @@ -1118,11 +1120,48 @@ async def test_update_addon_resets_progress_on_error( state = hass.states.get("update.test_update") assert state.attributes.get("in_progress") is False + assert state.attributes.get("update_percentage") is None + + ws = await hass_supervisor_ws_client() + job_uuid = uuid4().hex + + async def fake_update_addon_error( + _hass: HomeAssistant, + _addon: str, + _backup: bool, + _addon_name: str | None, + _installed_version: str | None, + ) -> None: + """Report some progress, then fail - as a mid-pull network error would.""" + await ws.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "job", + "data": { + "uuid": job_uuid, + "created": "2025-09-29T00:00:00.000000+00:00", + "name": "addon_manager_update", + "reference": "test", + "progress": 42, + "done": False, + "stage": None, + "extra": {"total": 1234567890}, + "errors": [], + }, + }, + } + ) + msg = await ws.receive_json() + assert msg["success"] + await hass.async_block_till_done() + raise HomeAssistantError with ( patch( "homeassistant.components.hassio.update.update_addon", - side_effect=HomeAssistantError, + side_effect=fake_update_addon_error, ), pytest.raises(HomeAssistantError), ): @@ -1137,6 +1176,163 @@ async def test_update_addon_resets_progress_on_error( assert state.attributes.get("in_progress") is False, ( "in_progress should be reset to False after error" ) + assert state.attributes.get("update_percentage") is None, ( + "update_percentage should be reset to None after error" + ) + + +def _bump_addon_to( + addons_list: AsyncMock, + addon_installed: AsyncMock, + version: str, + version_latest: str, +) -> None: + """Rewrite the addon fixtures to report a post-update version.""" + current = addons_list.return_value + addons_list.return_value = [ + replace( + current[0], + version=version, + version_latest=version_latest, + update_available=version != version_latest, + ), + *current[1:], + ] + + def _updated_info(slug: str): + addon = Mock( + spec=InstalledAddonComplete, + to_dict=addon_installed.return_value.to_dict, + **addon_installed.return_value.to_dict(), + ) + addon.name = "test" + addon.slug = "test" + addon.version = version + addon.version_latest = version_latest + addon.update_available = version != version_latest + addon.state = AddonState.STARTED + addon.url = "https://github.com/home-assistant/addons/test" + addon.auto_update = True + return addon + + addon_installed.side_effect = _updated_info + + +async def test_update_addon_stays_in_progress_until_refresh( + hass: HomeAssistant, + hass_supervisor_ws_client: WebSocketGenerator, + update_addon: AsyncMock, + addon_installed: AsyncMock, + addons_list: AsyncMock, +) -> None: + """Test addon update entity stays in progress until coordinator refresh. + + Supervisor emits the ``addon_manager_update`` job ``done=True`` WS event a + few milliseconds before ``/store/addons//update`` returns. Without + the ``_update_ongoing`` guard, ``_attr_in_progress`` is cleared while the + coordinator still holds the pre-update version and the UI briefly flips + back to "Update available". + """ + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + assert await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + await hass.async_block_till_done() + + entity_id = "update.test_update" + assert hass.states.get(entity_id).state == "on" + + ws = await hass_supervisor_ws_client() + job_uuid = uuid4().hex + in_progress_after_done: list[bool | None] = [] + + async def fake_update_addon(slug: str, _options: StoreAddonUpdate) -> None: + """Mimic Supervisor: fire done=True on WS, then return HTTP response.""" + await ws.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "job", + "data": { + "uuid": job_uuid, + "created": "2025-09-29T00:00:00.000000+00:00", + "name": "addon_manager_update", + "reference": "test", + "progress": 100, + "done": True, + "stage": None, + "extra": {"total": 1234567890}, + "errors": [], + }, + }, + } + ) + msg = await ws.receive_json() + assert msg["success"] + await hass.async_block_till_done() + in_progress_after_done.append( + hass.states.get(entity_id).attributes.get("in_progress") + ) + _bump_addon_to(addons_list, addon_installed, "2.0.1", "2.0.1") + + update_addon.side_effect = fake_update_addon + + await hass.services.async_call( + "update", "install", {"entity_id": entity_id}, blocking=True + ) + + # The done=True WS event fired mid-install must not drop in_progress; the + # coordinator data at that instant still carries the pre-update version. + assert in_progress_after_done == [True] + + state = hass.states.get(entity_id) + assert state.attributes.get("in_progress") is False + assert state.state == "off" + + +async def test_update_addon_completes_on_any_version_change( + hass: HomeAssistant, + update_addon: AsyncMock, + addon_installed: AsyncMock, + addons_list: AsyncMock, +) -> None: + """Test completion when installed version changes from the pre-install one. + + If a newer upstream release appears between install start and the refresh, + ``installed_version`` will not equal ``latest_version`` but will differ + from the pre-install version. The ongoing flag must still clear. + """ + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + assert await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + await hass.async_block_till_done() + + entity_id = "update.test_update" + + async def fake_update_addon(slug: str, _options: StoreAddonUpdate) -> None: + _bump_addon_to(addons_list, addon_installed, "2.0.1", "2.0.2") + + update_addon.side_effect = fake_update_addon + + await hass.services.async_call( + "update", "install", {"entity_id": entity_id}, blocking=True + ) + + state = hass.states.get(entity_id) + assert state.attributes.get("in_progress") is False + assert state.state == "on" async def test_update_supervisor( @@ -1167,7 +1363,7 @@ async def test_update_supervisor( async def test_update_supervisor_progress( hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, supervisor_info: AsyncMock, ) -> None: """Test progress reporting for a Supervisor update that was not initiated via the entity. @@ -1188,7 +1384,7 @@ async def test_update_supervisor_progress( ) await hass.async_block_till_done() - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() message_id = 0 job_uuid = uuid4().hex entity_id = "update.home_assistant_supervisor_update" @@ -1274,7 +1470,7 @@ def make_job_message(progress: float, done: bool | None) -> dict[str, Any]: async def test_update_supervisor_stays_in_progress_until_restart( hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, supervisor_info: AsyncMock, ) -> None: @@ -1309,7 +1505,7 @@ async def test_update_supervisor_stays_in_progress_until_restart( update_available=False, ) - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() await client.send_json( { "id": 1, @@ -1334,7 +1530,7 @@ async def test_update_supervisor_stays_in_progress_until_restart( async def test_update_supervisor_completes_on_any_version_change( hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, + hass_supervisor_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, supervisor_info: AsyncMock, ) -> None: @@ -1370,7 +1566,7 @@ async def test_update_supervisor_completes_on_any_version_change( update_available=True, ) - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() await client.send_json( { "id": 1, diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 89090edd28b067..ca58683f1b8769 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -95,11 +95,11 @@ def mock_all( @pytest.mark.usefixtures("hassio_env") async def test_ws_subscription( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_supervisor_ws_client: WebSocketGenerator ) -> None: """Test websocket subscription.""" assert await async_setup_component(hass, "hassio", {}) - client = await hass_ws_client(hass) + client = await hass_supervisor_ws_client() await client.send_json({WS_ID: 5, WS_TYPE: WS_TYPE_SUBSCRIBE}) response = await client.receive_json() assert response["success"] @@ -131,6 +131,27 @@ async def test_ws_subscription( assert response["success"] +@pytest.mark.usefixtures("hassio_env") +async def test_admin_non_supervisor_publish_supervisor_event_failure( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_admin_user: MockUser +) -> None: + """Test non admin user cannot publish supervisor event.""" + hass_admin_user.groups = [] + assert await async_setup_component(hass, "hassio", {}) + client = await hass_ws_client(hass) + + await client.send_json( + { + WS_ID: 1, + WS_TYPE: "supervisor/event", + ATTR_DATA: {ATTR_WS_EVENT: "test", "lorem": "ipsum"}, + } + ) + msg = await client.receive_json() + assert msg["success"] is False + assert msg["error"]["message"] == "Only allowed as Supervisor" + + @pytest.mark.usefixtures("hassio_env") async def test_websocket_supervisor_api( hass: HomeAssistant, @@ -277,7 +298,17 @@ async def test_websocket_non_admin_user( websocket_client = await hass_ws_client(hass) aioclient_mock.get( "http://127.0.0.1/addons/test_addon/info", - json={"result": "ok", "data": {}}, + json={ + "result": "ok", + "data": { + "name": "test", + "state": "started", + "slug": "test_addon", + "version": "2.0.0", + "ingress_url": "http://127.0.0.1/ingress/test_addon", + "options": {"option1": "value1", "option2": "value2"}, + }, + }, ) aioclient_mock.get( "http://127.0.0.1/ingress/session", @@ -288,6 +319,8 @@ async def test_websocket_non_admin_user( json={"result": "ok", "data": {}}, ) + # Should return the fields frontend needs (name, version, state, slug and ingress_url) + # but not options, as user is not admin and options can contain sensitive information await websocket_client.send_json( { WS_ID: 1, @@ -297,7 +330,14 @@ async def test_websocket_non_admin_user( } ) msg = await websocket_client.receive_json() - assert msg["result"] == {} + assert msg["result"] == { + "name": "test", + "state": "started", + "slug": "test_addon", + "version": "2.0.0", + "ingress_url": "http://127.0.0.1/ingress/test_addon", + } + assert "options" not in msg["result"] await websocket_client.send_json( { diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 66d48fa4d3d8f4..cae2e1a306a2ae 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -28,7 +28,15 @@ TooManyRequestsError, UnauthorizedError, ) -from aiohomeconnect.model.program import Option, OptionKey, Program, ProgramKey +from aiohomeconnect.model.program import ( + Option, + OptionKey, + Program, + ProgramDefinition, + ProgramDefinitionConstraints, + ProgramDefinitionOption, + ProgramKey, +) from freezegun.api import FrozenDateTimeFactory import pytest @@ -986,3 +994,99 @@ async def test_fetch_base_program_options_when_favorite_program_event( client.get_available_program.assert_awaited_once_with( appliance.ha_id, program_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50 ) + + +@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True) +@pytest.mark.parametrize( + "event_key", + [ + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ], +) +async def test_option_values_kept_after_changing_program( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + event_key: EventKey, +) -> None: + """Test that when a program is changed, the options are kept and defaults are not used.""" + appliance_ha_id = appliance.ha_id + entity_id = "switch.dishwasher_half_load" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.DISHCARE_DISHWASHER_AUTO_1, + options=[ + ProgramDefinitionOption( + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, + "Boolean", + constraints=ProgramDefinitionConstraints(default=False), + ) + ], + ) + ) + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + assert not hass.states.is_state(entity_id, "on") + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + key=EventKey.DISHCARE_DISHWASHER_OPTION_HALF_LOAD, + raw_key=EventKey.DISHCARE_DISHWASHER_OPTION_HALF_LOAD.value, + timestamp=0, + level="", + handling="", + value=True, + ), + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state(entity_id, "on") + + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.DISHCARE_DISHWASHER_ECO_50, + options=[ + ProgramDefinitionOption( + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, + "Boolean", + constraints=ProgramDefinitionConstraints(default=False), + ) + ], + ) + ) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ), + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state(entity_id, "on") diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index dc9fb1d34c27fe..79f07c5285d3bb 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -521,11 +521,16 @@ async def test_untrack_time_change(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("at_sensor"), ["sensor.next_alarm", "{{ 'sensor.next_alarm' }}"] ) +@pytest.mark.parametrize( + "device_class", + [SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME], +) async def test_if_fires_using_at_sensor( hass: HomeAssistant, freezer: FrozenDateTimeFactory, service_calls: list[ServiceCall], at_sensor: str, + device_class: SensorDeviceClass, ) -> None: """Test for firing at sensor time.""" now = dt_util.now() @@ -535,7 +540,7 @@ async def test_if_fires_using_at_sensor( hass.states.async_set( "sensor.next_alarm", trigger_dt.isoformat(), - {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, + {ATTR_DEVICE_CLASS: device_class}, ) time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) @@ -572,7 +577,7 @@ async def test_if_fires_using_at_sensor( hass.states.async_set( "sensor.next_alarm", trigger_dt.isoformat(), - {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, + {ATTR_DEVICE_CLASS: device_class}, ) await hass.async_block_till_done() @@ -589,13 +594,13 @@ async def test_if_fires_using_at_sensor( hass.states.async_set( "sensor.next_alarm", trigger_dt.isoformat(), - {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, + {ATTR_DEVICE_CLASS: device_class}, ) await hass.async_block_till_done() hass.states.async_set( "sensor.next_alarm", broken, - {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, + {ATTR_DEVICE_CLASS: device_class}, ) await hass.async_block_till_done() @@ -609,7 +614,7 @@ async def test_if_fires_using_at_sensor( hass.states.async_set( "sensor.next_alarm", trigger_dt.isoformat(), - {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, + {ATTR_DEVICE_CLASS: device_class}, ) await hass.async_block_till_done() hass.states.async_set( @@ -633,12 +638,17 @@ async def test_if_fires_using_at_sensor( ({"minutes": 5}, timedelta(minutes=5)), ], ) +@pytest.mark.parametrize( + "device_class", + [SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME], +) async def test_if_fires_using_at_sensor_with_offset( hass: HomeAssistant, service_calls: list[ServiceCall], freezer: FrozenDateTimeFactory, offset: str | dict[str, int], delta: timedelta, + device_class: SensorDeviceClass, ) -> None: """Test for firing at sensor time.""" now = dt_util.now() @@ -649,7 +659,7 @@ async def test_if_fires_using_at_sensor_with_offset( hass.states.async_set( "sensor.next_alarm", start_dt.isoformat(), - {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, + {ATTR_DEVICE_CLASS: device_class}, ) time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) @@ -693,7 +703,7 @@ async def test_if_fires_using_at_sensor_with_offset( hass.states.async_set( "sensor.next_alarm", start_dt.isoformat(), - {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, + {ATTR_DEVICE_CLASS: device_class}, ) await hass.async_block_till_done() diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index 7dc93ccf97b53d..2ec4602222a7e8 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -149,7 +149,6 @@ async def test_setup_zha(hass: HomeAssistant, addon_store_info) -> None: "radio_type": "ezsp", } assert config_entry.options == {} - assert config_entry.title == "Yellow" async def test_contributes_radio_serial_port( diff --git a/tests/components/homematic/test_init.py b/tests/components/homematic/test_init.py new file mode 100644 index 00000000000000..f07eba864ee623 --- /dev/null +++ b/tests/components/homematic/test_init.py @@ -0,0 +1,64 @@ +"""Tests for the Homematic integration.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.homematic.const import ( + ATTR_INTERFACE, + DATA_HOMEMATIC, + SERVICE_SET_INSTALL_MODE, +) +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import Unauthorized +from homeassistant.setup import async_setup_component + +from tests.common import MockUser + +DOMAIN = "homematic" +BASE_CONFIG = {DOMAIN: {"hosts": {"ccu2": {"host": "127.0.0.1"}}}} + + +@pytest.fixture +async def setup_homematic(hass: HomeAssistant) -> None: + """Set up the homematic component.""" + with patch( + "homeassistant.components.homematic.HMConnection", + return_value=MagicMock(), + ): + await async_setup_component(hass, DOMAIN, BASE_CONFIG) + await hass.async_block_till_done() + + +async def test_set_install_mode_admin_allowed( + hass: HomeAssistant, + setup_homematic: None, + hass_admin_user: MockUser, +) -> None: + """Test that admin users can call set_install_mode.""" + await hass.services.async_call( + DOMAIN, + SERVICE_SET_INSTALL_MODE, + {ATTR_INTERFACE: "ccu2"}, + blocking=True, + context=Context(user_id=hass_admin_user.id), + ) + hass.data[DATA_HOMEMATIC].setInstallMode.assert_called_once_with( + "ccu2", t=60, mode=1, address=None + ) + + +async def test_set_install_mode_non_admin_rejected( + hass: HomeAssistant, + setup_homematic: None, + hass_read_only_user: MockUser, +) -> None: + """Test that non-admin users cannot call set_install_mode.""" + with pytest.raises(Unauthorized): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_INSTALL_MODE, + {ATTR_INTERFACE: "ccu2"}, + blocking=True, + context=Context(user_id=hass_read_only_user.id), + ) diff --git a/tests/components/homematicip_cloud/test_init.py b/tests/components/homematicip_cloud/test_init.py index 5fd93badc9dace..e532eaaada4d75 100644 --- a/tests/components/homematicip_cloud/test_init.py +++ b/tests/components/homematicip_cloud/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, Mock, patch from homematicip.exceptions.connection_exceptions import HmipConnectionError +import pytest from homeassistant.components.homematicip_cloud.const import ( CONF_ACCESSPOINT, @@ -16,6 +17,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -202,3 +204,161 @@ async def test_setup_services(hass: HomeAssistant) -> None: assert len(config_entries) == 1 await hass.config_entries.async_unload(config_entries[0].entry_id) + + +# --- Unique ID migration tests --- + + +@pytest.fixture +def mock_config_entry_v1(hass: HomeAssistant) -> MockConfigEntry: + """Create a v1 config entry for migration testing.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={HMIPC_HAPID: "ABC123", HMIPC_AUTHTOKEN: "token", HMIPC_NAME: ""}, + version=1, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.mark.parametrize( + ("platform", "old_unique_id", "new_unique_id"), + [ + ( + "binary_sensor", + "HomematicipMotionDetector_3014F711ABCD", + "3014F711ABCD_1_motion", + ), + ( + "switch", + "HomematicipMultiSwitch_Channel3_3014F711ABCD", + "3014F711ABCD_3_switch", + ), + ( + "light", + "HomematicipNotificationLight_Top_3014F711ABCD", + "3014F711ABCD_2_notification_light", + ), + ("climate", "HomematicipHeatingGroup_UUID-GROUP-123", "UUID-GROUP-123_climate"), + ], + ids=["single_channel", "multi_channel", "notification_light", "group"], +) +async def test_migrate_unique_id( + hass: HomeAssistant, + mock_config_entry_v1: MockConfigEntry, + entity_registry: er.EntityRegistry, + platform: str, + old_unique_id: str, + new_unique_id: str, +) -> None: + """Test unique_id migration for different entity types.""" + entity_registry.async_get_or_create( + platform, + DOMAIN, + old_unique_id, + config_entry=mock_config_entry_v1, + ) + + with patch("homeassistant.components.homematicip_cloud.HomematicipHAP") as mock_hap: + instance = mock_hap.return_value + instance.async_setup = AsyncMock(return_value=True) + instance.home.id = "1" + instance.home.modelType = "mock-type" + instance.home.name = "mock-name" + instance.home.label = "mock-label" + instance.home.currentAPVersion = "mock-ap-version" + instance.async_reset = AsyncMock(return_value=True) + + await hass.config_entries.async_setup(mock_config_entry_v1.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v1.version == 2 + assert entity_registry.async_get_entity_id(platform, DOMAIN, new_unique_id) + + +async def test_migrate_stable_unique_id_skipped( + hass: HomeAssistant, + mock_config_entry_v1: MockConfigEntry, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that a non-class-name unique_id is silently skipped and preserved.""" + entity_registry.async_get_or_create( + "sensor", + DOMAIN, + "HomematicipFutureEntity_3014F711ABCD", + config_entry=mock_config_entry_v1, + ) + + with patch("homeassistant.components.homematicip_cloud.HomematicipHAP") as mock_hap: + instance = mock_hap.return_value + instance.async_setup = AsyncMock(return_value=True) + instance.home.id = "1" + instance.home.modelType = "mock-type" + instance.home.name = "mock-name" + instance.home.label = "mock-label" + instance.home.currentAPVersion = "mock-ap-version" + instance.async_reset = AsyncMock(return_value=True) + + await hass.config_entries.async_setup(mock_config_entry_v1.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v1.version == 2 + # Unknown prefix is not a known class name, so it's treated as already + # stable and skipped silently (no warning, just debug). + assert "already stable format" in caplog.text + # Old unique_id is preserved (not migrated) + assert entity_registry.async_get_entity_id( + "sensor", DOMAIN, "HomematicipFutureEntity_3014F711ABCD" + ) + + +async def test_migrate_battery_and_obsolete_access_point( + hass: HomeAssistant, + mock_config_entry_v1: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test battery migration and obsolete access point entity removal.""" + + # Obsolete access point battery entity: legacy unique_id, no linked device. + obsolete_entity_id = entity_registry.async_get_or_create( + "binary_sensor", + DOMAIN, + "HomematicipBatterySensor_ABC123", + config_entry=mock_config_entry_v1, + ).entity_id + + # Real device battery entity: same legacy class prefix, but attached to a device. + device_entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_v1.entry_id, + identifiers={(DOMAIN, "3014F711ABCD")}, + ) + entity_registry.async_get_or_create( + "binary_sensor", + DOMAIN, + "HomematicipBatterySensor_3014F711ABCD", + config_entry=mock_config_entry_v1, + device_id=device_entry.id, + ) + + with patch("homeassistant.components.homematicip_cloud.HomematicipHAP") as mock_hap: + instance = mock_hap.return_value + instance.async_setup = AsyncMock(return_value=True) + instance.home.id = "1" + instance.home.modelType = "mock-type" + instance.home.name = "mock-name" + instance.home.label = "mock-label" + instance.home.currentAPVersion = "mock-ap-version" + instance.async_reset = AsyncMock(return_value=True) + + await hass.config_entries.async_setup(mock_config_entry_v1.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v1.version == 2 + # Obsolete access point battery entity removed + assert entity_registry.async_get(obsolete_entity_id) is None + # Real device battery entity migrated + assert entity_registry.async_get_entity_id( + "binary_sensor", DOMAIN, "3014F711ABCD_0_battery" + ) diff --git a/tests/components/honeywell_string_lights/__init__.py b/tests/components/honeywell_string_lights/__init__.py new file mode 100644 index 00000000000000..948d9ef3ec3e02 --- /dev/null +++ b/tests/components/honeywell_string_lights/__init__.py @@ -0,0 +1 @@ +"""Tests for the Honeywell String Lights integration.""" diff --git a/tests/components/honeywell_string_lights/conftest.py b/tests/components/honeywell_string_lights/conftest.py new file mode 100644 index 00000000000000..8df7888f79c899 --- /dev/null +++ b/tests/components/honeywell_string_lights/conftest.py @@ -0,0 +1,44 @@ +"""Common fixtures for the Honeywell String Lights tests.""" + +from __future__ import annotations + +import pytest + +from homeassistant.components.honeywell_string_lights.const import ( + CONF_TRANSMITTER, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.radio_frequency.common import MockRadioFrequencyEntity + +TRANSMITTER_ENTITY_ID = "radio_frequency.test_rf_transmitter" + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, +) -> MockConfigEntry: + """Return a mock config entry for Honeywell String Lights.""" + entity_registry = er.async_get(hass) + entity_entry = entity_registry.async_get(TRANSMITTER_ENTITY_ID) + return MockConfigEntry( + domain=DOMAIN, + title="Honeywell String Lights", + data={CONF_TRANSMITTER: entity_entry.id}, + unique_id=entity_entry.id, + ) + + +@pytest.fixture +async def init_string_lights( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Set up the Honeywell String Lights integration.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry diff --git a/tests/components/honeywell_string_lights/test_config_flow.py b/tests/components/honeywell_string_lights/test_config_flow.py new file mode 100644 index 00000000000000..62c9833457241a --- /dev/null +++ b/tests/components/honeywell_string_lights/test_config_flow.py @@ -0,0 +1,90 @@ +"""Test the Honeywell String Lights config flow.""" + +from __future__ import annotations + +from homeassistant.components.honeywell_string_lights.const import ( + CONF_TRANSMITTER, + DOMAIN, +) +from homeassistant.components.radio_frequency import DATA_COMPONENT, DOMAIN as RF_DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import TRANSMITTER_ENTITY_ID + +from tests.common import MockConfigEntry +from tests.components.radio_frequency.common import MockRadioFrequencyEntity + + +async def test_user_flow( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + entity_registry: er.EntityRegistry, +) -> None: + """Test the user config flow creates an entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": 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_TRANSMITTER: TRANSMITTER_ENTITY_ID}, + ) + + entity_entry = entity_registry.async_get(TRANSMITTER_ENTITY_ID) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Honeywell String Lights" + assert result["data"] == {CONF_TRANSMITTER: entity_entry.id} + assert result["result"].unique_id == entity_entry.id + + +async def test_unique_id_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test aborting when the same transmitter is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_TRANSMITTER: TRANSMITTER_ENTITY_ID}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_no_transmitters(hass: HomeAssistant) -> None: + """Test the flow aborts when no RF transmitters are registered at all.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_transmitters" + + +async def test_no_compatible_transmitters(hass: HomeAssistant) -> None: + """Test aborting when transmitters exist but none support 433.92 MHz OOK.""" + assert await async_setup_component(hass, RF_DOMAIN, {}) + await hass.async_block_till_done() + incompatible = MockRadioFrequencyEntity( + "incompatible", frequency_ranges=[(868_000_000, 869_000_000)] + ) + await hass.data[DATA_COMPONENT].async_add_entities([incompatible]) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_compatible_transmitters" diff --git a/tests/components/honeywell_string_lights/test_light.py b/tests/components/honeywell_string_lights/test_light.py new file mode 100644 index 00000000000000..fe9a26fe1c3c67 --- /dev/null +++ b/tests/components/honeywell_string_lights/test_light.py @@ -0,0 +1,102 @@ +"""Tests for the Honeywell String Lights light platform.""" + +from __future__ import annotations + +from homeassistant.components.honeywell_string_lights.light import COMMANDS +from homeassistant.components.light import ( + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import Context, HomeAssistant, State + +from tests.common import MockConfigEntry, mock_restore_cache +from tests.components.radio_frequency.common import MockRadioFrequencyEntity + +ENTITY_ID = "light.honeywell_string_lights" + + +async def test_turn_on_off_sends_commands( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_string_lights: MockConfigEntry, +) -> None: + """Test turning the light on and off sends the correct RF commands.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_ASSUMED_STATE] is True + + context = Context() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + context=context, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert state.context is context + assert len(mock_rf_entity.send_command_calls) == 1 + command = mock_rf_entity.send_command_calls[0] + assert command.command is COMMANDS.load_command("turn_on") + assert command.context is context + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + context=context, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + assert state.context is context + assert len(mock_rf_entity.send_command_calls) == 2 + command = mock_rf_entity.send_command_calls[1] + assert command.command is COMMANDS.load_command("turn_off") + assert command.context is context + + +async def test_restore_state( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the light restores its previous on state.""" + mock_restore_cache(hass, [State(ENTITY_ID, STATE_ON)]) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + +async def test_unload_entry( + hass: HomeAssistant, init_string_lights: MockConfigEntry +) -> None: + """Test unloading the config entry removes the entity.""" + assert hass.states.get(ENTITY_ID) is not None + + assert await hass.config_entries.async_unload(init_string_lights.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/hue/test_scene.py b/tests/components/hue/test_scene.py index afde6b60137c3f..bbb479e033fc28 100644 --- a/tests/components/hue/test_scene.py +++ b/tests/components/hue/test_scene.py @@ -2,6 +2,8 @@ from unittest.mock import Mock +import pytest + from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -200,3 +202,27 @@ async def test_scene_updates( await hass.async_block_till_done() test_entity = hass.states.get(test_entity_id) assert test_entity is None + + +async def test_scene_with_orphaned_group( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that a scene referencing a non-existent group is skipped and logged.""" + orphaned_scene = { + **FAKE_SCENE, + "id": "orphaned_scene_id", + "group": {"rid": "non-existent-group-id", "rtype": "room"}, + } + await mock_bridge_v2.api.load_test_data([*v2_resources_test_data, orphaned_scene]) + + await setup_platform(hass, mock_bridge_v2, Platform.SCENE) + + # the orphaned scene should not be created as an entity + assert hass.states.get("scene.test_room_mocked_scene_orphaned") is None + # the valid scenes should still be created + assert len(hass.states.async_all()) == 3 + # an error should be logged for the orphaned scene + assert "Unable to create Hue scene entity for orphaned_scene_id" in caplog.text diff --git a/tests/components/humidifier/test_condition.py b/tests/components/humidifier/test_condition.py index b45f8882964ed4..76d4acabaadaaa 100644 --- a/tests/components/humidifier/test_condition.py +++ b/tests/components/humidifier/test_condition.py @@ -30,6 +30,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_numerical_attribute_condition_above_below_all, @@ -63,6 +64,33 @@ async def test_humidifier_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("humidifier.is_off", {}, True, True), + ("humidifier.is_on", {}, True, True), + ("humidifier.is_drying", {}, True, False), + ("humidifier.is_humidifying", {}, True, False), + ], +) +async def test_humidifier_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that humidifier conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/humidity/test_condition.py b/tests/components/humidity/test_condition.py index f878dfe14a005a..d62065853daf0d 100644 --- a/tests/components/humidity/test_condition.py +++ b/tests/components/humidity/test_condition.py @@ -20,6 +20,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_numerical_attribute_condition_above_below_all, parametrize_numerical_attribute_condition_above_below_any, parametrize_numerical_condition_above_below_all, @@ -68,6 +69,33 @@ async def test_humidity_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +_PLAIN_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("humidity.is_value", _PLAIN_THRESHOLD, True, False), + ], +) +async def test_humidity_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that humidity conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/husqvarna_automower/fixtures/mower.json b/tests/components/husqvarna_automower/fixtures/mower.json index c00f344bd8dc79..73fc6dbbc7dacc 100644 --- a/tests/components/husqvarna_automower/fixtures/mower.json +++ b/tests/components/husqvarna_automower/fixtures/mower.json @@ -98,24 +98,36 @@ { "workAreaId": 123456, "name": "Front lawn", + "type": "SYSTEMATIC", "cuttingHeight": 50, "enabled": true, + "orientation": 1800, + "orientationShift": 1800, + "currentOrientation": 0, "progress": 40, - "lastTimeCompleted": 1723449269 + "lastTimeCompleted": 1723449269, + "lastTimeAbandoned": 0, + "useGlobalCuttingHeight": true }, { "workAreaId": 654321, "name": "Back lawn", + "type": "RANDOM", "cuttingHeight": 25, - "enabled": true + "enabled": true, + "lastTimeAbandoned": 0, + "useGlobalCuttingHeight": true }, { "workAreaId": 0, "name": "", + "type": "SYSTEMATIC", "cuttingHeight": 50, "enabled": false, "progress": 20, - "lastTimeCompleted": 1723439269 + "lastTimeCompleted": 1723439269, + "lastTimeAbandoned": 0, + "useGlobalCuttingHeight": true } ], "positions": [ diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index b4651af62f59fb..8fed95f9f8543c 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -138,25 +138,43 @@ ]), 'work_areas': dict({ '0': dict({ + 'current_orientation': None, 'cutting_height': 50, 'enabled': False, + 'last_time_abandoned': None, 'last_time_completed': '2024-08-12T05:07:49+02:00', 'name': 'my_lawn', + 'orientation': None, + 'orientation_shift': None, 'progress': 20, + 'type': 'systematic', + 'use_global_cutting_height': True, }), '123456': dict({ + 'current_orientation': 0, 'cutting_height': 50, 'enabled': True, + 'last_time_abandoned': None, 'last_time_completed': '2024-08-12T07:54:29+02:00', 'name': 'Front lawn', + 'orientation': 1800, + 'orientation_shift': 1800, 'progress': 40, + 'type': 'systematic', + 'use_global_cutting_height': True, }), '654321': dict({ + 'current_orientation': None, 'cutting_height': 25, 'enabled': True, + 'last_time_abandoned': None, 'last_time_completed': None, 'name': 'Back lawn', + 'orientation': None, + 'orientation_shift': None, 'progress': None, + 'type': 'random', + 'use_global_cutting_height': True, }), }), }) diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index 0e8c8fa99b5397..47c01cd0eeda8a 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -15,6 +15,7 @@ HusqvarnaWSServerHandshakeError, ) from aioautomower.model import Calendar, MowerAttributes, MowerStates, WorkArea +from aioautomower.model.model_work_areas import Type from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -463,6 +464,8 @@ def fake_register_websocket_response( last_time_completed=datetime( 2024, 10, 1, 11, 11, 0, tzinfo=dt_util.get_default_time_zone() ), + type=Type.RANDOM, + use_global_cutting_height=False, ) } ) diff --git a/tests/components/iaqualink/test_config_flow.py b/tests/components/iaqualink/test_config_flow.py index 58cd6fa5c977f1..bf1c1208c96db1 100644 --- a/tests/components/iaqualink/test_config_flow.py +++ b/tests/components/iaqualink/test_config_flow.py @@ -203,6 +203,51 @@ async def test_reauth_success(hass: HomeAssistant, config_data: dict[str, str]) } +async def test_reconfigure_success( + hass: HomeAssistant, config_data: dict[str, str] +) -> None: + """Test successful reconfiguration.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=config_data[CONF_USERNAME], + data=config_data, + ) + entry.add_to_hass(hass) + + new_username = "updated@example.com" + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.iaqualink.config_flow.AqualinkClient.login", + return_value=None, + ), + patch( + "homeassistant.config_entries.ConfigEntries.async_reload", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: new_username, CONF_PASSWORD: "new_password"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.title == new_username + assert dict(entry.data) == { + **config_data, + CONF_USERNAME: new_username, + CONF_PASSWORD: "new_password", + } + + async def test_reauth_invalid_auth( hass: HomeAssistant, config_data: dict[str, str] ) -> None: diff --git a/tests/components/illuminance/test_condition.py b/tests/components/illuminance/test_condition.py index d82a29581c3d0e..614ea7146ffd03 100644 --- a/tests/components/illuminance/test_condition.py +++ b/tests/components/illuminance/test_condition.py @@ -17,6 +17,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_numerical_condition_above_below_all, @@ -55,6 +56,31 @@ async def test_illuminance_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("illuminance.is_detected", {}, True, True), + ("illuminance.is_not_detected", {}, True, True), + ], +) +async def test_illuminance_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that illuminance conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/indevolt/conftest.py b/tests/components/indevolt/conftest.py index 384ecc469b8014..202e6b2f08db3f 100644 --- a/tests/components/indevolt/conftest.py +++ b/tests/components/indevolt/conftest.py @@ -1,7 +1,6 @@ """Setup the Indevolt test environment.""" from collections.abc import Generator -from typing import Any from unittest.mock import AsyncMock, patch import pytest @@ -16,6 +15,7 @@ from tests.common import MockConfigEntry, load_json_object_fixture TEST_HOST = "192.168.1.100" +ALT_TEST_HOST = "192.168.1.101" TEST_PORT = 8080 TEST_DEVICE_SN_GEN1 = "BK1600-12345678" TEST_DEVICE_SN_GEN2 = "SolidFlex2000-87654321" @@ -27,11 +27,13 @@ "device": "BK1600", "generation": 1, "sn": TEST_DEVICE_SN_GEN1, + "host": ALT_TEST_HOST, }, 2: { "device": "CMS-SF2000", "generation": 2, "sn": TEST_DEVICE_SN_GEN2, + "host": TEST_HOST, }, } @@ -43,26 +45,43 @@ def generation(request: pytest.FixtureRequest) -> int: @pytest.fixture -def entry_data(generation: int) -> dict[str, Any]: - """Return the config entry data based on generation.""" - device_info = DEVICE_MAPPING[generation] - return { - CONF_HOST: TEST_HOST, - CONF_SERIAL_NUMBER: device_info["sn"], - CONF_MODEL: device_info["device"], - CONF_GENERATION: device_info["generation"], - } +def alt_generation(request: pytest.FixtureRequest) -> int: + """Return the alternative device generation.""" + return getattr(request, "param", 1) @pytest.fixture -def mock_config_entry(generation: int, entry_data: dict[str, Any]) -> MockConfigEntry: +def mock_config_entry(generation: int) -> MockConfigEntry: """Return the default mocked config entry.""" device_info = DEVICE_MAPPING[generation] return MockConfigEntry( domain=DOMAIN, title=device_info["device"], version=1, - data=entry_data, + data={ + CONF_HOST: device_info["host"], + CONF_SERIAL_NUMBER: device_info["sn"], + CONF_MODEL: device_info["device"], + CONF_GENERATION: device_info["generation"], + }, + unique_id=device_info["sn"], + ) + + +@pytest.fixture +def alt_mock_config_entry(alt_generation: int) -> MockConfigEntry: + """Return a second mocked config entry for multi-device tests.""" + device_info = DEVICE_MAPPING[alt_generation] + return MockConfigEntry( + domain=DOMAIN, + title=device_info["device"], + version=1, + data={ + CONF_HOST: device_info["host"], + CONF_SERIAL_NUMBER: device_info["sn"], + CONF_MODEL: device_info["device"], + CONF_GENERATION: device_info["generation"], + }, unique_id=device_info["sn"], ) @@ -87,6 +106,9 @@ def mock_indevolt(generation: int) -> Generator[AsyncMock]: client = mock_client.return_value client.fetch_data.return_value = fixture_data client.set_data.return_value = True + client.stop.return_value = True + client.charge.return_value = True + client.discharge.return_value = True client.get_config.return_value = { "device": { "sn": device_info["sn"], diff --git a/tests/components/indevolt/test_button.py b/tests/components/indevolt/test_button.py index a5ea45c8d886b8..ad8e36b7e4b79d 100644 --- a/tests/components/indevolt/test_button.py +++ b/tests/components/indevolt/test_button.py @@ -1,19 +1,12 @@ """Tests for the Indevolt button platform.""" -from unittest.mock import AsyncMock, call, patch +from unittest.mock import AsyncMock, patch -from indevolt_api import TimeOutException +from indevolt_api import IndevoltConfig, IndevoltEnergyMode import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.components.indevolt.const import ( - ENERGY_MODE_READ_KEY, - ENERGY_MODE_WRITE_KEY, - PORTABLE_MODE, - REALTIME_ACTION_KEY, - REALTIME_ACTION_MODE, -) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -64,14 +57,11 @@ async def test_button_press_standby( blocking=True, ) - # Verify set_data was called twice with correct parameters - assert mock_indevolt.set_data.call_count == 2 - mock_indevolt.set_data.assert_has_calls( - [ - call(ENERGY_MODE_WRITE_KEY, REALTIME_ACTION_MODE), - call(REALTIME_ACTION_KEY, [0, 0, 0]), - ] + # Verify set_data was called for mode switch and stop() was called + mock_indevolt.set_data.assert_called_once_with( + IndevoltConfig.WRITE_ENERGY_MODE, IndevoltEnergyMode.REAL_TIME_CONTROL ) + mock_indevolt.stop.assert_called_once() @pytest.mark.parametrize("generation", [2], indirect=True) @@ -83,7 +73,9 @@ async def test_button_press_standby_already_in_realtime_mode( """Test pressing standby when already in real-time mode skips the mode switch.""" # Force real-time control mode - mock_indevolt.fetch_data.return_value[ENERGY_MODE_READ_KEY] = REALTIME_ACTION_MODE + mock_indevolt.fetch_data.return_value[IndevoltConfig.READ_ENERGY_MODE] = ( + IndevoltEnergyMode.REAL_TIME_CONTROL + ) with patch("homeassistant.components.indevolt.PLATFORMS", [Platform.BUTTON]): await setup_integration(hass, mock_config_entry) @@ -98,22 +90,23 @@ async def test_button_press_standby_already_in_realtime_mode( blocking=True, ) - # Verify set_data was called once with correct parameters - mock_indevolt.set_data.assert_called_once_with(REALTIME_ACTION_KEY, [0, 0, 0]) + # Verify stop() was called and no mode switch was needed + mock_indevolt.set_data.assert_not_called() + mock_indevolt.stop.assert_called_once() @pytest.mark.parametrize("generation", [2], indirect=True) -async def test_button_press_standby_timeout_error( +async def test_button_press_standby_rejected_command( hass: HomeAssistant, mock_indevolt: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test pressing standby raises HomeAssistantError when the device times out.""" + """Test pressing standby raises HomeAssistantError when the device rejects the command.""" with patch("homeassistant.components.indevolt.PLATFORMS", [Platform.BUTTON]): await setup_integration(hass, mock_config_entry) - # Simulate an API push failure - mock_indevolt.set_data.side_effect = TimeOutException("Timed out") + # Simulate stop() returning False (device rejected the command) + mock_indevolt.stop.return_value = False # Mock call to pause (dis)charging with pytest.raises(HomeAssistantError): @@ -134,7 +127,9 @@ async def test_button_press_standby_portable_mode_error( """Test pressing standby raises HomeAssistantError when device is in outdoor/portable mode.""" # Force outdoor/portable mode - mock_indevolt.fetch_data.return_value[ENERGY_MODE_READ_KEY] = PORTABLE_MODE + mock_indevolt.fetch_data.return_value[IndevoltConfig.READ_ENERGY_MODE] = ( + IndevoltEnergyMode.OUTDOOR_PORTABLE + ) with patch("homeassistant.components.indevolt.PLATFORMS", [Platform.BUTTON]): await setup_integration(hass, mock_config_entry) diff --git a/tests/components/indevolt/test_number.py b/tests/components/indevolt/test_number.py index a0cb1ee89f4615..abfa5c8f7f22c1 100644 --- a/tests/components/indevolt/test_number.py +++ b/tests/components/indevolt/test_number.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory +from indevolt_api import IndevoltConfig import pytest from syrupy.assertion import SnapshotAssertion @@ -18,18 +19,6 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform -KEY_READ_DISCHARGE_LIMIT = "6105" -KEY_WRITE_DISCHARGE_LIMIT = "1142" - -KEY_READ_MAX_AC_OUTPUT_POWER = "11011" -KEY_WRITE_MAX_AC_OUTPUT_POWER = "1147" - -KEY_READ_INVERTER_INPUT_LIMIT = "11009" -KEY_WRITE_INVERTER_INPUT_LIMIT = "1138" - -KEY_READ_FEEDIN_POWER_LIMIT = "11010" -KEY_WRITE_FEEDIN_POWER_LIMIT = "1146" - @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize("generation", [2], indirect=True) @@ -53,26 +42,26 @@ async def test_number( [ ( "number.cms_sf2000_discharge_limit", - KEY_READ_DISCHARGE_LIMIT, - KEY_WRITE_DISCHARGE_LIMIT, + IndevoltConfig.READ_DISCHARGE_LIMIT, + IndevoltConfig.WRITE_DISCHARGE_LIMIT, 50, ), ( "number.cms_sf2000_max_ac_output_power", - KEY_READ_MAX_AC_OUTPUT_POWER, - KEY_WRITE_MAX_AC_OUTPUT_POWER, + IndevoltConfig.READ_MAX_AC_OUTPUT_POWER, + IndevoltConfig.WRITE_MAX_AC_OUTPUT_POWER, 1500, ), ( "number.cms_sf2000_inverter_input_limit", - KEY_READ_INVERTER_INPUT_LIMIT, - KEY_WRITE_INVERTER_INPUT_LIMIT, + IndevoltConfig.READ_INVERTER_INPUT_LIMIT, + IndevoltConfig.WRITE_INVERTER_INPUT_LIMIT, 800, ), ( "number.cms_sf2000_feed_in_power_limit", - KEY_READ_FEEDIN_POWER_LIMIT, - KEY_WRITE_FEEDIN_POWER_LIMIT, + IndevoltConfig.READ_FEEDIN_POWER_LIMIT, + IndevoltConfig.WRITE_FEEDIN_POWER_LIMIT, 1200, ), ], diff --git a/tests/components/indevolt/test_select.py b/tests/components/indevolt/test_select.py index 217c793d2cb663..cf875bd73ddffe 100644 --- a/tests/components/indevolt/test_select.py +++ b/tests/components/indevolt/test_select.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory +from indevolt_api import IndevoltConfig, IndevoltEnergyMode import pytest from syrupy.assertion import SnapshotAssertion @@ -18,9 +19,6 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform -KEY_READ_ENERGY_MODE = "7101" -KEY_WRITE_ENERGY_MODE = "47005" - @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize("generation", [2, 1], indirect=True) @@ -42,9 +40,9 @@ async def test_select( @pytest.mark.parametrize( ("option", "expected_value"), [ - ("self_consumed_prioritized", 1), - ("real_time_control", 4), - ("charge_discharge_schedule", 5), + ("self_consumed_prioritized", IndevoltEnergyMode.SELF_CONSUMED_PRIORITIZED), + ("real_time_control", IndevoltEnergyMode.REAL_TIME_CONTROL), + ("charge_discharge_schedule", IndevoltEnergyMode.CHARGE_DISCHARGE_SCHEDULE), ], ) async def test_select_option( @@ -62,7 +60,9 @@ async def test_select_option( mock_indevolt.set_data.reset_mock() # Update mock data to reflect the new value - mock_indevolt.fetch_data.return_value[KEY_READ_ENERGY_MODE] = expected_value + mock_indevolt.fetch_data.return_value[IndevoltConfig.READ_ENERGY_MODE] = ( + expected_value + ) # Attempt to change option await hass.services.async_call( @@ -73,7 +73,9 @@ async def test_select_option( ) # Verify set_data was called with correct parameters - mock_indevolt.set_data.assert_called_with(KEY_WRITE_ENERGY_MODE, expected_value) + mock_indevolt.set_data.assert_called_with( + IndevoltConfig.WRITE_ENERGY_MODE, expected_value + ) # Verify updated state assert (state := hass.states.get("select.cms_sf2000_energy_mode")) is not None @@ -120,7 +122,9 @@ async def test_select_unavailable_outdoor_portable( """Test that entity is unavailable when device is in outdoor/portable mode (value 0).""" # Update mock data to fake outdoor/portable mode - mock_indevolt.fetch_data.return_value[KEY_READ_ENERGY_MODE] = 0 + mock_indevolt.fetch_data.return_value[IndevoltConfig.READ_ENERGY_MODE] = ( + IndevoltEnergyMode.OUTDOOR_PORTABLE + ) # Initialize platform to test availability logic with patch("homeassistant.components.indevolt.PLATFORMS", [Platform.SELECT]): diff --git a/tests/components/indevolt/test_sensor.py b/tests/components/indevolt/test_sensor.py index 21aef8cffcdda9..f53fe24b7850ca 100644 --- a/tests/components/indevolt/test_sensor.py +++ b/tests/components/indevolt/test_sensor.py @@ -126,7 +126,6 @@ async def test_battery_pack_filtering_fetch_error( mock_config_entry: MockConfigEntry, mock_indevolt: AsyncMock, entity_registry: er.EntityRegistry, - caplog: pytest.LogCaptureFixture, ) -> None: """Test battery pack filtering when fetch fails.""" diff --git a/tests/components/indevolt/test_services.py b/tests/components/indevolt/test_services.py new file mode 100644 index 00000000000000..e32bf4bf30151b --- /dev/null +++ b/tests/components/indevolt/test_services.py @@ -0,0 +1,432 @@ +"""Tests for Indevolt services/actions.""" + +from unittest.mock import AsyncMock + +from indevolt_api import ( + IndevoltConfig, + IndevoltEnergyMode, + PowerExceedsMaxError, + SocBelowMinimumError, +) +import pytest + +from homeassistant.components.indevolt.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +def _get_device_id(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> str: + """Return the device registry ID for the given config entry.""" + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) + assert device_entry is not None + return device_entry.id + + +@pytest.mark.parametrize("generation", [2], indirect=True) +@pytest.mark.parametrize( + ("service_name", "power", "target_soc"), + [ + ("charge", 1200, 60), + ("discharge", 1200, 40), + ], +) +async def test_service_charge_discharge( + hass: HomeAssistant, + mock_indevolt: AsyncMock, + mock_config_entry: MockConfigEntry, + service_name: str, + power: int, + target_soc: int, +) -> None: + """Test charge and discharge services.""" + await setup_integration(hass, mock_config_entry) + + # Reset mock call count for this iteration + mock_indevolt.set_data.reset_mock() + + # Mock call to start service + await hass.services.async_call( + DOMAIN, + service_name, + { + "device_id": [_get_device_id(hass, mock_config_entry)], + "power": power, + "target_soc": target_soc, + }, + blocking=True, + ) + + # Verify energy mode switch and charge/discharge were called correctly + mock_indevolt.set_data.assert_called_once_with( + IndevoltConfig.WRITE_ENERGY_MODE, IndevoltEnergyMode.REAL_TIME_CONTROL + ) + if service_name == "charge": + mock_indevolt.charge.assert_called_once_with(power, target_soc) + else: + mock_indevolt.discharge.assert_called_once_with(power, target_soc) + + +@pytest.mark.parametrize("generation", [1], indirect=True) +@pytest.mark.parametrize( + ("service_name", "power", "target_soc"), + [ + ("charge", 1300, 60), + ("discharge", 1000, 20), + ], +) +async def test_service_power_too_high( + hass: HomeAssistant, + mock_indevolt: AsyncMock, + mock_config_entry: MockConfigEntry, + service_name: str, + power: int, + target_soc: int, +) -> None: + """Test charge and discharge service validation for max power.""" + await setup_integration(hass, mock_config_entry) + + # Configure the API mock to raise PowerExceedsMaxError for exceeded power + mock_indevolt.check_charge_limits.side_effect = PowerExceedsMaxError(power, 1200, 1) + mock_indevolt.check_discharge_limits.side_effect = PowerExceedsMaxError( + power, 800, 1 + ) + + # Mock call to start service (exceed max power for gen 1) + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + service_name, + { + "device_id": [_get_device_id(hass, mock_config_entry)], + "power": power, + "target_soc": target_soc, + }, + blocking=True, + ) + + # Verify correct translation key is used for the error + assert exc_info.value.translation_key == "power_exceeds_max" + + +@pytest.mark.parametrize("generation", [2], indirect=True) +@pytest.mark.parametrize("service_name", ["charge", "discharge"]) +async def test_service_target_soc_below_minimum( + hass: HomeAssistant, + mock_indevolt: AsyncMock, + mock_config_entry: MockConfigEntry, + service_name: str, +) -> None: + """Test charge and discharge service validation when SOC is below the library hard minimum.""" + await setup_integration(hass, mock_config_entry) + + # Configure the API mock to raise SocBelowMinimumError + mock_indevolt.check_charge_limits.side_effect = SocBelowMinimumError(3, 5, 2) + mock_indevolt.check_discharge_limits.side_effect = SocBelowMinimumError(3, 5, 2) + + # Mock call to start service (target SOC below hard minimum) + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + service_name, + { + "device_id": [_get_device_id(hass, mock_config_entry)], + "power": 500, + "target_soc": 3, + }, + blocking=True, + ) + + # Verify correct translation key is used for the error + assert exc_info.value.translation_key == "soc_below_minimum" + + +@pytest.mark.parametrize("generation", [2], indirect=True) +@pytest.mark.parametrize("service_name", ["charge", "discharge"]) +async def test_service_target_soc_below_emergency( + hass: HomeAssistant, + mock_indevolt: AsyncMock, + mock_config_entry: MockConfigEntry, + service_name: str, +) -> None: + """Test charge and discharge service validation for target SOC.""" + await setup_integration(hass, mock_config_entry) + + # Mock call to start service (target SOC below Emergency SOC (soft limit)) + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + service_name, + { + "device_id": [_get_device_id(hass, mock_config_entry)], + "power": 1000, + "target_soc": 1, + }, + blocking=True, + ) + + # Verify correct translation key is used for the error + assert exc_info.value.translation_key == "soc_below_emergency" + + +@pytest.mark.parametrize("generation", [2], indirect=True) +async def test_service_missing_target( + hass: HomeAssistant, + mock_indevolt: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test services fail when target does not resolve to an indevolt entry.""" + await setup_integration(hass, mock_config_entry) + + # Mock call with an unknown device ID + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + "discharge", + { + "device_id": ["non-existent-device-id"], + "power": 500, + "target_soc": 50, + }, + blocking=True, + ) + + # Verify correct translation key is used for the error + assert exc_info.value.translation_key == "no_matching_target_entries" + + +@pytest.mark.parametrize("generation", [2], indirect=True) +@pytest.mark.parametrize("alt_generation", [1], indirect=True) +@pytest.mark.parametrize( + ("service_name", "power", "target_soc"), + [ + ("charge", 1300, 60), + ("discharge", 1000, 20), + ], +) +async def test_multi_device_partial_validation_failure( + hass: HomeAssistant, + mock_indevolt: AsyncMock, + mock_config_entry: MockConfigEntry, + alt_mock_config_entry: MockConfigEntry, + service_name: str, + power: int, + target_soc: int, +) -> None: + """Test charge and discharge with two devices where only the gen 1 device fails power validation.""" + + # Set up multiple devices (gen 1 & gen 2) + await setup_integration(hass, mock_config_entry) + await setup_integration(hass, alt_mock_config_entry) + + # Configure the mock to raise PowerExceedsMaxError only for gen 1 devices + def raise_if_gen1_charge(p: int, soc: int, generation: int) -> None: + if generation == 1: + raise PowerExceedsMaxError(p, 1200, generation) + + def raise_if_gen1_discharge(p: int, soc: int, generation: int) -> None: + if generation == 1: + raise PowerExceedsMaxError(p, 800, generation) + + mock_indevolt.check_charge_limits.side_effect = raise_if_gen1_charge + mock_indevolt.check_discharge_limits.side_effect = raise_if_gen1_discharge + + # Mock call to start service on both devices (exceed max power for gen 1) + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + service_name, + { + "device_id": [ + _get_device_id(hass, mock_config_entry), + _get_device_id(hass, alt_mock_config_entry), + ], + "power": power, + "target_soc": target_soc, + }, + blocking=True, + ) + + # Confirm error references correct device (gen 1 fails, gen 2 does not) + assert exc_info.value.translation_key == "multi_device_errors" + errors = exc_info.value.translation_placeholders["errors"] + assert alt_mock_config_entry.title in errors + assert mock_config_entry.title not in errors + + +@pytest.mark.parametrize("generation", [2], indirect=True) +@pytest.mark.parametrize("alt_generation", [1], indirect=True) +@pytest.mark.parametrize("service_name", ["charge", "discharge"]) +async def test_multi_device_full_validation_failure( + hass: HomeAssistant, + mock_indevolt: AsyncMock, + mock_config_entry: MockConfigEntry, + alt_mock_config_entry: MockConfigEntry, + service_name: str, +) -> None: + """Test charge and discharge with two devices where both fail SOC validation.""" + + # Set up multiple devices (gen 1 & gen 2) + await setup_integration(hass, mock_config_entry) + await setup_integration(hass, alt_mock_config_entry) + + # Mock call to start service on both devices (target SOC < emergency SOC for both) + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + service_name, + { + "device_id": [ + _get_device_id(hass, mock_config_entry), + _get_device_id(hass, alt_mock_config_entry), + ], + "power": 100, + "target_soc": 1, + }, + blocking=True, + ) + + # Both device names should appear in the concatenated error message + assert exc_info.value.translation_key == "multi_device_errors" + errors = exc_info.value.translation_placeholders["errors"] + assert f"{mock_config_entry.title}: " in errors + assert f"{alt_mock_config_entry.title}: " in errors + + +@pytest.mark.parametrize("generation", [2], indirect=True) +async def test_charge_outdoor_portable( + hass: HomeAssistant, + mock_indevolt: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test charge service fails when device is in outdoor/portable mode.""" + await setup_integration(hass, mock_config_entry) + + # Force outdoor/portable mode + coordinator = mock_config_entry.runtime_data + coordinator.data[IndevoltConfig.READ_ENERGY_MODE] = ( + IndevoltEnergyMode.OUTDOOR_PORTABLE + ) + + # Mock call to start charging (device in outdoor/portable mode) + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + "charge", + { + "device_id": [_get_device_id(hass, mock_config_entry)], + "power": 500, + "target_soc": 100, + }, + blocking=True, + ) + + # Verify correct translation key is used for the error + assert ( + exc_info.value.translation_key + == "energy_mode_change_unavailable_outdoor_portable" + ) + + +@pytest.mark.parametrize("generation", [2], indirect=True) +async def test_service_charge_missing_energy_mode( + hass: HomeAssistant, + mock_indevolt: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test charge fails when current energy mode cannot be retrieved.""" + await setup_integration(hass, mock_config_entry) + + # Remove current energy mode value + coordinator = mock_config_entry.runtime_data + del coordinator.data[IndevoltConfig.READ_ENERGY_MODE] + + # Mock call to start charging (current energy mode unknown) + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + "charge", + { + "device_id": [_get_device_id(hass, mock_config_entry)], + "power": 500, + "target_soc": 80, + }, + blocking=True, + ) + + # Verify correct translation key is used for the error + assert exc_info.value.translation_key == "failed_to_retrieve_current_energy_mode" + + +@pytest.mark.parametrize("generation", [2], indirect=True) +async def test_single_device_execution_failure( + hass: HomeAssistant, + mock_indevolt: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the original exception is re-raised for a single device execution failure.""" + await setup_integration(hass, mock_config_entry) + + # Simulate an API push failure + mock_indevolt.set_data.side_effect = HomeAssistantError("Device push failed") + + # Mock call to start charging + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + "charge", + { + "device_id": [_get_device_id(hass, mock_config_entry)], + "power": 500, + "target_soc": 80, + }, + blocking=True, + ) + + # Verify correct translation key is used for the error (for single coordinator) + assert str(exc_info.value) == "Device push failed" + assert exc_info.value.translation_key is None + + +@pytest.mark.parametrize("generation", [2], indirect=True) +@pytest.mark.parametrize("alt_generation", [1], indirect=True) +async def test_multi_device_execution_failure( + hass: HomeAssistant, + mock_indevolt: AsyncMock, + mock_config_entry: MockConfigEntry, + alt_mock_config_entry: MockConfigEntry, +) -> None: + """Test that multi_device_errors is raised when execution fails for multiple devices.""" + + # Set up multiple devices (gen 1 & gen 2) + await setup_integration(hass, mock_config_entry) + await setup_integration(hass, alt_mock_config_entry) + + # Simulate an API push failure (triggers for both coordinators) + mock_indevolt.set_data.side_effect = HomeAssistantError("Device push failed") + + # Mock call to start charging both devices + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + "charge", + { + "device_id": [ + _get_device_id(hass, mock_config_entry), + _get_device_id(hass, alt_mock_config_entry), + ], + "power": 500, + "target_soc": 80, + }, + blocking=True, + ) + + # Verify correct translation key is used for the error (for multiple coordinators) + assert exc_info.value.translation_key == "multi_device_errors" diff --git a/tests/components/indevolt/test_switch.py b/tests/components/indevolt/test_switch.py index 62df9234a259e7..577d897ae5c64b 100644 --- a/tests/components/indevolt/test_switch.py +++ b/tests/components/indevolt/test_switch.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory +from indevolt_api import IndevoltConfig import pytest from syrupy.assertion import SnapshotAssertion @@ -18,15 +19,6 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform -KEY_READ_GRID_CHARGING = "2618" -KEY_WRITE_GRID_CHARGING = "1143" - -KEY_READ_LIGHT = "7171" -KEY_WRITE_LIGHT = "7265" - -KEY_READ_BYPASS = "680" -KEY_WRITE_BYPASS = "7266" - DEFAULT_STATE_ON = 1 DEFAULT_STATE_OFF = 0 @@ -53,20 +45,20 @@ async def test_switch( [ ( "switch.cms_sf2000_allow_grid_charging", - KEY_READ_GRID_CHARGING, - KEY_WRITE_GRID_CHARGING, + IndevoltConfig.READ_GRID_CHARGING, + IndevoltConfig.WRITE_GRID_CHARGING, 1001, ), ( "switch.cms_sf2000_led_indicator", - KEY_READ_LIGHT, - KEY_WRITE_LIGHT, + IndevoltConfig.READ_LIGHT, + IndevoltConfig.WRITE_LIGHT, DEFAULT_STATE_ON, ), ( "switch.cms_sf2000_bypass_socket", - KEY_READ_BYPASS, - KEY_WRITE_BYPASS, + IndevoltConfig.READ_BYPASS, + IndevoltConfig.WRITE_BYPASS, DEFAULT_STATE_ON, ), ], @@ -112,20 +104,20 @@ async def test_switch_turn_on( [ ( "switch.cms_sf2000_allow_grid_charging", - KEY_READ_GRID_CHARGING, - KEY_WRITE_GRID_CHARGING, + IndevoltConfig.READ_GRID_CHARGING, + IndevoltConfig.WRITE_GRID_CHARGING, 1000, ), ( "switch.cms_sf2000_led_indicator", - KEY_READ_LIGHT, - KEY_WRITE_LIGHT, + IndevoltConfig.READ_LIGHT, + IndevoltConfig.WRITE_LIGHT, DEFAULT_STATE_OFF, ), ( "switch.cms_sf2000_bypass_socket", - KEY_READ_BYPASS, - KEY_WRITE_BYPASS, + IndevoltConfig.READ_BYPASS, + IndevoltConfig.WRITE_BYPASS, DEFAULT_STATE_OFF, ), ], diff --git a/tests/components/intent_script/test_init.py b/tests/components/intent_script/test_init.py index e6276151894185..22c9c805eaa6d4 100644 --- a/tests/components/intent_script/test_init.py +++ b/tests/components/intent_script/test_init.py @@ -5,7 +5,7 @@ import pytest from homeassistant import config as hass_config -from homeassistant.components.intent_script import DOMAIN +from homeassistant.components.intent_script import CONF_ACTION, DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME, SERVICE_RELOAD from homeassistant.core import HomeAssistant from homeassistant.helpers import ( @@ -13,6 +13,7 @@ entity_registry as er, floor_registry as fr, intent, + script, ) from homeassistant.setup import async_setup_component @@ -462,3 +463,38 @@ async def test_reload(hass: HomeAssistant) -> None: assert len(intents) == 0 assert intents.get("NewIntent1") is None assert intents.get("NewIntent2") is None + + +async def test_reload_unloads_scripts(hass: HomeAssistant) -> None: + """Test that reloading intent scripts unloads the action scripts.""" + await async_setup_component( + hass, + "intent_script", + { + "intent_script": { + "TestIntent": { + "action": {"service": "test.service"}, + } + } + }, + ) + + existing_intents = hass.data[DOMAIN] + action_script = existing_intents["TestIntent"][CONF_ACTION] + assert isinstance(action_script, script.Script) + + yaml_path = get_fixture_path("configuration_no_entry.yaml", "intent_script") + with ( + patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path), + patch.object( + action_script, "async_stop", wraps=action_script.async_stop + ) as stop_mock, + patch.object( + action_script, "async_unload", wraps=action_script.async_unload + ) as unload_mock, + ): + await hass.services.async_call(DOMAIN, SERVICE_RELOAD, blocking=True) + await hass.async_block_till_done() + + stop_mock.assert_called_once() + unload_mock.assert_called_once() diff --git a/tests/components/israel_rail/conftest.py b/tests/components/israel_rail/conftest.py index 07a101d40c7cb5..1c780a27183451 100644 --- a/tests/components/israel_rail/conftest.py +++ b/tests/components/israel_rail/conftest.py @@ -71,21 +71,23 @@ def get_train_route( dest_platform: str = "2", origin_station: str = "3500", destination_station: str = "3700", + departure_delay: int | None = None, ) -> TrainRoute: """Build a TrainRoute of the israelrail API.""" - return TrainRoute( - [ - { - "orignStation": origin_station, - "destinationStation": destination_station, - "departureTime": departure_time, - "arrivalTime": arrival_time, - "originPlatform": origin_platform, - "destPlatform": dest_platform, - "trainNumber": train_number, - } + train_data: dict = { + "orignStation": origin_station, + "destinationStation": destination_station, + "departureTime": departure_time, + "arrivalTime": arrival_time, + "originPlatform": origin_platform, + "destPlatform": dest_platform, + "trainNumber": train_number, + } + if departure_delay is not None: + train_data["etaDiffTimes"] = [ + {"stationId": origin_station, "difMin": departure_delay} ] - ) + return TrainRoute([train_data]) TRAINS = [ diff --git a/tests/components/israel_rail/snapshots/test_sensor.ambr b/tests/components/israel_rail/snapshots/test_sensor.ambr index 448cfc173efadc..a094feae95327e 100644 --- a/tests/components/israel_rail/snapshots/test_sensor.ambr +++ b/tests/components/israel_rail/snapshots/test_sensor.ambr @@ -155,6 +155,65 @@ 'state': '2021-10-10T10:30:10+00:00', }) # --- +# name: test_valid_config[sensor.mock_title_departure_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_departure_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Departure delay', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Departure delay', + 'platform': 'israel_rail', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'departure_delay', + 'unique_id': 'באר יעקב אשקלון_departure_delay', + 'unit_of_measurement': , + }) +# --- +# name: test_valid_config[sensor.mock_title_departure_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Israel rail.', + 'device_class': 'duration', + 'friendly_name': 'Mock Title Departure delay', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_departure_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_valid_config[sensor.mock_title_platform-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/israel_rail/test_sensor.py b/tests/components/israel_rail/test_sensor.py index a1bf00472d9059..b1322d237e7716 100644 --- a/tests/components/israel_rail/test_sensor.py +++ b/tests/components/israel_rail/test_sensor.py @@ -12,7 +12,7 @@ from homeassistant.helpers import entity_registry as er from . import goto_future, init_integration -from .conftest import TRAINS, get_time +from .conftest import TRAINS, get_time, get_train_route from tests.common import MockConfigEntry, snapshot_platform @@ -37,7 +37,7 @@ async def test_update_train( ) -> None: """Ensure the train data is updated.""" await init_integration(hass, mock_config_entry) - assert len(hass.states.async_entity_ids()) == 6 + assert len(hass.states.async_entity_ids()) == 7 departure_sensor = hass.states.get("sensor.mock_title_departure") expected_time = get_time(10, 10) assert departure_sensor.state == expected_time @@ -46,7 +46,7 @@ async def test_update_train( await goto_future(hass, freezer) - assert len(hass.states.async_entity_ids()) == 6 + assert len(hass.states.async_entity_ids()) == 7 departure_sensor = hass.states.get("sensor.mock_title_departure") expected_time = get_time(10, 20) assert departure_sensor.state == expected_time @@ -60,10 +60,10 @@ async def test_fail_query( ) -> None: """Ensure the integration handles query failures.""" await init_integration(hass, mock_config_entry) - assert len(hass.states.async_entity_ids()) == 6 + assert len(hass.states.async_entity_ids()) == 7 mock_israelrail.query.side_effect = Exception("error") await goto_future(hass, freezer) - assert len(hass.states.async_entity_ids()) == 6 + assert len(hass.states.async_entity_ids()) == 7 departure_sensor = hass.states.get("sensor.mock_title_departure") assert departure_sensor.state == STATE_UNAVAILABLE @@ -76,7 +76,7 @@ async def test_no_departures( ) -> None: """Test handling when there are no departures available.""" await init_integration(hass, mock_config_entry) - assert len(hass.states.async_entity_ids()) == 6 + assert len(hass.states.async_entity_ids()) == 7 # Simulate no departures (e.g., after-hours) mock_israelrail.query.return_value = [] @@ -84,7 +84,7 @@ async def test_no_departures( await goto_future(hass, freezer) # All sensors should still exist - assert len(hass.states.async_entity_ids()) == 6 + assert len(hass.states.async_entity_ids()) == 7 # Departure sensors should have unknown state (None) departure_sensor = hass.states.get("sensor.mock_title_departure") @@ -106,3 +106,35 @@ async def test_no_departures( train_number_sensor = hass.states.get("sensor.mock_title_train_number") assert train_number_sensor.state == STATE_UNKNOWN + + departure_delay_sensor = hass.states.get("sensor.mock_title_departure_delay") + assert departure_delay_sensor.state == STATE_UNKNOWN + + +async def test_departure_delay( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_israelrail: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Ensure departure_delay is exposed as a sensor.""" + await init_integration(hass, mock_config_entry) + + departure_delay_sensor = hass.states.get("sensor.mock_title_departure_delay") + assert departure_delay_sensor is not None + assert departure_delay_sensor.state == "0" + + mock_israelrail.query.return_value = [ + get_train_route( + train_number="1234", + departure_time=get_time(10, 10), + arrival_time=get_time(10, 30), + departure_delay=7, + ), + *TRAINS[1:], + ] + + await goto_future(hass, freezer) + + departure_delay_sensor = hass.states.get("sensor.mock_title_departure_delay") + assert departure_delay_sensor.state == "7" diff --git a/tests/components/isy994/conftest.py b/tests/components/isy994/conftest.py new file mode 100644 index 00000000000000..3cf24445914e42 --- /dev/null +++ b/tests/components/isy994/conftest.py @@ -0,0 +1,97 @@ +"""Fixtures for the ISY994 tests.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from pyisy.nodes import Node +import pytest + +from homeassistant.components.isy994.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +MOCK_UUID = "00:00:00:00:00:00" + + +@pytest.fixture +def mock_config_entry(): + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "http://1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + unique_id=MOCK_UUID, + ) + + +@pytest.fixture +def mock_isy(): + """Return a mock ISY object.""" + mock = MagicMock() + mock.nodes = MagicMock() + mock.nodes.__iter__.return_value = [] + mock.nodes.status_events = MagicMock() + mock.programs = MagicMock() + mock.programs.get_by_name.return_value = None + mock.variables = MagicMock() + mock.variables.children = [] + mock.networking = MagicMock() + mock.networking.nobjs = [] + mock.clock = MagicMock() + mock.websocket = MagicMock() + mock.conf = { + "name": "Skynet ISY", + "model": "IoX", + "firmware": "6.0.4", + "Networking Module": True, + "Portal": True, + } + mock.uuid = MOCK_UUID + mock.conn.url = "http://1.1.1.1:80" + mock.initialize = AsyncMock() + return mock + + +@pytest.fixture +def mock_node(): + """Return a mock ISY node.""" + + def _mock_node(isy, address, name, node_def_id, node_type=None): + node = MagicMock(spec=Node) + node.isy = isy + node.address = address + node.name = name + node.node_def_id = node_def_id + node.type = node_type + node.status = 0 + node.uom = None + node.prec = 0 + node.protocol = "insteon" + node.folder = None + node.parent_node = None + node.primary_node = address + node.aux_properties = {} + node.status_events = MagicMock() + node.status_events.subscribe.return_value = MagicMock() + node.control_events = MagicMock() + node.control_events.subscribe.return_value = MagicMock() + node.is_backlight_supported = False + return node + + return _mock_node + + +@pytest.fixture(autouse=True) +def mock_isy_init(mock_isy): + """Mock pyisy.ISY initialization.""" + with ( + patch("homeassistant.components.isy994.ISY", return_value=mock_isy), + patch( + "homeassistant.components.isy994.config_flow.Connection.test_connection", + return_value="", + ), + ): + yield mock_isy diff --git a/tests/components/isy994/snapshots/test_sensor.ambr b/tests/components/isy994/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..3c8c7a5415bb5f --- /dev/null +++ b/tests/components/isy994/snapshots/test_sensor.ambr @@ -0,0 +1,1348 @@ +# serializer version: 1 +# name: test_sensor_snapshots[sensor.energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 5', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshots[sensor.energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123456', + }) +# --- +# name: test_sensor_snapshots[sensor.energy_energy_device_communication_errors-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.energy_energy_device_communication_errors', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy Device Communication Errors', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Energy Device Communication Errors', + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 5_ERR', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshots[sensor.energy_energy_device_communication_errors-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Energy Energy Device Communication Errors', + }), + 'context': , + 'entity_id': 'sensor.energy_energy_device_communication_errors', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_snapshots[sensor.flow_aux_list-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flow_aux_list', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshots[sensor.flow_aux_list-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Flow Aux List', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flow_aux_list', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_snapshots[sensor.flow_aux_list_flow_aux_list_device_communication_errors-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.flow_aux_list_flow_aux_list_device_communication_errors', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Flow Aux List Device Communication Errors', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flow Aux List Device Communication Errors', + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 11_ERR', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshots[sensor.flow_aux_list_flow_aux_list_device_communication_errors-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Flow Aux List Flow Aux List Device Communication Errors', + }), + 'context': , + 'entity_id': 'sensor.flow_aux_list_flow_aux_list_device_communication_errors', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_snapshots[sensor.flow_aux_list_flow_aux_list_flow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flow_aux_list_flow_aux_list_flow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Flow Aux List Flow', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flow Aux List Flow', + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 11_FLOW', + 'unit_of_measurement': 'gal/s', + }) +# --- +# name: test_sensor_snapshots[sensor.flow_aux_list_flow_aux_list_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Flow Aux List Flow Aux List Flow', + 'unit_of_measurement': 'gal/s', + }), + 'context': , + 'entity_id': 'sensor.flow_aux_list_flow_aux_list_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_sensor_snapshots[sensor.flow_rate_gph-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flow_rate_gph', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshots[sensor.flow_rate_gph-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Flow Rate GPH', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flow_rate_gph', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '300', + }) +# --- +# name: test_sensor_snapshots[sensor.flow_rate_gph_flow_rate_gph_device_communication_errors-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.flow_rate_gph_flow_rate_gph_device_communication_errors', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Flow Rate GPH Device Communication Errors', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flow Rate GPH Device Communication Errors', + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 3_ERR', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshots[sensor.flow_rate_gph_flow_rate_gph_device_communication_errors-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Flow Rate GPH Flow Rate GPH Device Communication Errors', + }), + 'context': , + 'entity_id': 'sensor.flow_rate_gph_flow_rate_gph_device_communication_errors', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_snapshots[sensor.flow_rate_gpm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flow_rate_gpm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshots[sensor.flow_rate_gpm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Flow Rate GPM', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flow_rate_gpm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_sensor_snapshots[sensor.flow_rate_gpm_flow_rate_gpm_device_communication_errors-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.flow_rate_gpm_flow_rate_gpm_device_communication_errors', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Flow Rate GPM Device Communication Errors', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flow Rate GPM Device Communication Errors', + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 2_ERR', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshots[sensor.flow_rate_gpm_flow_rate_gpm_device_communication_errors-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Flow Rate GPM Flow Rate GPM Device Communication Errors', + }), + 'context': , + 'entity_id': 'sensor.flow_rate_gpm_flow_rate_gpm_device_communication_errors', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_snapshots[sensor.flow_rate_gps-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flow_rate_gps', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 9', + 'unit_of_measurement': 'gal/s', + }) +# --- +# name: test_sensor_snapshots[sensor.flow_rate_gps-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Flow Rate GPS', + 'unit_of_measurement': 'gal/s', + }), + 'context': , + 'entity_id': 'sensor.flow_rate_gps', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_sensor_snapshots[sensor.flow_rate_gps_flow_rate_gps_device_communication_errors-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.flow_rate_gps_flow_rate_gps_device_communication_errors', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Flow Rate GPS Device Communication Errors', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flow Rate GPS Device Communication Errors', + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 9_ERR', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshots[sensor.flow_rate_gps_flow_rate_gps_device_communication_errors-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Flow Rate GPS Flow Rate GPS Device Communication Errors', + }), + 'context': , + 'entity_id': 'sensor.flow_rate_gps_flow_rate_gps_device_communication_errors', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_snapshots[sensor.flow_rate_gps_list-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flow_rate_gps_list', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 10', + 'unit_of_measurement': 'gal/s', + }) +# --- +# name: test_sensor_snapshots[sensor.flow_rate_gps_list-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Flow Rate GPS List', + 'unit_of_measurement': 'gal/s', + }), + 'context': , + 'entity_id': 'sensor.flow_rate_gps_list', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_sensor_snapshots[sensor.flow_rate_gps_list_flow_rate_gps_list_device_communication_errors-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.flow_rate_gps_list_flow_rate_gps_list_device_communication_errors', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Flow Rate GPS List Device Communication Errors', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flow Rate GPS List Device Communication Errors', + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 10_ERR', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshots[sensor.flow_rate_gps_list_flow_rate_gps_list_device_communication_errors-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Flow Rate GPS List Flow Rate GPS List Device Communication Errors', + }), + 'context': , + 'entity_id': 'sensor.flow_rate_gps_list_flow_rate_gps_list_device_communication_errors', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_snapshots[sensor.flow_rate_lph-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flow_rate_lph', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshots[sensor.flow_rate_lph-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Flow Rate LPH', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flow_rate_lph', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_sensor_snapshots[sensor.flow_rate_lph_flow_rate_lph_device_communication_errors-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.flow_rate_lph_flow_rate_lph_device_communication_errors', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Flow Rate LPH Device Communication Errors', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flow Rate LPH Device Communication Errors', + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 1_ERR', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshots[sensor.flow_rate_lph_flow_rate_lph_device_communication_errors-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Flow Rate LPH Flow Rate LPH Device Communication Errors', + }), + 'context': , + 'entity_id': 'sensor.flow_rate_lph_flow_rate_lph_device_communication_errors', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_snapshots[sensor.power_node-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_node', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 8', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshots[sensor.power_node-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power Node', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_node', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_snapshots[sensor.power_node_power_node_device_communication_errors-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.power_node_power_node_device_communication_errors', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power Node Device Communication Errors', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power Node Device Communication Errors', + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 8_ERR', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshots[sensor.power_node_power_node_device_communication_errors-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Power Node Power Node Device Communication Errors', + }), + 'context': , + 'entity_id': 'sensor.power_node_power_node_device_communication_errors', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_snapshots[sensor.power_node_power_node_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_node_power_node_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power Node Power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power Node Power', + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 8_CPW', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshots[sensor.power_node_power_node_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power Node Power Node Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_node_power_node_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '250', + }) +# --- +# name: test_sensor_snapshots[sensor.power_node_power_node_total_energy_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_node_power_node_total_energy_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power Node Total Energy Used', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power Node Total Energy Used', + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 8_TPW', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshots[sensor.power_node_power_node_total_energy_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Power Node Power Node Total Energy Used', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_node_power_node_total_energy_used', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50000', + }) +# --- +# name: test_sensor_snapshots[sensor.rain_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rain_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 6', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshots[sensor.rain_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'precipitation_intensity', + 'friendly_name': 'Rain Rate', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.rain_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.48', + }) +# --- +# name: test_sensor_snapshots[sensor.rain_rate_rain_rate_device_communication_errors-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rain_rate_rain_rate_device_communication_errors', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Rain Rate Device Communication Errors', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Rain Rate Device Communication Errors', + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 6_ERR', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshots[sensor.rain_rate_rain_rate_device_communication_errors-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rain Rate Rain Rate Device Communication Errors', + }), + 'context': , + 'entity_id': 'sensor.rain_rate_rain_rate_device_communication_errors', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_snapshots[sensor.temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshots[sensor.temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.5', + }) +# --- +# name: test_sensor_snapshots[sensor.temperature_temperature_device_communication_errors-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.temperature_temperature_device_communication_errors', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature Device Communication Errors', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature Device Communication Errors', + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 4_ERR', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshots[sensor.temperature_temperature_device_communication_errors-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Temperature Temperature Device Communication Errors', + }), + 'context': , + 'entity_id': 'sensor.temperature_temperature_device_communication_errors', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_snapshots[sensor.water_meter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 7', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshots[sensor.water_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Water Meter', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37384.726778784', + }) +# --- +# name: test_sensor_snapshots[sensor.water_meter_water_meter_device_communication_errors-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.water_meter_water_meter_device_communication_errors', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Water Meter Device Communication Errors', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water Meter Device Communication Errors', + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_22 22 22 7_ERR', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshots[sensor.water_meter_water_meter_device_communication_errors-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Meter Water Meter Device Communication Errors', + }), + 'context': , + 'entity_id': 'sensor.water_meter_water_meter_device_communication_errors', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/isy994/test_sensor.py b/tests/components/isy994/test_sensor.py new file mode 100644 index 00000000000000..538fd8da129c64 --- /dev/null +++ b/tests/components/isy994/test_sensor.py @@ -0,0 +1,139 @@ +"""Test the ISY994 sensor platform.""" + +from collections.abc import Callable +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def mock_sensor_platform(): + """Mock the platforms to only include sensor.""" + with patch("homeassistant.components.isy994.PLATFORMS", [Platform.SENSOR]): + yield + + +async def test_sensor_snapshots( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_isy: MagicMock, + mock_node: Callable[..., Any], +) -> None: + """Test sensors with snapshots.""" + mock_config_entry.add_to_hass(hass) + + # Mock nodes covering various UOMs and device classes + nodes = [] + + # Standardized UOMs + # Node 1: Liters per Hour + node1 = mock_node(mock_isy, "22 22 22 1", "Flow Rate LPH", "GenericSensor") + node1.status = 1000 + node1.uom = "130" + node1.prec = "1" + nodes.append(("Sensors/Flow Rate LPH", node1)) + + # Node 2: Gallons per Minute + node2 = mock_node(mock_isy, "22 22 22 2", "Flow Rate GPM", "GenericSensor") + node2.status = 50 + node2.uom = "143" + node2.prec = "1" + nodes.append(("Sensors/Flow Rate GPM", node2)) + + # Node 3: Gallons per Hour + node3 = mock_node(mock_isy, "22 22 22 3", "Flow Rate GPH", "GenericSensor") + node3.status = 300 + node3.uom = "144" + node3.prec = "0" + nodes.append(("Sensors/Flow Rate GPH", node3)) + + # Node 9: Gallons per Second (142) - Should have NO device_class due to guard + node9 = mock_node(mock_isy, "22 22 22 9", "Flow Rate GPS", "GenericSensor") + node9.status = 1 + node9.uom = "142" + node9.prec = "1" + nodes.append(("Sensors/Flow Rate GPS", node9)) + + # Node 10: Gallons per Second (142) in ISYv4 list form - guard must still apply + node10 = mock_node(mock_isy, "22 22 22 10", "Flow Rate GPS List", "GenericSensor") + node10.status = 2 + node10.uom = ["142"] + node10.prec = "1" + nodes.append(("Sensors/Flow Rate GPS List", node10)) + + # Other UOMs from test_mappings + # Temperature (4) + node4 = mock_node(mock_isy, "22 22 22 4", "Temperature", "GenericSensor") + node4.status = 215 + node4.uom = "4" + node4.prec = "1" + nodes.append(("Sensors/Temperature", node4)) + + # Energy (33) - TOTAL_INCREASING + node5 = mock_node(mock_isy, "22 22 22 5", "Energy", "GenericSensor") + node5.status = 123456 + node5.uom = "33" + node5.prec = "0" + nodes.append(("Sensors/Energy", node5)) + + # Precipitation Intensity (24) + node6 = mock_node(mock_isy, "22 22 22 6", "Rain Rate", "GenericSensor") + node6.status = 12 + node6.uom = "24" + node6.prec = "1" + nodes.append(("Sensors/Rain Rate", node6)) + + # Water (69) + node7 = mock_node(mock_isy, "22 22 22 7", "Water Meter", "GenericSensor") + node7.status = 9876 + node7.uom = "69" + node7.prec = "0" + nodes.append(("Sensors/Water Meter", node7)) + + # Aux Properties (TPW, CPW) + node8 = mock_node(mock_isy, "22 22 22 8", "Power Node", "GenericSensor") + node8.status = 0 + node8.uom = "73" # Watts + node8.aux_properties = { + "TPW": MagicMock(value=50000, uom="33", prec="0"), # Total Power (Energy) + "CPW": MagicMock(value=250, uom="73", prec="0"), # Current Power + } + nodes.append(("Sensors/Power Node", node8)) + + # Aux FLOW with ISYv4 list-form UOM 142 (gal/s) - guard must clear + # device_class without raising TypeError on the unhashable list. + node11 = mock_node(mock_isy, "22 22 22 11", "Flow Aux List", "GenericSensor") + node11.status = 0 + node11.uom = "73" + node11.aux_properties = { + "FLOW": MagicMock(value=1, uom=["142"], prec="1"), + } + nodes.append(("Sensors/Flow Aux List", node11)) + + mock_isy.nodes.__iter__.return_value = nodes + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Enable disabled entities (like aux sensors) + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for entry in entity_entries: + if entry.disabled_by: + entity_registry.async_update_entity(entry.entity_id, disabled_by=None) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/izone/conftest.py b/tests/components/izone/conftest.py index 7f0bb092b4071b..61b6ac2637db4b 100644 --- a/tests/components/izone/conftest.py +++ b/tests/components/izone/conftest.py @@ -99,6 +99,7 @@ async def mock_discovery( mock_disco.return_value.controllers = { mock_controller.device_uid: mock_controller } + mock_disco.return_value.close = AsyncMock() yield mock_disco diff --git a/tests/components/jvc_projector/test_coordinator.py b/tests/components/jvc_projector/test_coordinator.py index fd1eddb83d88dd..efe069c2cf3162 100644 --- a/tests/components/jvc_projector/test_coordinator.py +++ b/tests/components/jvc_projector/test_coordinator.py @@ -3,7 +3,11 @@ from datetime import timedelta from unittest.mock import AsyncMock -from jvcprojector import JvcProjectorTimeoutError, command as cmd +from jvcprojector import ( + JvcProjectorCommandError, + JvcProjectorTimeoutError, + command as cmd, +) import pytest from homeassistant.components.jvc_projector.coordinator import ( @@ -11,6 +15,7 @@ INTERVAL_SLOW, ) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow @@ -58,3 +63,42 @@ async def test_coordinator_setup_connect_error( ) -> None: """Test coordinator connect error.""" assert mock_integration.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + "mock_device", + [{"fixture_override": {cmd.Power: JvcProjectorCommandError}}], + indirect=True, +) +async def test_coordinator_setup_power_command_error( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_integration: MockConfigEntry, +) -> None: + """Test coordinator fails setup when Power command errors with no cached value.""" + assert mock_integration.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + "mock_device", + [{"fixture_override": {cmd.Input: JvcProjectorCommandError}}], + indirect=True, +) +async def test_coordinator_command_error_keeps_other_entities_available( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_integration: MockConfigEntry, +) -> None: + """Test a failing command does not take every entity offline.""" + assert mock_integration.state is ConfigEntryState.LOADED + + coordinator = mock_integration.runtime_data + assert coordinator.last_update_success is True + + power = hass.states.get("sensor.jvc_projector_status") + assert power is not None + assert power.state == "on" + + light_time = hass.states.get("sensor.jvc_projector_light_time") + assert light_time is not None + assert light_time.state != STATE_UNAVAILABLE diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 357d859cdd6368..c14d0aed249231 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -97,6 +97,7 @@ async def patch_xknx_start(): state=XknxConnectionState.CONNECTED, connection_type=XknxConnectionType.TUNNEL_TCP, ) + await self.hass.async_block_till_done() def knx_ip_interface_mock(): """Create a xknx knx ip interface mock.""" diff --git a/tests/components/knx/fixtures/config_store_expose.json b/tests/components/knx/fixtures/config_store_expose.json new file mode 100644 index 00000000000000..478d12dede3812 --- /dev/null +++ b/tests/components/knx/fixtures/config_store_expose.json @@ -0,0 +1,31 @@ +{ + "version": 2, + "minor_version": 4, + "key": "knx/config_store.json", + "data": { + "entities": {}, + "expose": { + "cover.test": { + "options": [ + { + "ga": { + "write": "1/1/1", + "dpt": "1.001" + } + }, + { + "ga": { + "write": "2/2/2", + "dpt": "5.001" + }, + "attribute": "current_position", + "value_template": "{{ 100 - value }}", + "cooldown": 5.0 + } + ], + "notes": "Invert cover position for KNX uses 0: open" + } + }, + "time_server": {} + } +} diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index cfc5ec81064dcd..ee0ebce81ab227 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -1,4 +1,44 @@ # serializer version: 1 +# name: test_knx_get_expose_config + dict({ + 'id': 1, + 'result': dict({ + 'notes': 'Invert cover position for KNX uses 0: open', + 'options': list([ + dict({ + 'ga': dict({ + 'dpt': '1.001', + 'write': '1/1/1', + }), + }), + dict({ + 'attribute': 'current_position', + 'cooldown': 5.0, + 'ga': dict({ + 'dpt': '5.001', + 'write': '2/2/2', + }), + 'value_template': '{{ 100 - value }}', + }), + ]), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_knx_get_expose_groups + dict({ + 'id': 1, + 'result': dict({ + 'cover.test': list([ + '1/1/1', + '2/2/2', + ]), + }), + 'success': True, + 'type': 'result', + }) +# --- # name: test_knx_get_schema[binary_sensor] dict({ 'id': 1, @@ -2216,6 +2256,7 @@ 'options': list([ 'date', 'timestamp', + 'uptime', 'absolute_humidity', 'apparent_power', 'aqi', diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index 75d9165ce8e6bc..891ccd5c766741 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -468,13 +468,13 @@ async def test_update_expose_error( { "type": "knx/update_expose", "entity_id": "switch.test", - "options": [{"ga": {"dpt": "1.001"}}], + "data": {"options": [{"ga": {"dpt": "1.001"}}]}, } ) res = await client.receive_json() assert res["success"], res assert res["result"]["success"] is False - assert res["result"]["errors"][0]["path"] == ["options", "0", "ga", "write"] + assert res["result"]["errors"][0]["path"] == ["data", "options", "0", "ga", "write"] assert res["result"]["errors"][0]["error_message"] == "required key not provided" @@ -491,7 +491,7 @@ async def test_validate_expose( { "type": "knx/validate_expose", "entity_id": "switch.test", - "options": [{"ga": {"write": "1/2/3", "dpt": "1.001"}}], + "data": {"options": [{"ga": {"write": "1/2/3", "dpt": "1.001"}}]}, } ) res = await client.receive_json() @@ -502,13 +502,13 @@ async def test_validate_expose( { "type": "knx/validate_expose", "entity_id": "switch.test", - "options": [{"ga": {"write": "1/2/3", "dpt": "invalid"}}], + "data": {"options": [{"ga": {"write": "1/2/3", "dpt": "invalid"}}]}, } ) res = await client.receive_json() assert res["success"], res assert res["result"]["success"] is False - assert res["result"]["errors"][0]["path"] == ["options", "0", "ga", "dpt"] + assert res["result"]["errors"][0]["path"] == ["data", "options", "0", "ga", "dpt"] async def test_delete_expose( @@ -523,13 +523,13 @@ async def test_delete_expose( await knx.setup_integration() client = await hass_ws_client(hass) - expose_options = [{"ga": {"write": "2/2/2", "dpt": "1.001"}}] + expose_options = {"options": [{"ga": {"write": "2/2/2", "dpt": "1.001"}}]} await client.send_json_auto_id( { "type": "knx/update_expose", "entity_id": ENTITY_ID, - "options": expose_options, + "data": expose_options, } ) res = await client.receive_json() diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index f0b2459349c149..3e28af8b6a36e3 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -401,7 +401,16 @@ async def test_ui_expose_create_and_update( { "type": "knx/update_expose", "entity_id": ENTITY_ID, - "options": [{"ga": {"write": GROUP_ADDRESS_1, "dpt": "1.001"}}], + "data": { + "options": [ + { + "ga": { + "write": GROUP_ADDRESS_1, + "dpt": "1.001", + } + } + ], + }, } ) res = await ws_client.receive_json() @@ -418,13 +427,16 @@ async def test_ui_expose_create_and_update( { "type": "knx/update_expose", "entity_id": ENTITY_ID, - "options": [ - {"ga": {"write": GROUP_ADDRESS_1, "dpt": "1.001"}}, - { - "ga": {"write": GROUP_ADDRESS_2, "dpt": "5.001"}, - "attribute": "brightness", - }, - ], + "data": { + "options": [ + {"ga": {"write": GROUP_ADDRESS_1, "dpt": "1.001"}}, + { + "ga": {"write": GROUP_ADDRESS_2, "dpt": "5.001"}, + "attribute": "brightness", + }, + ], + "notes": "This is a note", + }, } ) res = await ws_client.receive_json() @@ -455,17 +467,19 @@ async def test_ui_expose_with_options( { "type": "knx/update_expose", "entity_id": ENTITY_ID, - "options": [ - { - "ga": {"write": GROUP_ADDRESS_1, "dpt": "5.010"}, - "attribute": "brightness", - "cooldown": 2.5, - "default": 0, - "periodic_send": 60.0, - "respond_to_read": False, - "value_template": "{{ 50 if value >= 50 else 1 }}", - } - ], + "data": { + "options": [ + { + "ga": {"write": GROUP_ADDRESS_1, "dpt": "5.010"}, + "attribute": "brightness", + "cooldown": 2.5, + "default": 0, + "periodic_send": 60.0, + "respond_to_read": False, + "value_template": "{{ 50 if value >= 50 else 1 }}", + } + ], + }, } ) res = await ws_client.receive_json() diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index 5bdcfc989dbd0f..372ff446f7cbf8 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -442,6 +442,43 @@ async def test_knx_get_schema( assert res == snapshot +async def test_knx_get_expose_groups( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test knx/get_expose_groups command returning proper expose groups data.""" + await knx.setup_integration( + config_store_fixture="config_store_expose.json", + ) + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "knx/get_expose_groups"}) + res = await client.receive_json() + assert res == snapshot + + +async def test_knx_get_expose_config( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test knx/get_expose_config command returning proper expose config data.""" + await knx.setup_integration( + config_store_fixture="config_store_expose.json", + ) + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "knx/get_expose_config", + "entity_id": "cover.test", + } + ) + res = await client.receive_json() + assert res == snapshot + + @pytest.mark.parametrize( "endpoint", [ diff --git a/tests/components/lawn_mower/test_condition.py b/tests/components/lawn_mower/test_condition.py index 25bdf62d8fa82d..27ab7503a0002c 100644 --- a/tests/components/lawn_mower/test_condition.py +++ b/tests/components/lawn_mower/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, other_states, parametrize_condition_states_all, parametrize_condition_states_any, @@ -43,6 +44,34 @@ async def test_lawn_mower_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("lawn_mower.is_docked", {}, True, True), + ("lawn_mower.is_encountering_an_error", {}, True, True), + ("lawn_mower.is_mowing", {}, True, True), + ("lawn_mower.is_paused", {}, True, True), + ("lawn_mower.is_returning", {}, True, True), + ], +) +async def test_lawn_mower_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that lawn_mower conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/lg_netcast/test_remote.py b/tests/components/lg_netcast/test_remote.py new file mode 100644 index 00000000000000..facdbe80b08085 --- /dev/null +++ b/tests/components/lg_netcast/test_remote.py @@ -0,0 +1,59 @@ +"""Tests for LG Netcast remote platform.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +from pylgnetcast import LG_COMMAND +import pytest + +from homeassistant.components.remote import ( + ATTR_COMMAND, + DOMAIN as REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from . import MODEL_NAME, setup_lgnetcast + +REMOTE_ENTITY_ID = f"{REMOTE_DOMAIN}.{MODEL_NAME.lower()}" + + +@pytest.fixture(autouse=True) +def mock_lg_netcast() -> Generator[MagicMock]: + """Mock LG Netcast library.""" + with patch( + "homeassistant.components.lg_netcast.LgNetCastClient" + ) as mock_client_class: + yield mock_client_class + + +async def test_send_command(hass: HomeAssistant, mock_lg_netcast: MagicMock) -> None: + """Test remote.send_command calls the client with the correct command code.""" + await setup_lgnetcast(hass) + context_client = mock_lg_netcast.return_value.__enter__.return_value + + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: REMOTE_ENTITY_ID, ATTR_COMMAND: ["POWER"]}, + blocking=True, + ) + + context_client.send_command.assert_called_once_with(LG_COMMAND.POWER) + + +async def test_send_command_invalid( + hass: HomeAssistant, mock_lg_netcast: MagicMock +) -> None: + """Test remote.send_command raises ServiceValidationError for an unknown command name.""" + await setup_lgnetcast(hass) + + with pytest.raises(ServiceValidationError, match="Unknown command"): + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: REMOTE_ENTITY_ID, ATTR_COMMAND: ["NOT_A_REAL_COMMAND"]}, + blocking=True, + ) diff --git a/tests/components/light/test_condition.py b/tests/components/light/test_condition.py index 6851527aee23fa..e52d9b60f62e90 100644 --- a/tests/components/light/test_condition.py +++ b/tests/components/light/test_condition.py @@ -13,6 +13,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -151,6 +152,31 @@ async def test_light_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("light.is_off", {}, True, True), + ("light.is_on", {}, True, True), + ], +) +async def test_light_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that light conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/lock/test_condition.py b/tests/components/lock/test_condition.py index 73d51620974945..d2a009b5a5cedf 100644 --- a/tests/components/lock/test_condition.py +++ b/tests/components/lock/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, other_states, parametrize_condition_states_all, parametrize_condition_states_any, @@ -42,6 +43,33 @@ async def test_lock_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("lock.is_jammed", {}, True, True), + ("lock.is_locked", {}, True, True), + ("lock.is_open", {}, True, True), + ("lock.is_unlocked", {}, True, True), + ], +) +async def test_lock_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that lock conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index ab12f84b5108ea..708aec7720437a 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -2559,6 +2559,7 @@ def _cycle_entities(): entity_id, state, {ATTR_UNIT_OF_MEASUREMENT: "any"} ) hass.states.async_set("counter.any", state) + hass.states.async_set("image.any", state) hass.states.async_set("proximity.any", state) # We will compare event subscriptions after closing the websocket connection, @@ -2573,7 +2574,7 @@ def _cycle_entities(): "id": 7, "type": "logbook/event_stream", "start_time": now.isoformat(), - "entity_ids": ["sensor.uom", "counter.any", "proximity.any"], + "entity_ids": ["sensor.uom", "counter.any", "image.any", "proximity.any"], } ) @@ -2908,6 +2909,7 @@ def _create_events(): entity_id, state, {ATTR_UNIT_OF_MEASUREMENT: "any"} ) hass.states.async_set("counter.any", state) + hass.states.async_set("image.any", state) hass.states.async_set("proximity.any", state) hass.bus.async_fire("mock_event", {"device_id": device.id}) hass.bus.async_fire("mock_event", {"device_id": device2.id}) @@ -2924,7 +2926,7 @@ def _create_events(): "id": 7, "type": "logbook/event_stream", "start_time": now.isoformat(), - "entity_ids": ["sensor.uom", "counter.any", "proximity.any"], + "entity_ids": ["sensor.uom", "counter.any", "image.any", "proximity.any"], "device_ids": [device.id, device2.id], } ) @@ -3113,6 +3115,11 @@ def auto_off_listener(event): {}, 0, # Counter is an always continuous domain ), + ( + "image.map0", + {}, + 0, # Image is an always continuous domain + ), ( "zone.home", {}, diff --git a/tests/components/logger/test_init.py b/tests/components/logger/test_init.py index 51a613ab567003..ec7859beb7e019 100644 --- a/tests/components/logger/test_init.py +++ b/tests/components/logger/test_init.py @@ -11,11 +11,12 @@ from homeassistant.components import logger from homeassistant.components.logger import LOGSEVERITY from homeassistant.components.logger.helpers import SAVE_DELAY_LONG -from homeassistant.core import HomeAssistant +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import Unauthorized from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import async_call_logger_set_level, async_fire_time_changed +from tests.common import MockUser, async_call_logger_set_level, async_fire_time_changed HASS_NS = "unused.homeassistant" COMPONENTS_NS = f"{HASS_NS}.components" @@ -430,3 +431,20 @@ async def test_log_once_removed_from_store( await hass.async_block_till_done() assert hass_storage["core.logger"]["data"] == {"logs": {}} + + +@pytest.mark.parametrize("service", ["set_level", "set_default_level"]) +async def test_services_require_admin( + hass: HomeAssistant, hass_read_only_user: MockUser, service: str +) -> None: + """Test logger services require admin.""" + assert await async_setup_component(hass, "logger", {}) + + with pytest.raises(Unauthorized): + await hass.services.async_call( + logger.DOMAIN, + service, + {"level": "debug"} if service == "set_default_level" else {"test": "debug"}, + context=Context(user_id=hass_read_only_user.id), + blocking=True, + ) diff --git a/tests/components/logger/test_websocket_api.py b/tests/components/logger/test_websocket_api.py index 91bddd2f414dfe..b58b3d5ea59f61 100644 --- a/tests/components/logger/test_websocket_api.py +++ b/tests/components/logger/test_websocket_api.py @@ -364,3 +364,51 @@ async def test_module_log_level_override( assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.NOTSET } + + +async def test_integration_log_level_requires_admin( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_read_only_access_token: str, +) -> None: + """Test setting integration log level requires admin.""" + assert await async_setup_component(hass, "logger", {}) + + websocket_client = await hass_ws_client(hass, hass_read_only_access_token) + await websocket_client.send_json( + { + "id": 7, + "type": "logger/integration_log_level", + "integration": "websocket_api", + "level": "DEBUG", + "persistence": "none", + } + ) + + msg = await websocket_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "unauthorized" + + +async def test_module_log_level_requires_admin( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_read_only_access_token: str, +) -> None: + """Test setting module log level requires admin.""" + assert await async_setup_component(hass, "logger", {}) + + websocket_client = await hass_ws_client(hass, hass_read_only_access_token) + await websocket_client.send_json( + { + "id": 7, + "type": "logger/log_level", + "module": "homeassistant.components.websocket_api", + "level": "DEBUG", + "persistence": "none", + } + ) + + msg = await websocket_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "unauthorized" diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index c6fcd206b15f91..12ec3dc5240104 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -22,6 +22,7 @@ "air_quality_sensor", "aqara_door_window_p2", "aqara_motion_p2", + "aqara_multi_state_p100", "aqara_presence_fp300", "aqara_sensor_w100", "aqara_thermostat_w500", diff --git a/tests/components/matter/fixtures/nodes/aqara_multi_state_p100.json b/tests/components/matter/fixtures/nodes/aqara_multi_state_p100.json new file mode 100644 index 00000000000000..74019b1c431a2e --- /dev/null +++ b/tests/components/matter/fixtures/nodes/aqara_multi_state_p100.json @@ -0,0 +1,407 @@ +{ + "node_id": 364, + "date_commissioned": "2026-03-10T00:21:04.489000", + "last_interview": "2026-04-10T11:57:17.154000", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/65533": 2, + "0/29/65532": 0, + "0/29/0": [ + { + "0": 18, + "1": 1 + }, + { + "0": 22, + "1": 3 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 52, 53, 60, 62, 63, 70], + "0/29/2": [41], + "0/29/3": [1, 2], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/29/65529": [], + "0/29/65528": [], + "0/31/1": [], + "0/31/65533": 2, + "0/31/65532": 0, + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 + } + ], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/31/65529": [], + "0/31/65528": [], + "0/40/11": "20251229", + "0/40/12": "AS080", + "0/40/13": "https://www.aqara.com/en/products.html", + "0/40/14": "Multi-State Sensor P100", + "0/40/15": "54EF4410015E5399", + "0/40/16": false, + "0/40/24": 1, + "0/40/65533": 4, + "0/40/0": 18, + "0/40/1": "Aqara", + "0/40/2": 4447, + "0/40/3": "Multi-State Sensor P100", + "0/40/4": 8203, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1000, + "0/40/8": "1.0.0.0", + "0/40/9": 1002, + "0/40/10": "1.0.0.2", + "0/40/18": "FC9C116E96AF14A0", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 17039616, + "0/40/22": 5, + "0/40/65532": 0, + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 21, 22, + 65528, 65529, 65531, 65532, 65533 + ], + "0/40/65529": [], + "0/40/65528": [], + "0/42/65533": 1, + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/42/65529": [0], + "0/42/65528": [], + "0/48/65533": 2, + "0/48/65532": 0, + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/48/65529": [0, 2, 4], + "0/48/65528": [1, 3, 5], + "0/49/65532": 2, + "0/49/2": 10, + "0/49/3": 20, + "0/49/9": 4, + "0/49/10": 4, + "0/49/65533": 2, + "0/49/0": 1, + "0/49/1": [ + { + "0": "/yXnNCiUSvw=", + "1": true + } + ], + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "/yXnNCiUSvw=", + "0/49/7": null, + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65528": [1, 5, 7], + "0/51/65532": 1, + "0/51/4": 6, + "0/51/5": [], + "0/51/65533": 2, + "0/51/0": [ + { + "0": "MyHome1895415629", + "1": true, + "2": null, + "3": null, + "4": "cqbSuXtndsc=", + "5": [], + "6": [ + "/YXIOtIiAAHLfipa/IZ15A==", + "/dH8OtkWot0AAAD//gC4Aw==", + "/dH8OtkWot1GlaI6UhuJiQ==", + "/oAAAAAAAABwptK5e2d2xw==" + ], + "7": 4 + } + ], + "0/51/1": 1, + "0/51/2": 679, + "0/51/8": false, + "0/51/65531": [0, 1, 2, 4, 5, 8, 65528, 65529, 65531, 65532, 65533], + "0/51/65529": [0, 1], + "0/51/65528": [2], + "0/52/65532": 1, + "0/52/0": [ + { + "0": 11, + "1": "Bluetoot", + "3": 1395 + }, + { + "0": 10, + "1": "Bluetoot", + "3": 107 + }, + { + "0": 2, + "1": "OT Stack", + "3": 756 + }, + { + "0": 3, + "1": "sys_evt", + "3": 1832 + }, + { + "0": 1, + "1": "Bluetoot", + "3": 306 + }, + { + "0": 9, + "1": "Tmr Svc", + "3": 855 + }, + { + "0": 6, + "1": "app", + "3": 718 + }, + { + "0": 8, + "1": "IDLE", + "3": 237 + }, + { + "0": 5, + "1": "CHIP", + "3": 231 + } + ], + "0/52/1": 54632, + "0/52/2": 36560, + "0/52/3": 4294951292, + "0/52/65533": 1, + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/52/65529": [0], + "0/52/65528": [], + "0/53/65532": 15, + "0/53/6": 0, + "0/53/22": 3127, + "0/53/23": 3125, + "0/53/24": 2, + "0/53/39": 255, + "0/53/40": 182, + "0/53/41": 2, + "0/53/63": null, + "0/53/64": null, + "0/53/65533": 2, + "0/53/0": 25, + "0/53/1": 2, + "0/53/2": "MyHome1895415629", + "0/53/3": 49399, + "0/53/4": 18385355265015040764, + "0/53/5": "QP3R/DrZFqLd", + "0/53/7": [ + { + "0": 15199626930737624439, + "1": 628, + "2": 47104, + "3": 577932, + "4": 206026, + "5": 3, + "6": -41, + "7": -42, + "8": 28, + "9": 1, + "10": true, + "11": true, + "12": true, + "13": false + } + ], + "0/53/8": [ + { + "0": 15199626930737624439, + "1": 47104, + "2": 46, + "3": 0, + "4": 0, + "5": 3, + "6": 3, + "7": 116, + "8": true, + "9": true + } + ], + "0/53/9": 1216112755, + "0/53/10": 68, + "0/53/11": 52, + "0/53/12": 177, + "0/53/13": 20, + "0/53/59": { + "0": 672, + "1": 57487 + }, + "0/53/60": "AB//wA==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 22, 23, 24, 39, 40, 41, 59, + 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/53/65529": [0], + "0/53/65528": [], + "0/60/65532": 1, + "0/60/65533": 1, + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/60/65529": [0, 1, 2], + "0/60/65528": [], + "0/62/65533": 1, + "0/62/0": [ + { + "1": "FTABAQQkAgE3AyQTAhgmBFx8YC8mBdyyDUQ3BiQVAiURbAEYJAcBJAgBMAlBBDP7qKQsHuBi45zn4RejInN+AEUzFqUrzJk6EuoYjYmH3Yp9c9PiK8DR/bn66Z/4cughMr4Uewh1Blstnj0lUJk3CjUBKAEYJAIBNgMEAgQBGDAEFKzIdkDOIFno0xGg5dwLqRevOcKsMAUU/eZfRuhWvUFT8WNU1R/sUE0q70YYMAtAyoqHj64qMq2D0pXxHe3/kBtkNBQLhzwTKxWlNyUb73BypVsgO8GXoY7X4ei3l9sWicjmi2TxSjWwWYiGWlc9/xg=", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEJB6axSR6Xj7Ab+FB5+C+slsdDtj0qCvcRHCCpCYTX6svgMPs/yVVEfvJgIUXZ5gkLS9jK1CpsF4u8MZR6qsNZzcKNQEpARgkAmAwBBT95l9G6Fa9QVPxY1TVH+xQTSrvRjAFFLyrP0JigOFmlOJIyXL9CANMKs5NGDALQNykW7UIqcgXgx+UezCVYPRU8/CpHh9CJBqL/7wKfTM62ujWJlrH0P5DEZ5bV9ZihCk4Wg/DMM2BUuUcTOEEqzEY", + "254": 2 + } + ], + "0/62/1": [ + { + "1": "BCZ12LdJK3WZUiquu2PD6iWSaeQK6J6DWw86GihFX4HiWOG1JQip6ILp0IFNffrIGwriEteEhksN56MylydpF/s=", + "2": 4939, + "3": 2, + "4": 364, + "5": "Home", + "254": 2 + } + ], + "0/62/2": 5, + "0/62/3": 2, + "0/62/4": [ + "FTABEQDQx1YK3DNF4VspLtBSH0RWJAIBNwMmFKXW6WQYJgSH9oQvJgWHuJzrNwYmFKXW6WQYJAcBJAgBMAlBBGCkW/XG7wOs/MmWOEItvplPFRgUY9sAtyYIn/4rFIak+Z0AXSkROqxLUAQa8V7DLPgpo0JBqXvHPHi9xLO5tb83CjUBKQEYJAJgMAQUZZlpdl2hjoDT0Rf0y6zCQ129lKowBRRlmWl2XaGOgNPRF/TLrMJDXb2UqhgwC0Dq2rZf/UEww43h7sE2IUGQvb4vrhhK/Iqbx4ginWbxp/h9th7NRTa76ByxReos8Y4mJ9QGaGvcNThhSGF5lx5+GA==", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEJnXYt0krdZlSKq67Y8PqJZJp5AronoNbDzoaKEVfgeJY4bUlCKnogunQgU19+sgbCuIS14SGSw3nozKXJ2kX+zcKNQEpARgkAmAwBBS8qz9CYoDhZpTiSMly/QgDTCrOTTAFFLyrP0JigOFmlOJIyXL9CANMKs5NGDALQIfPjG1LeoSoRd3sJ2NeaS3VrHyftI8l6dOwafhoGMQdCRwyadYABiUG/Po1BnWmg4laSh88nP3zAAnQ2j0l4tAY" + ], + "0/62/5": 2, + "0/62/65532": 0, + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65528": [1, 3, 5, 8], + "0/63/65533": 2, + "0/63/65532": 0, + "0/63/0": [], + "0/63/1": [], + "0/63/2": 0, + "0/63/3": 3, + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/63/65529": [0, 1, 3, 4], + "0/63/65528": [2, 5], + "0/70/65533": 3, + "0/70/65532": 0, + "0/70/0": 300, + "0/70/1": 100, + "0/70/2": 500, + "0/70/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/70/65529": [], + "0/70/65528": [], + "1/29/65533": 2, + "1/29/65532": 0, + "1/29/0": [ + { + "0": 21, + "1": 2 + } + ], + "1/29/1": [3, 29, 69, 128], + "1/29/2": [], + "1/29/3": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/29/65529": [], + "1/29/65528": [], + "1/128/65532": 8, + "1/128/0": 6, + "1/128/1": 10, + "1/128/2": 4, + "1/128/65533": 1, + "1/128/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/128/65529": [], + "1/128/65528": [], + "1/3/65533": 4, + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/3/65529": [0, 64], + "1/3/65528": [], + "1/69/65533": 1, + "1/69/0": false, + "1/69/65532": 0, + "1/69/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/69/65529": [], + "1/69/65528": [], + "2/29/65533": 2, + "2/29/65532": 0, + "2/29/0": [ + { + "0": 17, + "1": 1 + } + ], + "2/29/1": [29, 47], + "2/29/2": [], + "2/29/3": [], + "2/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "2/29/65529": [], + "2/29/65528": [], + "2/47/65532": 10, + "2/47/11": 2972, + "2/47/12": 200, + "2/47/14": 0, + "2/47/15": false, + "2/47/16": 2, + "2/47/19": "CR2450", + "2/47/25": 1, + "2/47/65533": 2, + "2/47/0": 1, + "2/47/1": 0, + "2/47/2": "Battery", + "2/47/31": [], + "2/47/65531": [ + 0, 1, 2, 11, 12, 14, 15, 16, 19, 25, 31, 65528, 65529, 65531, 65532, 65533 + ], + "2/47/65529": [], + "2/47/65528": [] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/heiman_smoke_detector.json b/tests/components/matter/fixtures/nodes/heiman_smoke_detector.json index e25240610a8116..2c4e5f064beede 100644 --- a/tests/components/matter/fixtures/nodes/heiman_smoke_detector.json +++ b/tests/components/matter/fixtures/nodes/heiman_smoke_detector.json @@ -232,7 +232,19 @@ "1/92/65530": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "1/92/65531": [ 0, 1, 3, 4, 5, 6, 7, 65528, 65529, 65530, 65531, 65532, 65533 - ] + ], + "1/302775297/16": 11, + "1/302775297/17": 22, + "1/302775297/18": 33, + "1/302775297/19": 44, + "1/302775297/20": 1, + "1/302775297/21": 0, + "1/302775297/22": 0, + "1/302775297/65533": 1, + "1/302775297/65532": 0, + "1/302775297/65531": [20, 21, 22, 65528, 65529, 65531, 65532, 65533], + "1/302775297/65529": [0], + "1/302775297/65528": [] }, "attribute_subscriptions": [] } diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index f000171a17e20a..efc83eed307474 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -101,6 +101,57 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[aqara_multi_state_p100][binary_sensor.multi_state_sensor_p100_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.multi_state_sensor_p100_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Door', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000016C-MatterNodeDevice-1-ContactSensor-69-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[aqara_multi_state_p100][binary_sensor.multi_state_sensor_p100_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Multi-State Sensor P100 Door', + }), + 'context': , + 'entity_id': 'binary_sensor.multi_state_sensor_p100_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensors[aqara_presence_fp300][binary_sensor.presence_multi_sensor_fp300_1_occupancy-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 39ef9b81827c1a..561dd52871d401 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -152,6 +152,57 @@ 'state': 'unknown', }) # --- +# name: test_buttons[aqara_multi_state_p100][button.multi_state_sensor_p100_identify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.multi_state_sensor_p100_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Identify', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000016C-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[aqara_multi_state_p100][button.multi_state_sensor_p100_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Multi-State Sensor P100 Identify', + }), + 'context': , + 'entity_id': 'button.multi_state_sensor_p100_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[aqara_presence_fp300][button.presence_multi_sensor_fp300_1_identify_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1884,6 +1935,56 @@ 'state': 'unknown', }) # --- +# name: test_buttons[heiman_smoke_detector][button.smoke_sensor_temporary_mute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.smoke_sensor_temporary_mute', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temporary mute', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temporary mute', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temporary_mute_request', + 'unique_id': '00000000000004D2-000000000000000B-MatterNodeDevice-1-HeimanSmokeCoAlarmTemporaryMuteRequest-302775297-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[heiman_smoke_detector][button.smoke_sensor_temporary_mute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smoke sensor Temporary mute', + }), + 'context': , + 'entity_id': 'button.smoke_sensor_temporary_mute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[ikea_air_quality_monitor][button.alpstuga_air_quality_monitor_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index d0392b49717a6e..15962c8f15b7ad 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -1,4 +1,63 @@ # serializer version: 1 +# name: test_numbers[aqara_door_window_p2][number.aqara_door_and_window_sensor_p2_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 3, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.aqara_door_and_window_sensor_p2_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensitivity', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensitivity_level', + 'unique_id': '00000000000004D2-000000000000005B-MatterNodeDevice-1-BooleanStateConfigurationCurrentSensitivityLevel-128-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[aqara_door_window_p2][number.aqara_door_and_window_sensor_p2_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aqara Door and Window Sensor P2 Sensitivity', + 'max': 3, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.aqara_door_and_window_sensor_p2_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_numbers[aqara_motion_p2][number.aqara_motion_and_light_sensor_p2_hold_time-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -59,6 +118,124 @@ 'state': '30', }) # --- +# name: test_numbers[aqara_motion_p2][number.aqara_motion_and_light_sensor_p2_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 3, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.aqara_motion_and_light_sensor_p2_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensitivity', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensitivity_level', + 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-BooleanStateConfigurationCurrentSensitivityLevel-128-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[aqara_motion_p2][number.aqara_motion_and_light_sensor_p2_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aqara Motion and Light Sensor P2 Sensitivity', + 'max': 3, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.aqara_motion_and_light_sensor_p2_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_numbers[aqara_multi_state_p100][number.multi_state_sensor_p100_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.multi_state_sensor_p100_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensitivity', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensitivity_level', + 'unique_id': '00000000000004D2-000000000000016C-MatterNodeDevice-1-BooleanStateConfigurationCurrentSensitivityLevel-128-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[aqara_multi_state_p100][number.multi_state_sensor_p100_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Multi-State Sensor P100 Sensitivity', + 'max': 10, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.multi_state_sensor_p100_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- # name: test_numbers[aqara_presence_fp300][number.presence_multi_sensor_fp300_1_hold_time-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -119,6 +296,65 @@ 'state': '10', }) # --- +# name: test_numbers[aqara_presence_fp300][number.presence_multi_sensor_fp300_1_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 3, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.presence_multi_sensor_fp300_1_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensitivity', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensitivity_level', + 'unique_id': '00000000000004D2-00000000000000CD-MatterNodeDevice-1-BooleanStateConfigurationCurrentSensitivityLevel-128-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[aqara_presence_fp300][number.presence_multi_sensor_fp300_1_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Presence Multi-Sensor FP300 1 Sensitivity', + 'max': 3, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.presence_multi_sensor_fp300_1_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- # name: test_numbers[aqara_thermostat_w500][number.floor_heating_thermostat_occupied_setback-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -838,6 +1074,65 @@ 'state': '70', }) # --- +# name: test_numbers[heiman_motion_sensor_m1][number.smart_motion_sensor_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 3, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.smart_motion_sensor_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensitivity', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensitivity_level', + 'unique_id': '00000000000004D2-0000000000000058-MatterNodeDevice-1-BooleanStateConfigurationCurrentSensitivityLevel-128-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[heiman_motion_sensor_m1][number.smart_motion_sensor_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart motion sensor Sensitivity', + 'max': 3, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.smart_motion_sensor_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_numbers[inovelli_vtm30][number.white_series_onoff_switch_led_off_intensity_load_control-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 0136173018a5e2..5d63b72d1a1f25 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -1,187 +1,4 @@ # serializer version: 1 -# name: test_selects[aqara_door_window_p2][select.aqara_door_and_window_sensor_p2_sensitivity-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - '10 mm', - '20 mm', - '30 mm', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.aqara_door_and_window_sensor_p2_sensitivity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Sensitivity', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensitivity', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'sensitivity_level', - 'unique_id': '00000000000004D2-000000000000005B-MatterNodeDevice-1-AqaraBooleanStateConfigurationCurrentSensitivityLevel-128-0', - 'unit_of_measurement': None, - }) -# --- -# name: test_selects[aqara_door_window_p2][select.aqara_door_and_window_sensor_p2_sensitivity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aqara Door and Window Sensor P2 Sensitivity', - 'options': list([ - '10 mm', - '20 mm', - '30 mm', - ]), - }), - 'context': , - 'entity_id': 'select.aqara_door_and_window_sensor_p2_sensitivity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '30 mm', - }) -# --- -# name: test_selects[aqara_motion_p2][select.aqara_motion_and_light_sensor_p2_sensitivity-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'low', - 'standard', - 'high', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.aqara_motion_and_light_sensor_p2_sensitivity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Sensitivity', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensitivity', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'sensitivity_level', - 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-AqaraOccupancySensorBooleanStateConfigurationCurrentSensitivityLevel-128-0', - 'unit_of_measurement': None, - }) -# --- -# name: test_selects[aqara_motion_p2][select.aqara_motion_and_light_sensor_p2_sensitivity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aqara Motion and Light Sensor P2 Sensitivity', - 'options': list([ - 'low', - 'standard', - 'high', - ]), - }), - 'context': , - 'entity_id': 'select.aqara_motion_and_light_sensor_p2_sensitivity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'standard', - }) -# --- -# name: test_selects[aqara_presence_fp300][select.presence_multi_sensor_fp300_1_sensitivity-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'low', - 'standard', - 'high', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.presence_multi_sensor_fp300_1_sensitivity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Sensitivity', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensitivity', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'sensitivity_level', - 'unique_id': '00000000000004D2-00000000000000CD-MatterNodeDevice-1-AqaraOccupancySensorBooleanStateConfigurationCurrentSensitivityLevel-128-0', - 'unit_of_measurement': None, - }) -# --- -# name: test_selects[aqara_presence_fp300][select.presence_multi_sensor_fp300_1_sensitivity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Presence Multi-Sensor FP300 1 Sensitivity', - 'options': list([ - 'low', - 'standard', - 'high', - ]), - }), - 'context': , - 'entity_id': 'select.presence_multi_sensor_fp300_1_sensitivity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'low', - }) -# --- # name: test_selects[aqara_thermostat_w500][select.floor_heating_thermostat_temperature_display_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1050,67 +867,6 @@ 'state': 'previous', }) # --- -# name: test_selects[heiman_motion_sensor_m1][select.smart_motion_sensor_sensitivity-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'low', - 'standard', - 'high', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.smart_motion_sensor_sensitivity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Sensitivity', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensitivity', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'sensitivity_level', - 'unique_id': '00000000000004D2-0000000000000058-MatterNodeDevice-1-HeimanOccupancySensorBooleanStateConfigurationCurrentSensitivityLevel-128-0', - 'unit_of_measurement': None, - }) -# --- -# name: test_selects[heiman_motion_sensor_m1][select.smart_motion_sensor_sensitivity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Smart motion sensor Sensitivity', - 'options': list([ - 'low', - 'standard', - 'high', - ]), - }), - 'context': , - 'entity_id': 'select.smart_motion_sensor_sensitivity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'high', - }) -# --- # name: test_selects[inovelli_vtm30][select.white_series_onoff_switch_button_press_delay-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index d339bed55735cc..82613a2d1f585d 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -951,6 +951,172 @@ 'state': '37.0', }) # --- +# name: test_sensors[aqara_multi_state_p100][sensor.multi_state_sensor_p100_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.multi_state_sensor_p100_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000016C-MatterNodeDevice-2-PowerSource-47-12', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[aqara_multi_state_p100][sensor.multi_state_sensor_p100_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Multi-State Sensor P100 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.multi_state_sensor_p100_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[aqara_multi_state_p100][sensor.multi_state_sensor_p100_battery_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.multi_state_sensor_p100_battery_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery type', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery type', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_replacement_description', + 'unique_id': '00000000000004D2-000000000000016C-MatterNodeDevice-2-PowerSourceBatReplacementDescription-47-19', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[aqara_multi_state_p100][sensor.multi_state_sensor_p100_battery_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Multi-State Sensor P100 Battery type', + }), + 'context': , + 'entity_id': 'sensor.multi_state_sensor_p100_battery_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'CR2450', + }) +# --- +# name: test_sensors[aqara_multi_state_p100][sensor.multi_state_sensor_p100_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.multi_state_sensor_p100_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': '00000000000004D2-000000000000016C-MatterNodeDevice-2-PowerSourceBatVoltage-47-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[aqara_multi_state_p100][sensor.multi_state_sensor_p100_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Multi-State Sensor P100 Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.multi_state_sensor_p100_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.972', + }) +# --- # name: test_sensors[aqara_presence_fp300][sensor.presence_multi_sensor_fp300_1_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1799,7 +1965,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.floor_heating_thermostat_energy', 'has_entity_name': True, 'hidden_by': None, @@ -2089,7 +2255,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.electricity_monitor_ac_energy', 'has_entity_name': True, 'hidden_by': None, @@ -2150,7 +2316,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.electricity_monitor_ac_power', 'has_entity_name': True, 'hidden_by': None, @@ -3233,7 +3399,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.eve_energy_20ecn4101_energy_top', 'has_entity_name': True, 'hidden_by': None, @@ -3291,7 +3457,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.eve_energy_20ecn4101_power_top', 'has_entity_name': True, 'hidden_by': None, @@ -3465,7 +3631,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.eve_energy_plug_energy', 'has_entity_name': True, 'hidden_by': None, @@ -3523,7 +3689,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.eve_energy_plug_power', 'has_entity_name': True, 'hidden_by': None, @@ -3700,7 +3866,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.eve_energy_plug_patched_energy', 'has_entity_name': True, 'hidden_by': None, @@ -3761,7 +3927,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.eve_energy_plug_patched_power', 'has_entity_name': True, 'hidden_by': None, @@ -7264,7 +7430,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.white_series_onoff_switch_energy', 'has_entity_name': True, 'hidden_by': None, @@ -7380,7 +7546,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.white_series_onoff_switch_power', 'has_entity_name': True, 'hidden_by': None, @@ -9047,7 +9213,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.mock_battery_storage_power', 'has_entity_name': True, 'hidden_by': None, @@ -10964,7 +11130,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.mock_solar_inverter_energy_exported', 'has_entity_name': True, 'hidden_by': None, @@ -11025,7 +11191,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.mock_solar_inverter_power', 'has_entity_name': True, 'hidden_by': None, @@ -12201,7 +12367,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.dishwasher_energy', 'has_entity_name': True, 'hidden_by': None, @@ -12392,7 +12558,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.dishwasher_power', 'has_entity_name': True, 'hidden_by': None, @@ -12885,7 +13051,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.evse_energy', 'has_entity_name': True, 'hidden_by': None, @@ -12946,7 +13112,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.evse_energy_exported', 'has_entity_name': True, 'hidden_by': None, @@ -13764,7 +13930,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.laundrywasher_energy', 'has_entity_name': True, 'hidden_by': None, @@ -13953,7 +14119,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.laundrywasher_power', 'has_entity_name': True, 'hidden_by': None, @@ -14438,7 +14604,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.water_heater_energy', 'has_entity_name': True, 'hidden_by': None, @@ -14617,7 +14783,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.water_heater_power', 'has_entity_name': True, 'hidden_by': None, @@ -15315,7 +15481,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.yndx_00540_power', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index cf1d1aef7839fd..c5d3446c64a62b 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -1,4 +1,54 @@ # serializer version: 1 +# name: test_switches[eve_energy_20ecn4101][switch.eve_energy_20ecn4101_child_lock_top-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.eve_energy_20ecn4101_child_lock_top', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Child lock (top)', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock (top)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '00000000000004D2-00000000000000C7-MatterNodeDevice-1-EveChildLock-319486977-319422481', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[eve_energy_20ecn4101][switch.eve_energy_20ecn4101_child_lock_top-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eve Energy 20ECN4101 Child lock (top)', + }), + 'context': , + 'entity_id': 'switch.eve_energy_20ecn4101_child_lock_top', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switches[eve_energy_20ecn4101][switch.eve_energy_20ecn4101_switch_bottom-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -152,6 +202,56 @@ 'state': 'off', }) # --- +# name: test_switches[eve_energy_plug][switch.eve_energy_plug_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.eve_energy_plug_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Child lock', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '00000000000004D2-000000000000003D-MatterNodeDevice-1-EveChildLock-319486977-319422481', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[eve_energy_plug][switch.eve_energy_plug_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eve Energy Plug Child lock', + }), + 'context': , + 'entity_id': 'switch.eve_energy_plug_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switches[eve_energy_plug_patched][switch.eve_energy_plug_patched-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -203,6 +303,106 @@ 'state': 'off', }) # --- +# name: test_switches[eve_energy_plug_patched][switch.eve_energy_plug_patched_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.eve_energy_plug_patched_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Child lock', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-1-EveChildLock-319486977-319422481', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[eve_energy_plug_patched][switch.eve_energy_plug_patched_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eve Energy Plug Patched Child lock', + }), + 'context': , + 'entity_id': 'switch.eve_energy_plug_patched_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[eve_shutter][switch.eve_shutter_switch_20eci1701_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.eve_shutter_switch_20eci1701_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Child lock', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '00000000000004D2-0000000000000094-MatterNodeDevice-1-EveChildLock-319486977-319422481', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[eve_shutter][switch.eve_shutter_switch_20eci1701_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eve Shutter Switch 20ECI1701 Child lock', + }), + 'context': , + 'entity_id': 'switch.eve_shutter_switch_20eci1701_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switches[eve_thermo_v4][switch.eve_thermo_20ebp1701_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/test_button.py b/tests/components/matter/test_button.py index 56133805de9432..b4bdf907e67bca 100644 --- a/tests/components/matter/test_button.py +++ b/tests/components/matter/test_button.py @@ -4,6 +4,7 @@ from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode +from matter_server.common.custom_clusters import HeimanCluster import pytest from syrupy.assertion import SnapshotAssertion @@ -82,6 +83,40 @@ async def test_operational_state_buttons( ) +@pytest.mark.parametrize("node_fixture", ["heiman_smoke_detector"]) +async def test_heiman_temporary_mute_button( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test button entity for Heiman SmokeCoAlarm temporary mute request.""" + state = hass.states.get("button.smoke_sensor_temporary_mute") + assert state + assert state.attributes["friendly_name"] == "Smoke sensor Temporary mute" + await hass.services.async_call( + "button", + "press", + {"entity_id": "button.smoke_sensor_temporary_mute"}, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=HeimanCluster.Commands.MutingSensor(), + ) + + +@pytest.mark.parametrize("node_fixture", ["heiman_smoke_detector"]) +@pytest.mark.parametrize("attributes", [{"1/302775297/65529": []}]) +@pytest.mark.usefixtures("matter_node") +async def test_heiman_temporary_mute_button_not_discovered_without_muting_command( + hass: HomeAssistant, +) -> None: + """Test that the temporary mute button is not created when MutingSensor is absent from AcceptedCommandList.""" + assert hass.states.get("button.smoke_sensor_temporary_mute") is None + + @pytest.mark.parametrize("node_fixture", ["heiman_smoke_detector"]) async def test_smoke_detector_self_test( hass: HomeAssistant, diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index 8e82c04bed6bc1..51ba3f761eecc5 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -342,6 +342,44 @@ async def test_thermostat_occupied_setback( ) +@pytest.mark.parametrize("node_fixture", ["aqara_multi_state_p100"]) +async def test_boolean_state_configuration_current_sensitivity_level( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test sensitivity level number entity for Aqara P100.""" + entity_id = "number.multi_state_sensor_p100_sensitivity" + + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": entity_id, + "value": 1, + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=1, + attribute=( + clusters.BooleanStateConfiguration.Attributes.CurrentSensitivityLevel + ), + ), + value=0, + ) + + set_node_attribute(matter_node, 1, 128, 0, 4) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state.state == "5" + + @pytest.mark.parametrize("node_fixture", ["mock_door_lock"]) async def test_lock_attributes( hass: HomeAssistant, diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index b1a6b60f411415..867e4dd100bd71 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -281,6 +281,7 @@ async def test_microwave_oven( ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize("node_fixture", ["aqara_door_window_p2"]) async def test_aqara_door_window_p2( hass: HomeAssistant, diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py index e89f3fc7fe63c7..c4578272b7d5c0 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -285,3 +285,55 @@ async def test_speaker_mute_uses_onoff_commands( state = hass.states.get("switch.mock_speaker_mute") assert state assert state.state == "off" + + +@pytest.mark.parametrize("node_fixture", ["eve_energy_plug"]) +async def test_eve_child_lock( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test the Eve child lock switch entity.""" + state = hass.states.get("switch.eve_energy_plug_child_lock") + assert state + assert state.state == "off" + # test attribute changes + set_node_attribute(matter_node, 1, 319486977, 319422481, True) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("switch.eve_energy_plug_child_lock") + assert state.state == "on" + set_node_attribute(matter_node, 1, 319486977, 319422481, False) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("switch.eve_energy_plug_child_lock") + assert state.state == "off" + # test switch service + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": "switch.eve_energy_plug_child_lock"}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args_list[0] == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=1, + attribute=clusters.EveCluster.Attributes.ChildLock, + ), + value=True, + ) + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": "switch.eve_energy_plug_child_lock"}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 2 + assert matter_client.write_attribute.call_args_list[1] == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=1, + attribute=clusters.EveCluster.Attributes.ChildLock, + ), + value=False, + ) diff --git a/tests/components/mcp_server/conftest.py b/tests/components/mcp_server/conftest.py index e109a9626d3d89..a2e98c1121ac7b 100644 --- a/tests/components/mcp_server/conftest.py +++ b/tests/components/mcp_server/conftest.py @@ -9,10 +9,17 @@ from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +async def ensure_homeassistant_loaded(hass: HomeAssistant) -> None: + """Ensure homeassistant component is loaded.""" + assert await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" diff --git a/tests/components/mealie/fixtures/get_mealplans.json b/tests/components/mealie/fixtures/get_mealplans.json index 6aa40e471bd1cd..f28054e3d99d12 100644 --- a/tests/components/mealie/fixtures/get_mealplans.json +++ b/tests/components/mealie/fixtures/get_mealplans.json @@ -325,7 +325,7 @@ "tools": [], "rating": null, "orgURL": "https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/", - "dateAdded": "2024-01-21", + "dateAdded": null, "dateUpdated": "2024-01-21T03:04:25.718367", "createdAt": "2024-01-21T02:13:11.323363", "updateAt": "2024-01-21T03:04:25.721489", diff --git a/tests/components/mealie/snapshots/test_diagnostics.ambr b/tests/components/mealie/snapshots/test_diagnostics.ambr index 8d2877686a7572..003414fad15db6 100644 --- a/tests/components/mealie/snapshots/test_diagnostics.ambr +++ b/tests/components/mealie/snapshots/test_diagnostics.ambr @@ -618,10 +618,7 @@ 'recipe': dict({ 'categories': list([ ]), - 'date_added': dict({ - '__type': "", - 'isoformat': '2024-01-21', - }), + 'date_added': None, 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index ff25b1e6072ef1..6298e711859eb1 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -3970,7 +3970,7 @@ 'recipe': dict({ 'categories': list([ ]), - 'date_added': HAFakeDate(2024, 1, 21), + 'date_added': None, 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, diff --git a/tests/components/media_player/test_condition.py b/tests/components/media_player/test_condition.py index 2dded050bfd62e..55ed770ea0d7bb 100644 --- a/tests/components/media_player/test_condition.py +++ b/tests/components/media_player/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, other_states, parametrize_condition_states_all, parametrize_condition_states_any, @@ -43,6 +44,34 @@ async def test_media_player_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("media_player.is_off", {}, True, True), + ("media_player.is_on", {}, True, False), + ("media_player.is_not_playing", {}, True, False), + ("media_player.is_paused", {}, True, True), + ("media_player.is_playing", {}, True, True), + ], +) +async def test_media_player_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that media_player conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 7fd0cbda8a6d9d..b7a247bc9736d5 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -738,6 +738,34 @@ async def test_webhook_update_location_with_gps_without_accuracy( assert state.state == STATE_UNKNOWN +async def test_webhook_update_location_preserves_float_gps_accuracy( + hass: HomeAssistant, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test that sub-meter ``gps_accuracy`` is not floored to an integer. + + Android's fused location provider reports accuracy as a float in metres. + The zone-containment predicate (``zone_dist - zone_radius < accuracy``) + can flip its result over a sub-metre difference at zone boundaries - + so flooring 6.938 to 6 has been observed to drop inner-zone transitions + in nested same-centre zones, with no automatic retry. + """ + resp = await webhook_client.post( + f"/api/webhook/{create_registrations[1]['webhook_id']}", + json={ + "type": "update_location", + "data": {"gps": [1, 2], "gps_accuracy": 6.938}, + }, + ) + + assert resp.status == HTTPStatus.OK + + state = hass.states.get("device_tracker.test_1_2") + assert state is not None + assert state.attributes["gps_accuracy"] == 6.938 + + async def test_webhook_update_location_with_location_name( hass: HomeAssistant, create_registrations: tuple[dict[str, Any], dict[str, Any]], diff --git a/tests/components/moisture/test_condition.py b/tests/components/moisture/test_condition.py index 65d7e7c76d07e8..7c636a7c90bc46 100644 --- a/tests/components/moisture/test_condition.py +++ b/tests/components/moisture/test_condition.py @@ -17,6 +17,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_numerical_condition_above_below_all, @@ -55,6 +56,31 @@ async def test_moisture_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("moisture.is_detected", {}, True, True), + ("moisture.is_not_detected", {}, True, True), + ], +) +async def test_moisture_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that moisture conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/motion/test_condition.py b/tests/components/motion/test_condition.py index b4b3c717e03368..ae829c6c293269 100644 --- a/tests/components/motion/test_condition.py +++ b/tests/components/motion/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -40,6 +41,31 @@ async def test_motion_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("motion.is_detected", {}, True, True), + ("motion.is_not_detected", {}, True, True), + ], +) +async def test_motion_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that motion conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), @@ -182,7 +208,7 @@ async def test_motion_condition_excludes_non_motion_device_class( ) # Matching entity in matching state - condition should be True - assert condition_any(hass) is True + assert condition_any.async_check() is True # Set matching entity to non-matching state hass.states.async_set( @@ -193,4 +219,4 @@ async def test_motion_condition_excludes_non_motion_device_class( await hass.async_block_till_done() # Wrong device class entity still in matching state, but should be excluded - assert condition_any(hass) is False + assert condition_any.async_check() is False diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index 19d4992a24073e..0473281b6c437c 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -517,6 +517,26 @@ "entity_picture": "https://example.com/f9261f6feed443e7b7d5f3fbe2a47414", }, } +MOCK_SUBENTRY_NUMBER_COMPONENT_NONE_UNIT = { + "a9261f6feed443e7b7d5f3fbe2a47414": { + "platform": "number", + "name": "Purifier", + "entity_category": None, + "command_topic": "test-topic", + "command_template": "{{ value }}", + "state_topic": "test-topic", + "min": 0.0, + "max": 10.0, + "step": 2.0, + "mode": "auto", + "device_class": "aqi", + "unit_of_measurement": "None", + "value_template": "{{ value_json.value }}", + "payload_reset": "None", + "retain": False, + "entity_picture": "https://example.com/a9261f6feed443e7b7d5f3fbe2a47414", + }, +} MOCK_SUBENTRY_SELECT_COMPONENT = { "fa261f6feed443e7b7d5f3fbe2a47414": { "platform": "select", @@ -544,6 +564,20 @@ "entity_picture": "https://example.com/e9261f6feed443e7b7d5f3fbe2a47412", }, } +MOCK_SUBENTRY_SENSOR_COMPONENT_UOM_NULL = { + "b0f85790a95d4889924602effff06b6e": { + "platform": "sensor", + "name": "Air quality", + "device_class": "aqi", + "entity_category": None, + "state_class": "measurement", + "state_topic": "test-topic", + # `unit_of_measurement` is stored as a string; + # it will be filtered from the config when exported or when set up. + "unit_of_measurement": "None", + "entity_picture": "https://example.com/b0f85790a95d4889924602effff06b6e", + }, +} MOCK_SUBENTRY_SENSOR_COMPONENT_STATE_CLASS = { "a0f85790a95d4889924602effff06b6e": { "platform": "sensor", @@ -793,6 +827,10 @@ "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_NUMBER_COMPONENT_NO_UNIT, } +MOCK_NUMBER_SUBENTRY_DATA_NONE_UNIT = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_NUMBER_COMPONENT_NONE_UNIT, +} MOCK_SELECT_SUBENTRY_DATA = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SELECT_COMPONENT, @@ -805,6 +843,10 @@ "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SENSOR_COMPONENT_STATE_CLASS, } +MOCK_SENSOR_SUBENTRY_DATA_UOM_NONE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_SENSOR_COMPONENT_UOM_NULL, +} MOCK_SENSOR_SUBENTRY_DATA_LAST_RESET_TEMPLATE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET, @@ -842,7 +884,8 @@ "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2 | MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT - | MOCK_SUBENTRY_SWITCH_COMPONENT, + | MOCK_SUBENTRY_SWITCH_COMPONENT + | MOCK_SUBENTRY_SENSOR_COMPONENT_UOM_NULL, } | MOCK_SUBENTRY_AVAILABILITY_DATA _SENTINEL = object() diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 50aef011f59b95..e6204b02633dd2 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -56,10 +56,12 @@ MOCK_NUMBER_SUBENTRY_DATA_CUSTOM_UNIT, MOCK_NUMBER_SUBENTRY_DATA_DEVICE_CLASS_UNIT, MOCK_NUMBER_SUBENTRY_DATA_NO_UNIT, + MOCK_NUMBER_SUBENTRY_DATA_NONE_UNIT, MOCK_SELECT_SUBENTRY_DATA, MOCK_SENSOR_SUBENTRY_DATA, MOCK_SENSOR_SUBENTRY_DATA_LAST_RESET_TEMPLATE, MOCK_SENSOR_SUBENTRY_DATA_STATE_CLASS, + MOCK_SENSOR_SUBENTRY_DATA_UOM_NONE, MOCK_SIREN_SUBENTRY_DATA, MOCK_SWITCH_SUBENTRY_DATA, MOCK_TEXT_SUBENTRY_DATA, @@ -3557,6 +3559,30 @@ async def test_migrate_of_incompatible_config_entry( "Milk notifier Speed", id="number_no_unit", ), + pytest.param( + MOCK_NUMBER_SUBENTRY_DATA_NONE_UNIT, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Purifier"}, + { + "device_class": "aqi", + "unit_of_measurement": "None", + }, + (), + { + "command_topic": "test-topic", + "command_template": "{{ value }}", + "state_topic": "test-topic", + "min": 0, + "max": 10, + "step": 2, + "mode": "auto", + "value_template": "{{ value_json.value }}", + "retain": False, + }, + (), + "Milk notifier Purifier", + id="number_None_unit", + ), pytest.param( MOCK_SELECT_SUBENTRY_DATA, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, @@ -3632,6 +3658,23 @@ async def test_migrate_of_incompatible_config_entry( "Milk notifier Energy", id="sensor_options", ), + pytest.param( + MOCK_SENSOR_SUBENTRY_DATA_UOM_NONE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Air quality"}, + { + "state_class": "measurement", + "device_class": "aqi", + "unit_of_measurement": "None", + }, + (), + { + "state_topic": "test-topic", + }, + (), + "Milk notifier Air quality", + id="sensor_aqi", + ), pytest.param( MOCK_SENSOR_SUBENTRY_DATA_STATE_CLASS, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, diff --git a/tests/components/mqtt/test_datetime.py b/tests/components/mqtt/test_datetime.py new file mode 100644 index 00000000000000..b984843e0e0472 --- /dev/null +++ b/tests/components/mqtt/test_datetime.py @@ -0,0 +1,704 @@ +"""The tests for the MQTT datetime platform.""" + +from __future__ import annotations + +import datetime as datetime_lib +from typing import Any +from unittest.mock import patch + +from dateutil.tz import UTC +from freezegun import freeze_time +import pytest + +from homeassistant.components import datetime, mqtt +from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .common import ( + help_custom_config, + help_test_availability_when_connection_lost, + help_test_availability_without_topic, + help_test_custom_availability_payload, + help_test_default_availability_payload, + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, + help_test_entity_debug_info_message, + help_test_entity_device_info_remove, + help_test_entity_device_info_update, + help_test_entity_device_info_with_connection, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, + help_test_reloadable, + help_test_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, + help_test_unique_id, + help_test_unload_config_entry_with_platform, + help_test_update_with_json_attrs_bad_json, + help_test_update_with_json_attrs_not_dict, +) + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient + +DEFAULT_CONFIG = { + mqtt.DOMAIN: {datetime.DOMAIN: {"name": "test", "command_topic": "test-topic"}} +} + + +async def async_set_value( + hass: HomeAssistant, entity_id: str, value: datetime_lib.datetime | None +) -> None: + """Set date and time value.""" + await hass.services.async_call( + datetime.DOMAIN, + datetime.SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, datetime.ATTR_DATETIME: value}, + blocking=True, + ) + + +@freeze_time("2026-04-24T12:52:00+00:00") +@pytest.mark.parametrize( + ("hass_config", "update_state"), + [ + ( + { + mqtt.DOMAIN: { + datetime.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + } + } + }, + ( + ("1/12/2025 3:00 +00:00", "2025-01-12T03:00:00+00:00"), + ("2025-12-02 03:12:10 +00:00", "2025-12-02T03:12:10+00:00"), + ("2025-05-02 03:12:10 +0000", "2025-05-02T03:12:10+00:00"), + ), + ), + ( + { + mqtt.DOMAIN: { + datetime.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "timezone": "Europe/Amsterdam", + } + } + }, + ( + ("1/12/2025 4:00", "2025-01-12T03:00:00+00:00"), + ("2025-04-02 04:12:10", "2025-04-02T02:12:10+00:00"), + ("2025-05-02 05:12:10", "2025-05-02T03:12:10+00:00"), + ), + ), + ], + ids=["update_with_tz", "tz_offset_7200"], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + update_state: tuple[tuple[str, str],], +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry() + await hass.async_block_till_done() + state = hass.states.get("datetime.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + for update, state_update in update_state: + async_fire_mqtt_message(hass, "state-topic", update) + state = hass.states.get("datetime.test") + assert state.state == state_update + + async_fire_mqtt_message(hass, "state-topic", "None") + state = hass.states.get("datetime.test") + assert state.state == STATE_UNKNOWN + + # Empty string should be ignored + caplog.clear() + async_fire_mqtt_message(hass, "state-topic", "") + assert "Ignoring empty state payload" in caplog.text + + state = hass.states.get("datetime.test") + assert state.state == STATE_UNKNOWN + + # Invalid value should show warning + caplog.clear() + async_fire_mqtt_message(hass, "state-topic", "No valid date/time") + assert "Invalid received date/time expression" in caplog.text + + +@freeze_time("2026-04-01T10:00:00+00:00") +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + datetime.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "timezone": "Europe/London", + } + } + } + ], +) +@pytest.mark.parametrize( + ("received_state", "expected_state"), + [ + ("1 March 2025", "2025-03-01T00:00:00+00:00"), + ("2025.03.01", "2025-03-01T00:00:00+00:00"), + # If only time is parsed the current data is attached + ("00:05:10", "2026-03-31T23:05:10+00:00"), + ], +) +async def test_controlling_validation_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + received_state: str, + expected_state: str, +) -> None: + """Test the validation of a received state.""" + await mqtt_mock_entry() + await hass.async_block_till_done() + + state = hass.states.get("datetime.test") + assert state.state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, "state-topic", received_state) + state = hass.states.get("datetime.test") + assert state.state == expected_state + + +@freeze_time("2026-04-01T10:00:00+00:00") +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + datetime.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "timezone": "Europe/London", + } + } + } + ], +) +@pytest.mark.parametrize( + "received_state", + [ + "2025-03-01T00:00:00+00:00", + "2025-03-01 00:00:00 +0000", + "1 March 2025 00:00:00 +0000", + ], +) +async def test_ambiguous_date_time_state_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, received_state: str +) -> None: + """Test the where the state has a timezone and a timezone is defined.""" + await mqtt_mock_entry() + await hass.async_block_till_done() + + state = hass.states.get("datetime.test") + assert state.state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, "state-topic", received_state) + state = hass.states.get("datetime.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + datetime.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "timezone": "Invalid", + } + } + } + ], +) +async def test_date_time_with_invalid_timezone_identifier( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test config with an invalid zimezone identifier.""" + await mqtt_mock_entry() + await hass.async_block_till_done() + + state = hass.states.get("datetime.test") + assert state.state == STATE_UNKNOWN + + assert ( + "Ignoring invalid timezone identifier for entity datetime.test, got 'Invalid'" + in caplog.text + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + datetime.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "qos": "2", + "timezone": "Europe/Amsterdam", + } + } + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands in optimistic mode.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("datetime.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_set_value( + hass, + "datetime.test", + datetime_lib.datetime( + year=2025, month=12, day=1, hour=10, minute=12, tzinfo=UTC + ), + ) + await hass.async_block_till_done() + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", "2025-12-01T10:12:00+00:00", 2, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("datetime.test") + assert state.state == "2025-12-01T10:12:00+00:00" + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_when_connection_lost( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock_entry, datetime.DOMAIN + ) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_without_topic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + config = { + mqtt.DOMAIN: { + datetime.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + } + } + } + await help_test_default_availability_payload( + hass, + mqtt_mock_entry, + datetime.DOMAIN, + config, + True, + "state-topic", + "2025-10-01 10:12:00", + ) + + +async def test_custom_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by custom payload with defined topic.""" + config = { + mqtt.DOMAIN: { + datetime.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + } + } + } + + await help_test_custom_availability_payload( + hass, + mqtt_mock_entry, + datetime.DOMAIN, + config, + True, + "state-topic", + "2025-10-01 10:12:00", + ) + + +async def test_setting_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG, None + ) + + +async def test_setting_attribute_with_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry, caplog, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_bad_json( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_json( + hass, mqtt_mock_entry, caplog, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_discovery_update_attr( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + datetime.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) +async def test_unique_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unique id option only creates one datetime entity per unique_id.""" + await help_test_unique_id(hass, mqtt_mock_entry, datetime.DOMAIN) + + +async def test_discovery_removal_time( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test removal of discovered datetime entity.""" + data = ( + '{ "name": "test",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + await help_test_discovery_removal(hass, mqtt_mock_entry, datetime.DOMAIN, data) + + +async def test_discovery_datetime_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test update of discovered datetime entity.""" + config1 = { + "name": "Beer", + "command_topic": "command-topic", + "state_topic": "state-topic", + } + config2 = { + "name": "Milk", + "command_topic": "command-topic", + "state_topic": "state-topic", + } + + await help_test_discovery_update( + hass, mqtt_mock_entry, datetime.DOMAIN, config1, config2 + ) + + +async def test_discovery_update_unchanged_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test update of discovered update.""" + data1 = '{ "name": "Beer", "state_topic": "state-topic", "command_topic": "command-topic"}' + with patch( + "homeassistant.components.mqtt.datetime.MqttDateTime.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock_entry, datetime.DOMAIN, data1, discovery_update + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer" }' + data2 = ( + '{ "name": "Milk",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + await help_test_discovery_broken( + hass, mqtt_mock_entry, datetime.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT date device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT date device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_subscriptions( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT subscriptions are managed when entity_id is updated.""" + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_debug_info_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT debug info.""" + await help_test_entity_debug_info_message( + hass, mqtt_mock_entry, datetime.DOMAIN, DEFAULT_CONFIG, None + ) + + +async def test_reloadable( + hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient +) -> None: + """Test reloading the MQTT platform.""" + domain = datetime.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize( + ("topic", "value", "attribute", "attribute_value"), + [ + ("state_topic", "2025-12-01 10:12:00 +0000", None, "2025-12-01T10:12:00+00:00"), + ], +) +async def test_encoding_subscribable_topics( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, +) -> None: + """Test handling of incoming encoded payload.""" + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock_entry, + datetime.DOMAIN, + DEFAULT_CONFIG[mqtt.DOMAIN][datetime.DOMAIN], + topic, + value, + attribute, + attribute_value, + ) + + +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry() + platform = datetime.DOMAIN + assert hass.states.get(f"{platform}.test") + + +async def test_unload_entry( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unloading the config entry.""" + domain = datetime.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry, domain, config + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + datetime.DOMAIN, + DEFAULT_CONFIG, + ( + { + "state_topic": "test-topic", + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "2025-12-01 10:12:00 +0000", "2025-12-01 10:12:01 +0000"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + datetime.DOMAIN, + DEFAULT_CONFIG, + ( + { + "state_topic": "test-topic", + "value_template": "{{ value_json.some_var * 1 }}", + }, + ), + ) + ], +) +async def test_value_template_fails( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the rendering of MQTT value template fails.""" + await mqtt_mock_entry() + async_fire_mqtt_message(hass, "test-topic", '{"some_var": null }') + assert ( + "TypeError: unsupported operand type(s) for *: 'NoneType' and 'int' rendering template" + in caplog.text + ) diff --git a/tests/components/netatmo/snapshots/test_init.ambr b/tests/components/netatmo/snapshots/test_init.ambr index 3f8d924bdbf122..fd12cb9fb69c2a 100644 --- a/tests/components/netatmo/snapshots/test_init.ambr +++ b/tests/components/netatmo/snapshots/test_init.ambr @@ -495,6 +495,37 @@ 'via_device_id': None, }) # --- +# name: test_devices[netatmo-12:34:56:00:86:99] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://home.netatmo.com/security', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:86:99', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Netatmo', + 'model': 'Smart Door/Window Sensors', + 'model_id': None, + 'name': 'Window Hall', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[netatmo-12:34:56:00:f1:62] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -779,7 +810,7 @@ 'area_id': None, 'config_entries': , 'config_entries_subentries': , - 'configuration_url': 'https://my.netatmo.com/app/energy', + 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), 'disabled_by': None, diff --git a/tests/components/netatmo/snapshots/test_sensor.ambr b/tests/components/netatmo/snapshots/test_sensor.ambr index 480629e6c11f76..86d92d2c4f6606 100644 --- a/tests/components/netatmo/snapshots/test_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_sensor.ambr @@ -8187,3 +8187,110 @@ 'state': 'High', }) # --- +# name: test_entity[sensor.window_hall_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.window_hall_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': '12:34:56:00:86:99-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.window_hall_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'battery', + 'friendly_name': 'Window Hall Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.window_hall_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.window_hall_rf_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.window_hall_rf_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'RF strength', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RF strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rf_strength', + 'unique_id': '12:34:56:00:86:99-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.window_hall_rf_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Window Hall RF strength', + }), + 'context': , + 'entity_id': 'sensor.window_hall_rf_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/nfandroidtv/conftest.py b/tests/components/nfandroidtv/conftest.py new file mode 100644 index 00000000000000..6129b36ce93b39 --- /dev/null +++ b/tests/components/nfandroidtv/conftest.py @@ -0,0 +1,37 @@ +"""Common fixtures for the Notifications for Android TV / Fire TV tests.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.nfandroidtv.const import DOMAIN +from homeassistant.const import CONF_HOST + +from . import HOST, NAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_notifications_android_tv() -> Generator[MagicMock]: + """Mock notifications_android_tv.""" + + with patch( + "homeassistant.components.nfandroidtv.config_flow.Notifications", autospec=True + ) as mock_client: + client = mock_client.return_value + client.cls = mock_client + + yield client + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Mock Notifications for Android TV / Fire TV configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=NAME, + data={CONF_HOST: HOST}, + entry_id="123456789", + ) diff --git a/tests/components/nfandroidtv/test_config_flow.py b/tests/components/nfandroidtv/test_config_flow.py index 271961fbee70b9..2daa18eb524389 100644 --- a/tests/components/nfandroidtv/test_config_flow.py +++ b/tests/components/nfandroidtv/test_config_flow.py @@ -1,11 +1,13 @@ """Test NFAndroidTV config flow.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch from notifications_android_tv.notifications import ConnectError +import pytest from homeassistant import config_entries from homeassistant.components.nfandroidtv.const import DOMAIN +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -97,3 +99,102 @@ async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "unknown"} + + +@pytest.mark.usefixtures("mock_notifications_android_tv") +async def test_flow_reconfigure( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "4.3.2.1"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_HOST] == "4.3.2.1" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), [(ConnectError, "cannot_connect"), (ValueError, "unknown")] +) +async def test_flow_reconfigure_errors( + hass: HomeAssistant, + mock_notifications_android_tv: MagicMock, + config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reconfigure flow errors.""" + + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_notifications_android_tv.cls.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "4.3.2.1"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_notifications_android_tv.cls.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "4.3.2.1"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_HOST] == "4.3.2.1" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_notifications_android_tv") +async def test_flow_reconfigure_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow aborts if already configured.""" + MockConfigEntry( + domain=DOMAIN, + title="Android TV / Fire TV (4.3.2.1)", + data={CONF_HOST: "4.3.2.1"}, + entry_id="987654321", + ).add_to_hass(hass) + + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "4.3.2.1"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert len(hass.config_entries.async_entries()) == 2 diff --git a/tests/components/nobo_hub/conftest.py b/tests/components/nobo_hub/conftest.py index ba31d95400a0a2..d3df30ec001b24 100644 --- a/tests/components/nobo_hub/conftest.py +++ b/tests/components/nobo_hub/conftest.py @@ -1,6 +1,7 @@ """Common fixtures for the Nobø Ecohub tests.""" from collections.abc import Generator +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from pynobo import nobo as pynobo_nobo @@ -58,7 +59,17 @@ def connect_exc() -> BaseException | None: @pytest.fixture -def mock_config_entry(ip_address: str, auto_discovered: bool) -> MockConfigEntry: +def config_entry_options() -> dict[str, Any]: + """Return the options stored on the config entry.""" + return {} + + +@pytest.fixture +def mock_config_entry( + ip_address: str, + auto_discovered: bool, + config_entry_options: dict[str, Any], +) -> MockConfigEntry: """Return a mock Nobø Ecohub config entry.""" return MockConfigEntry( domain=DOMAIN, @@ -69,6 +80,7 @@ def mock_config_entry(ip_address: str, auto_discovered: bool) -> MockConfigEntry CONF_IP_ADDRESS: ip_address, CONF_AUTO_DISCOVERED: auto_discovered, }, + options=config_entry_options, ) diff --git a/tests/components/nobo_hub/snapshots/test_climate.ambr b/tests/components/nobo_hub/snapshots/test_climate.ambr new file mode 100644 index 00000000000000..349e719ba207c6 --- /dev/null +++ b/tests/components/nobo_hub/snapshots/test_climate.ambr @@ -0,0 +1,83 @@ +# serializer version: 1 +# name: test_climate_entities[climate.living_room-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_modes': list([ + 'none', + 'comfort', + 'eco', + 'away', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.living_room', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'nobo_hub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '102000013098:1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities[climate.living_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Living room', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_mode': 'comfort', + 'preset_modes': list([ + 'none', + 'comfort', + 'eco', + 'away', + ]), + 'supported_features': , + 'target_temp_high': 21, + 'target_temp_low': 17, + 'target_temp_step': 1, + }), + 'context': , + 'entity_id': 'climate.living_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- diff --git a/tests/components/nobo_hub/snapshots/test_sensor.ambr b/tests/components/nobo_hub/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..49ba9c995459b2 --- /dev/null +++ b/tests/components/nobo_hub/snapshots/test_sensor.ambr @@ -0,0 +1,59 @@ +# serializer version: 1 +# name: test_sensor_entities[sensor.floor_sensor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.floor_sensor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'nobo_hub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '200000059091', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities[sensor.floor_sensor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Floor sensor Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.floor_sensor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.5', + }) +# --- diff --git a/tests/components/nobo_hub/test_climate.py b/tests/components/nobo_hub/test_climate.py new file mode 100644 index 00000000000000..e1db4c59c9b3db --- /dev/null +++ b/tests/components/nobo_hub/test_climate.py @@ -0,0 +1,218 @@ +"""Tests for the Nobø Ecohub climate platform.""" + +from unittest.mock import MagicMock + +from pynobo import nobo +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + DOMAIN as CLIMATE_DOMAIN, + PRESET_AWAY, + PRESET_COMFORT, + PRESET_ECO, + PRESET_NONE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.components.nobo_hub.const import ( + CONF_OVERRIDE_TYPE, + OVERRIDE_TYPE_NOW, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import fire_hub_update + +from tests.common import MockConfigEntry, snapshot_platform + +CLIMATE_ENTITY = "climate.living_room" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Only set up the climate platform for these tests.""" + return [Platform.CLIMATE] + + +@pytest.mark.usefixtures("init_integration") +async def test_climate_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """All climate entities match their snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("zone_mode", "expected_state", "expected_preset"), + [ + (nobo.API.NAME_OFF, HVACMode.OFF, PRESET_NONE), + (nobo.API.NAME_AWAY, HVACMode.AUTO, PRESET_AWAY), + (nobo.API.NAME_ECO, HVACMode.AUTO, PRESET_ECO), + (nobo.API.NAME_COMFORT, HVACMode.AUTO, PRESET_COMFORT), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_state_maps_zone_mode( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + zone_mode: str, + expected_state: HVACMode, + expected_preset: str, +) -> None: + """Zone modes map to the expected HVAC mode and preset.""" + mock_nobo_hub.get_current_zone_mode.return_value = zone_mode + await fire_hub_update(hass, mock_nobo_hub) + state = hass.states.get(CLIMATE_ENTITY) + assert state.state == expected_state + assert state.attributes[ATTR_PRESET_MODE] == expected_preset + + +@pytest.mark.usefixtures("init_integration") +async def test_state_override_forces_heat( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """A non-normal zone override maps to HVACMode.HEAT.""" + # Any non-NORMAL override value suffices; NAME_COMFORT is arbitrary. + mock_nobo_hub.get_zone_override_mode.return_value = nobo.API.NAME_COMFORT + await fire_hub_update(hass, mock_nobo_hub) + assert hass.states.get(CLIMATE_ENTITY).state == HVACMode.HEAT + + +@pytest.mark.usefixtures("init_integration") +async def test_current_temperature_unknown_when_missing( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """A missing current temperature surfaces as None.""" + mock_nobo_hub.get_current_zone_temperature.return_value = None + await fire_hub_update(hass, mock_nobo_hub) + assert hass.states.get(CLIMATE_ENTITY).attributes[ATTR_CURRENT_TEMPERATURE] is None + + +@pytest.mark.parametrize( + ("hvac_mode", "expected_override"), + [ + (HVACMode.AUTO, nobo.API.OVERRIDE_MODE_NORMAL), + (HVACMode.HEAT, nobo.API.OVERRIDE_MODE_COMFORT), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_set_hvac_mode( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + hvac_mode: HVACMode, + expected_override: str, +) -> None: + """Each HVAC mode maps to the expected zone override.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: CLIMATE_ENTITY, ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + mock_nobo_hub.async_create_override.assert_called_once_with( + expected_override, + nobo.API.OVERRIDE_TYPE_CONSTANT, + nobo.API.OVERRIDE_TARGET_ZONE, + "1", + ) + + +@pytest.mark.parametrize( + ("preset", "expected_mode"), + [ + (PRESET_NONE, nobo.API.OVERRIDE_MODE_NORMAL), + (PRESET_COMFORT, nobo.API.OVERRIDE_MODE_COMFORT), + (PRESET_ECO, nobo.API.OVERRIDE_MODE_ECO), + (PRESET_AWAY, nobo.API.OVERRIDE_MODE_AWAY), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_set_preset_mode( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + preset: str, + expected_mode: str, +) -> None: + """Each preset maps to the expected override mode.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: CLIMATE_ENTITY, ATTR_PRESET_MODE: preset}, + blocking=True, + ) + mock_nobo_hub.async_create_override.assert_called_once_with( + expected_mode, + nobo.API.OVERRIDE_TYPE_CONSTANT, + nobo.API.OVERRIDE_TARGET_ZONE, + "1", + ) + + +@pytest.mark.parametrize( + "config_entry_options", + [{CONF_OVERRIDE_TYPE: OVERRIDE_TYPE_NOW}], +) +@pytest.mark.usefixtures("init_integration") +async def test_set_preset_with_override_type_now( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """The override_type option flows into the zone override call.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: CLIMATE_ENTITY, ATTR_PRESET_MODE: PRESET_COMFORT}, + blocking=True, + ) + mock_nobo_hub.async_create_override.assert_called_once_with( + nobo.API.OVERRIDE_MODE_COMFORT, + nobo.API.OVERRIDE_TYPE_NOW, + nobo.API.OVERRIDE_TARGET_ZONE, + "1", + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_zone_removed_marks_unavailable( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """A zone removed via the Nobø app must not crash and goes unavailable.""" + mock_nobo_hub.zones.pop("1") + await fire_hub_update(hass, mock_nobo_hub) + assert hass.states.get(CLIMATE_ENTITY).state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("init_integration") +async def test_set_temperature_updates_zone( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """Setting target temperatures updates the zone on the hub.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: CLIMATE_ENTITY, + ATTR_TARGET_TEMP_LOW: 16.4, + ATTR_TARGET_TEMP_HIGH: 21.6, + }, + blocking=True, + ) + mock_nobo_hub.async_update_zone.assert_called_once_with( + "1", temp_comfort_c=22, temp_eco_c=16 + ) diff --git a/tests/components/nobo_hub/test_select.py b/tests/components/nobo_hub/test_select.py index 774ebb5f116f77..7564b27dca9a99 100644 --- a/tests/components/nobo_hub/test_select.py +++ b/tests/components/nobo_hub/test_select.py @@ -11,7 +11,7 @@ DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -136,3 +136,14 @@ async def test_week_profile_push_update( mock_nobo_hub.zones["1"]["week_profile_id"] = "1" await fire_hub_update(hass, mock_nobo_hub) assert hass.states.get(PROFILE_ENTITY).state == "Weekend" + + +@pytest.mark.usefixtures("init_integration") +async def test_zone_removed_marks_week_profile_unavailable( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """A zone removed via the Nobø app must not crash and goes unavailable.""" + mock_nobo_hub.zones.pop("1") + await fire_hub_update(hass, mock_nobo_hub) + assert hass.states.get(PROFILE_ENTITY).state == STATE_UNAVAILABLE diff --git a/tests/components/nobo_hub/test_sensor.py b/tests/components/nobo_hub/test_sensor.py new file mode 100644 index 00000000000000..3db220186464c3 --- /dev/null +++ b/tests/components/nobo_hub/test_sensor.py @@ -0,0 +1,68 @@ +"""Tests for the Nobø Ecohub sensor platform.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import fire_hub_update + +from tests.common import MockConfigEntry, snapshot_platform + +TEMPERATURE_ENTITY = "sensor.floor_sensor_temperature" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Only set up the sensor platform for these tests.""" + return [Platform.SENSOR] + + +@pytest.mark.usefixtures("init_integration") +async def test_sensor_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """All sensor entities match their snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_temperature_unknown_when_missing( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """Missing temperature values surface as unknown state.""" + mock_nobo_hub.get_current_component_temperature.return_value = None + await fire_hub_update(hass, mock_nobo_hub) + assert hass.states.get(TEMPERATURE_ENTITY).state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("init_integration") +async def test_temperature_push_update( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """Pushed hub updates refresh the temperature state.""" + assert hass.states.get(TEMPERATURE_ENTITY).state == "21.5" + + mock_nobo_hub.get_current_component_temperature.return_value = "19.3" + await fire_hub_update(hass, mock_nobo_hub) + assert hass.states.get(TEMPERATURE_ENTITY).state == "19.3" + + +@pytest.mark.usefixtures("init_integration") +async def test_component_removed_marks_unavailable( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """A component removed via the Nobø app must not crash and goes unavailable.""" + mock_nobo_hub.components.pop("200000059091") + await fire_hub_update(hass, mock_nobo_hub) + assert hass.states.get(TEMPERATURE_ENTITY).state == STATE_UNAVAILABLE diff --git a/tests/components/novy_cooker_hood/__init__.py b/tests/components/novy_cooker_hood/__init__.py new file mode 100644 index 00000000000000..cfc5235c1fdffc --- /dev/null +++ b/tests/components/novy_cooker_hood/__init__.py @@ -0,0 +1 @@ +"""Tests for the Novy Hood integration.""" diff --git a/tests/components/novy_cooker_hood/conftest.py b/tests/components/novy_cooker_hood/conftest.py new file mode 100644 index 00000000000000..b7f835e62d8500 --- /dev/null +++ b/tests/components/novy_cooker_hood/conftest.py @@ -0,0 +1,66 @@ +"""Common fixtures for the Novy Cooker Hood tests.""" + +from __future__ import annotations + +from collections.abc import Iterator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from rf_protocols import CodeCollection + +from homeassistant.components.novy_cooker_hood.const import ( + CONF_CODE, + CONF_TRANSMITTER, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.radio_frequency.common import ( + MockRadioFrequencyCommand, + MockRadioFrequencyEntity, +) + +TRANSMITTER_ENTITY_ID = "radio_frequency.test_rf_transmitter" + + +@pytest.fixture(autouse=True) +def mock_get_codes() -> Iterator[MagicMock]: + """Patch the bundled-codes loader so tests don't hit the filesystem.""" + fake_collection = MagicMock(spec=CodeCollection) + fake_collection.async_load_command = AsyncMock( + side_effect=lambda name: MockRadioFrequencyCommand() + ) + with patch( + "homeassistant.components.novy_cooker_hood.commands.get_codes", + return_value=fake_collection, + ): + yield fake_collection + + +@pytest.fixture +def mock_config_entry( + mock_rf_entity: MockRadioFrequencyEntity, + entity_registry: er.EntityRegistry, +) -> MockConfigEntry: + """Return a mock config entry for Novy Cooker Hood.""" + entity_entry = entity_registry.async_get(TRANSMITTER_ENTITY_ID) + assert entity_entry is not None + return MockConfigEntry( + domain=DOMAIN, + title="Novy Cooker Hood", + data={CONF_TRANSMITTER: entity_entry.id, CONF_CODE: 1}, + unique_id=f"{entity_entry.id}_1", + ) + + +@pytest.fixture +async def init_novy_cooker_hood( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Set up the Novy Cooker Hood integration.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry diff --git a/tests/components/novy_cooker_hood/test_config_flow.py b/tests/components/novy_cooker_hood/test_config_flow.py new file mode 100644 index 00000000000000..c920c51ab2b5f7 --- /dev/null +++ b/tests/components/novy_cooker_hood/test_config_flow.py @@ -0,0 +1,204 @@ +"""Test the Novy Hood config flow.""" + +from __future__ import annotations + +from collections.abc import Iterator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.novy_cooker_hood.commands import COMMAND_LIGHT +from homeassistant.components.novy_cooker_hood.const import ( + CONF_CODE, + CONF_TRANSMITTER, + DOMAIN, +) +from homeassistant.components.radio_frequency import DATA_COMPONENT, DOMAIN as RF_DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import TRANSMITTER_ENTITY_ID + +from tests.common import MockConfigEntry +from tests.components.radio_frequency.common import MockRadioFrequencyEntity + + +@pytest.fixture(autouse=True) +def mock_toggle_gap() -> Iterator[None]: + """Set the toggle gap to 0 so the test step doesn't actually wait.""" + with patch("homeassistant.components.novy_cooker_hood.config_flow._TOGGLE_GAP", 0): + yield + + +async def _start_user_flow(hass: HomeAssistant, code: str = "1") -> dict: + """Start the flow and submit the user step with the given code.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert CONF_CODE in result["data_schema"].schema + + return await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TRANSMITTER: TRANSMITTER_ENTITY_ID, + CONF_CODE: code, + }, + ) + + +async def test_user_flow_test_then_finish( + hass: HomeAssistant, + mock_get_codes: MagicMock, + mock_rf_entity: MockRadioFrequencyEntity, + entity_registry: er.EntityRegistry, +) -> None: + """Submitting the user step fires the test, then Finish creates the entry.""" + result = await _start_user_flow(hass, code="3") + + # Test was fired automatically (toggle on, wait, toggle off). + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "test_light" + mock_get_codes.async_load_command.assert_awaited_with(COMMAND_LIGHT) + assert len(mock_rf_entity.send_command_calls) == 2 + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "finish"} + ) + + entity_entry = entity_registry.async_get(TRANSMITTER_ENTITY_ID) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Novy Cooker Hood" + assert result["data"] == { + CONF_TRANSMITTER: entity_entry.id, + CONF_CODE: 3, + } + assert result["result"].unique_id == f"{entity_entry.id}_3" + + +async def test_user_flow_retry_picks_different_code( + hass: HomeAssistant, + mock_get_codes: MagicMock, + mock_rf_entity: MockRadioFrequencyEntity, + entity_registry: er.EntityRegistry, +) -> None: + """Retry returns to the user step; a new code re-fires the test and saves.""" + result = await _start_user_flow(hass, code="1") + assert result["type"] is FlowResultType.MENU + + # Pick Retry → back to user step. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "retry"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Submit a different code → test fires again, menu shown. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TRANSMITTER: TRANSMITTER_ENTITY_ID, + CONF_CODE: "7", + }, + ) + assert result["type"] is FlowResultType.MENU + # One load per test x two tests; two sends per test x two tests. + assert mock_get_codes.async_load_command.await_count == 2 + assert len(mock_rf_entity.send_command_calls) == 4 + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "finish"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_CODE] == 7 + + +async def test_user_flow_test_transmit_failure( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, +) -> None: + """A transmit failure surfaces as a `test_failed` menu with a Retry option.""" + with patch( + "homeassistant.components.novy_cooker_hood.config_flow.async_send_command", + side_effect=HomeAssistantError("nope"), + ): + result = await _start_user_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "test_failed" + + +async def test_unique_id_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test aborting when the same transmitter+code is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TRANSMITTER: TRANSMITTER_ENTITY_ID, + CONF_CODE: "1", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_same_transmitter_different_code_is_allowed( + hass: HomeAssistant, + mock_get_codes: MagicMock, + mock_config_entry: MockConfigEntry, + mock_rf_entity: MockRadioFrequencyEntity, + entity_registry: er.EntityRegistry, +) -> None: + """A second hood on the same transmitter but a different code is allowed.""" + mock_config_entry.add_to_hass(hass) + + result = await _start_user_flow(hass, code="5") + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "finish"} + ) + entity_entry = entity_registry.async_get(TRANSMITTER_ENTITY_ID) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_CODE] == 5 + assert result["result"].unique_id == f"{entity_entry.id}_5" + + +async def test_no_transmitters(hass: HomeAssistant) -> None: + """Test the flow aborts when no RF transmitters are registered at all.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_transmitters" + + +async def test_no_compatible_transmitters(hass: HomeAssistant) -> None: + """Test aborting when transmitters exist but none support 433.92 MHz OOK.""" + assert await async_setup_component(hass, RF_DOMAIN, {}) + await hass.async_block_till_done() + incompatible = MockRadioFrequencyEntity( + "incompatible", frequency_ranges=[(868_000_000, 869_000_000)] + ) + await hass.data[DATA_COMPONENT].async_add_entities([incompatible]) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_compatible_transmitters" diff --git a/tests/components/novy_cooker_hood/test_fan.py b/tests/components/novy_cooker_hood/test_fan.py new file mode 100644 index 00000000000000..ae63034c530639 --- /dev/null +++ b/tests/components/novy_cooker_hood/test_fan.py @@ -0,0 +1,285 @@ +"""Tests for the Novy Hood fan platform.""" + +from __future__ import annotations + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, + DOMAIN as FAN_DOMAIN, + SERVICE_DECREASE_SPEED, + SERVICE_INCREASE_SPEED, + SERVICE_SET_PERCENTAGE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import Context, HomeAssistant, State + +from tests.common import MockConfigEntry, mock_restore_cache +from tests.components.radio_frequency.common import MockRadioFrequencyEntity + +ENTITY_ID = "fan.novy_cooker_hood" + + +async def test_turn_on_calibrates_to_level_1( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """Default turn_on sends 4 minus + 1 plus and lands at 25%.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + assert state.attributes[ATTR_ASSUMED_STATE] is True + + context = Context() + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + context=context, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 25 + assert len(mock_rf_entity.send_command_calls) == 5 + assert all(c.context is context for c in mock_rf_entity.send_command_calls) + + +async def test_turn_on_with_percentage_calibrates_to_level( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """turn_on with percentage targets the matching level via calibration.""" + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 50}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert len(mock_rf_entity.send_command_calls) == 6 + + +async def test_set_percentage_zero_turns_off( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """set_percentage(0) turns the fan off via the calibration sequence.""" + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 0}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + assert state.attributes[ATTR_PERCENTAGE] == 0 + assert len(mock_rf_entity.send_command_calls) == 4 + + +async def test_turn_off_sends_four_minuses( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """turn_off sends 4 minus presses.""" + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + assert state.attributes[ATTR_PERCENTAGE] == 0 + assert len(mock_rf_entity.send_command_calls) == 4 + + +async def test_set_percentage_calibrates( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """set_percentage(75) sends 4 minus + 3 plus and lands at level 3.""" + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 75}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 75 + assert len(mock_rf_entity.send_command_calls) == 7 + + +async def test_increase_speed_sends_single_plus( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """increase_speed sends one plus and bumps level by one (no recalibration).""" + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 25 + assert len(mock_rf_entity.send_command_calls) == 1 + + +async def test_increase_speed_clamps_at_max( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + mock_config_entry: MockConfigEntry, +) -> None: + """Pressing increase at level 4 still sends the RF press but clamps level.""" + mock_restore_cache( + hass, [State(ENTITY_ID, STATE_ON, attributes={ATTR_PERCENTAGE: 100})] + ) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_PERCENTAGE] == 100 + assert len(mock_rf_entity.send_command_calls) == 1 + + +async def test_decrease_speed_sends_single_minus( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + mock_config_entry: MockConfigEntry, +) -> None: + """decrease_speed sends one minus and drops level by one.""" + mock_restore_cache( + hass, [State(ENTITY_ID, STATE_ON, attributes={ATTR_PERCENTAGE: 50})] + ) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_DECREASE_SPEED, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_PERCENTAGE] == 25 + assert len(mock_rf_entity.send_command_calls) == 1 + + +async def test_increase_speed_with_step_sends_n_presses( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """increase_speed with percentage_step sends N plus presses (no recalibration).""" + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE_STEP: 50}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert len(mock_rf_entity.send_command_calls) == 2 + + +async def test_decrease_speed_with_step_sends_n_presses( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + mock_config_entry: MockConfigEntry, +) -> None: + """decrease_speed with percentage_step sends N minus presses (no recalibration).""" + mock_restore_cache( + hass, [State(ENTITY_ID, STATE_ON, attributes={ATTR_PERCENTAGE: 100})] + ) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_DECREASE_SPEED, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE_STEP: 50}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert len(mock_rf_entity.send_command_calls) == 2 + + +async def test_decrease_speed_clamps_at_off( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """decrease_speed at level 0 still sends one minus but level stays at 0.""" + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_DECREASE_SPEED, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + assert len(mock_rf_entity.send_command_calls) == 1 + + +async def test_restore_state( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + mock_config_entry: MockConfigEntry, +) -> None: + """The fan restores its previous percentage without sending commands.""" + mock_restore_cache( + hass, [State(ENTITY_ID, STATE_ON, attributes={ATTR_PERCENTAGE: 50})] + ) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert mock_rf_entity.send_command_calls == [] diff --git a/tests/components/novy_cooker_hood/test_light.py b/tests/components/novy_cooker_hood/test_light.py new file mode 100644 index 00000000000000..daf5e7f28f5f13 --- /dev/null +++ b/tests/components/novy_cooker_hood/test_light.py @@ -0,0 +1,116 @@ +"""Tests for the Novy Hood light platform.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, call + +from homeassistant.components.light import ( + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.components.novy_cooker_hood.commands import COMMAND_LIGHT +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import Context, HomeAssistant, State + +from .conftest import TRANSMITTER_ENTITY_ID + +from tests.common import MockConfigEntry, mock_restore_cache +from tests.components.radio_frequency.common import MockRadioFrequencyEntity + +ENTITY_ID = "light.novy_cooker_hood_light" + + +async def test_turn_on_and_off_send_light_once_each( + hass: HomeAssistant, + mock_get_codes: MagicMock, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """Turn on sends a light toggle and flips is_on; turn off does the same.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_ASSUMED_STATE] is True + + context = Context() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + context=context, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert state.context is context + assert len(mock_rf_entity.send_command_calls) == 1 + assert mock_rf_entity.send_command_calls[0].context is context + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + context=context, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + assert mock_get_codes.async_load_command.await_args_list == [ + call(COMMAND_LIGHT), + call(COMMAND_LIGHT), + ] + assert len(mock_rf_entity.send_command_calls) == 2 + + +async def test_restore_state( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the light restores its previous on state.""" + mock_restore_cache(hass, [State(ENTITY_ID, STATE_ON)]) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + +async def test_entity_follows_transmitter_availability( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """The light becomes unavailable when the transmitter does, and back.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + hass.states.async_set(TRANSMITTER_ENTITY_ID, STATE_UNAVAILABLE) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set(TRANSMITTER_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/occupancy/test_condition.py b/tests/components/occupancy/test_condition.py index f4753d06acd6cb..c9f5e20afef610 100644 --- a/tests/components/occupancy/test_condition.py +++ b/tests/components/occupancy/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -40,6 +41,31 @@ async def test_occupancy_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("occupancy.is_detected", {}, True, True), + ("occupancy.is_not_detected", {}, True, True), + ], +) +async def test_occupancy_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that occupancy conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), @@ -182,7 +208,7 @@ async def test_occupancy_condition_excludes_non_occupancy_device_class( ) # Matching entity in matching state - condition should be True - assert condition_any(hass) is True + assert condition_any.async_check() is True # Set matching entity to non-matching state hass.states.async_set( @@ -193,4 +219,4 @@ async def test_occupancy_condition_excludes_non_occupancy_device_class( await hass.async_block_till_done() # Wrong device class entity still in matching state, but should be excluded - assert condition_any(hass) is False + assert condition_any.async_check() is False diff --git a/tests/components/onedrive/test_services.py b/tests/components/onedrive/test_services.py index df50a32b6877c8..cb0bb8721ed44b 100644 --- a/tests/components/onedrive/test_services.py +++ b/tests/components/onedrive/test_services.py @@ -194,7 +194,7 @@ async def test_filename_does_not_exist( ) -> None: """Test upload service call with a filename path that does not exist.""" await setup_integration(hass, mock_config_entry) - with pytest.raises(HomeAssistantError, match="does not exist"): + with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( DOMAIN, UPLOAD_SERVICE, @@ -206,6 +206,33 @@ async def test_filename_does_not_exist( blocking=True, return_response=True, ) + assert exc_info.value.translation_key == "filenames_do_not_exist" + assert TEST_FILENAME in exc_info.value.translation_placeholders["filenames"] + + +@pytest.mark.parametrize("upload_file", [MockUploadFile(exists=False)]) +async def test_multiple_filenames_do_not_exist( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload service reports all missing files, not just the first one.""" + await setup_integration(hass, mock_config_entry) + second_filename = "other_snapshot.jpg" + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: [TEST_FILENAME, second_filename], + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + assert exc_info.value.translation_key == "filenames_do_not_exist" + assert TEST_FILENAME in exc_info.value.translation_placeholders["filenames"] + assert second_filename in exc_info.value.translation_placeholders["filenames"] async def test_upload_service_fails_upload( diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index 1d75b96aa11773..39326072149841 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -6,7 +6,8 @@ import pytest from homeassistant import config_entries -from homeassistant.components.onvif import DOMAIN, config_flow +from homeassistant.components.onvif import config_flow +from homeassistant.components.onvif.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP from homeassistant.const import CONF_HOST, CONF_NAME, CONF_USERNAME from homeassistant.core import HomeAssistant diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index b674c4a0c8f0e2..6bdee46d720f5a 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -272,6 +272,8 @@ async def test_subentry_unsupported_model( ("gpt-5.3-codex", ["none", "low", "medium", "high", "xhigh"]), ("gpt-5.4", ["none", "low", "medium", "high", "xhigh"]), ("gpt-5.4-pro", ["medium", "high", "xhigh"]), + ("gpt-5.5", ["none", "low", "medium", "high", "xhigh"]), + ("gpt-5.5-pro", ["medium", "high", "xhigh"]), ], ) async def test_subentry_reasoning_effort_list( diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 0da3842884b488..acfb57abae0fab 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -1408,8 +1408,8 @@ async def test_devices( ) assert len(devices) == 4 # One for conversation, AI task, STT, and TTS - # Use the first device for snapshot comparison - device = devices[0] + # Find the conversation device for snapshot comparison + device = next(d for d in devices if d.name == "OpenAI Conversation") assert device == snapshot(exclude=props("identifiers")) # Verify the device has identifiers matching one of the subentries expected_identifiers = [ diff --git a/tests/components/otbr/conftest.py b/tests/components/otbr/conftest.py index 9140fcf6847c09..dc0753f65a69e0 100644 --- a/tests/components/otbr/conftest.py +++ b/tests/components/otbr/conftest.py @@ -1,10 +1,13 @@ """Test fixtures for the Open Thread Border Router integration.""" from collections.abc import Generator +from http import HTTPStatus +import re from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest +from python_otbr_api import KeyFormat from homeassistant.components import otbr from homeassistant.core import HomeAssistant @@ -19,6 +22,7 @@ ) from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker @pytest.fixture(name="enable_compute_pskc") @@ -44,6 +48,27 @@ def dataset_fixture() -> Any: return DATASET_CH16 +@pytest.fixture(name="key_format") +def key_format_fixture() -> KeyFormat: + """Override to control the OTBR JSON key format probe outcome.""" + return KeyFormat.PASCAL_CASE + + +@pytest.fixture(autouse=True) +def mock_api_actions( + aioclient_mock: AiohttpClientMocker, key_format: KeyFormat +) -> None: + """Mock the /api/actions probe used by python_otbr_api to detect key format. + + The probe was added in python_otbr_api 2.10.0: it returns 200 for OTBRs + that speak camelCase and 404 for older PascalCase OTBRs. + """ + status = ( + HTTPStatus.OK if key_format == KeyFormat.CAMEL_CASE else HTTPStatus.NOT_FOUND + ) + aioclient_mock.get(re.compile(r".*/api/actions$"), status=status) + + @pytest.fixture(name="get_active_dataset_tlvs") def get_active_dataset_tlvs_fixture(dataset: Any) -> Generator[AsyncMock]: """Mock get_active_dataset_tlvs.""" diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index a45bff9b212129..25cec8597b1da5 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -2,12 +2,14 @@ import asyncio from http import HTTPStatus +import re from typing import Any from unittest.mock import AsyncMock, Mock, patch import aiohttp import pytest import python_otbr_api +from python_otbr_api import KeyFormat from homeassistant.components import otbr from homeassistant.components.homeassistant_hardware import ( @@ -58,6 +60,25 @@ ) +def _expected_dataset_body(pan_id: int, key_format: KeyFormat) -> dict[str, Any]: + """Return the expected JSON body for a default-channel dataset PUT. + + python_otbr_api emits camelCase by default and rewrites to PascalCase only + when the /api/actions probe returns 404. + """ + if key_format == KeyFormat.PASCAL_CASE: + return { + "Channel": 15, + "NetworkName": f"ha-thread-{pan_id:04x}", + "PanId": pan_id, + } + return { + "channel": 15, + "networkName": f"ha-thread-{pan_id:04x}", + "panId": pan_id, + } + + @pytest.fixture(name="otbr_addon_info") def otbr_addon_info_fixture(addon_info: AsyncMock, addon_installed) -> AsyncMock: """Mock Supervisor otbr add-on info.""" @@ -153,6 +174,7 @@ async def test_user_flow_additional_entry_fail_get_address( # Do a user flow aioclient_mock.clear_requests() + aioclient_mock.get(re.compile(r".*/api/actions$"), status=HTTPStatus.NOT_FOUND) aioclient_mock.get(f"{url1}/node/ba-id", json=TEST_BORDER_AGENT_ID.hex()) aioclient_mock.get(f"{url2}/node/ba-id", status=HTTPStatus.NOT_FOUND) await _finish_user_flow(hass) @@ -239,9 +261,12 @@ async def test_user_flow_additional_entry_same_address( assert result["errors"] == {"base": "already_configured"} +@pytest.mark.parametrize("key_format", [KeyFormat.PASCAL_CASE, KeyFormat.CAMEL_CASE]) @pytest.mark.usefixtures("get_border_agent_id") async def test_user_flow_router_not_setup( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + key_format: KeyFormat, ) -> None: """Test the user flow when the border router has no dataset. @@ -278,12 +303,9 @@ async def test_user_flow_router_not_setup( # Check we create a dataset and enable the router assert aioclient_mock.mock_calls[-2][0] == "PUT" assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active" - pan_id = aioclient_mock.mock_calls[-2][2]["PanId"] - assert aioclient_mock.mock_calls[-2][2] == { - "Channel": 15, - "NetworkName": f"ha-thread-{pan_id:04x}", - "PanId": pan_id, - } + body = aioclient_mock.mock_calls[-2][2] + pan_id = body["PanId" if key_format == KeyFormat.PASCAL_CASE else "panId"] + assert body == _expected_dataset_body(pan_id, key_format) assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" @@ -671,9 +693,14 @@ async def _addon_info(slug: str) -> Mock: assert config_entry.unique_id == HASSIO_DATA.uuid +@pytest.mark.parametrize("key_format", [KeyFormat.PASCAL_CASE, KeyFormat.CAMEL_CASE]) @pytest.mark.usefixtures("get_border_agent_id") async def test_hassio_discovery_flow_router_not_setup( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_addon_info + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + multiprotocol_addon_manager_mock, + otbr_addon_info, + key_format: KeyFormat, ) -> None: """Test the hassio discovery flow when the border router has no dataset. @@ -701,12 +728,9 @@ async def test_hassio_discovery_flow_router_not_setup( # Check we create a dataset and enable the router assert aioclient_mock.mock_calls[-2][0] == "PUT" assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active" - pan_id = aioclient_mock.mock_calls[-2][2]["PanId"] - assert aioclient_mock.mock_calls[-2][2] == { - "Channel": 15, - "NetworkName": f"ha-thread-{pan_id:04x}", - "PanId": pan_id, - } + body = aioclient_mock.mock_calls[-2][2] + pan_id = body["PanId" if key_format == KeyFormat.PASCAL_CASE else "panId"] + assert body == _expected_dataset_body(pan_id, key_format) assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" @@ -731,7 +755,10 @@ async def test_hassio_discovery_flow_router_not_setup( @pytest.mark.usefixtures("get_border_agent_id") async def test_hassio_discovery_flow_router_not_setup_has_preferred( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_addon_info + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + multiprotocol_addon_manager_mock, + otbr_addon_info, ) -> None: """Test the hassio discovery flow when the border router has no dataset. @@ -782,12 +809,14 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred( assert config_entry.unique_id == HASSIO_DATA.uuid +@pytest.mark.parametrize("key_format", [KeyFormat.PASCAL_CASE, KeyFormat.CAMEL_CASE]) @pytest.mark.usefixtures("get_border_agent_id") async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, multiprotocol_addon_manager_mock, otbr_addon_info, + key_format: KeyFormat, ) -> None: """Test the hassio discovery flow when the border router has no dataset. @@ -818,12 +847,9 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( # Check we create a dataset and enable the router assert aioclient_mock.mock_calls[-2][0] == "PUT" assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active" - pan_id = aioclient_mock.mock_calls[-2][2]["PanId"] - assert aioclient_mock.mock_calls[-2][2] == { - "Channel": 15, - "NetworkName": f"ha-thread-{pan_id:04x}", - "PanId": pan_id, - } + body = aioclient_mock.mock_calls[-2][2] + pan_id = body["PanId" if key_format == KeyFormat.PASCAL_CASE else "panId"] + assert body == _expected_dataset_body(pan_id, key_format) assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" diff --git a/tests/components/picotts/test_tts.py b/tests/components/picotts/test_tts.py index 0ebce107b8017c..5a7e14198aa5c2 100644 --- a/tests/components/picotts/test_tts.py +++ b/tests/components/picotts/test_tts.py @@ -129,6 +129,7 @@ async def test_tts_service( ) == HTTPStatus.OK ) + await hass.async_block_till_done(wait_background_tasks=True) async def test_get_tts_audio_subprocess_error( diff --git a/tests/components/plex/test_sensor.py b/tests/components/plex/test_sensor.py index 02cbaac4db33be..695a33ef9d2de7 100644 --- a/tests/components/plex/test_sensor.py +++ b/tests/components/plex/test_sensor.py @@ -72,6 +72,9 @@ class MockPlexTVEpisode(MockPlexMedia): parentYear = 2021 +@pytest.mark.xfail( + reason="Flaky due to Python 3.14.3 asyncio changes - see home-assistant/core#162263" +) async def test_library_sensor_values( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/power/test_condition.py b/tests/components/power/test_condition.py index e5bff95dff50eb..3e9b7cf4a575bd 100644 --- a/tests/components/power/test_condition.py +++ b/tests/components/power/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, assert_numerical_condition_unit_conversion, parametrize_numerical_condition_above_below_all, parametrize_numerical_condition_above_below_any, @@ -37,6 +38,38 @@ async def test_power_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +_WATT_THRESHOLD = { + "threshold": { + "type": "above", + "value": {"number": 50, "unit_of_measurement": "W"}, + } +} + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("power.is_value", _WATT_THRESHOLD, True, False), + ], +) +async def test_power_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that power conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/pvoutput/test_config_flow.py b/tests/components/pvoutput/test_config_flow.py index fc4335de00dfba..9333100634dbf4 100644 --- a/tests/components/pvoutput/test_config_flow.py +++ b/tests/components/pvoutput/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock from pvo import PVOutputAuthenticationError, PVOutputConnectionError +import pytest from homeassistant.components.pvoutput.const import CONF_SYSTEM_ID, DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -242,3 +243,65 @@ async def test_reauth_api_error( assert result2.get("type") is FlowResultType.FORM assert result2.get("step_id") == "reauth_confirm" assert result2.get("errors") == {"base": "cannot_connect"} + + +@pytest.mark.usefixtures("mock_pvoutput") +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring an existing PVOutput entry.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new-api-key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new-api-key" + assert mock_config_entry.data[CONF_SYSTEM_ID] == 12345 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (PVOutputAuthenticationError, {"base": "invalid_auth"}), + (PVOutputConnectionError, {"base": "cannot_connect"}), + ], +) +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_pvoutput: MagicMock, + mock_config_entry: MockConfigEntry, + side_effect: type[Exception], + expected_error: dict[str, str], +) -> None: + """Test reconfigure flow recovers from errors.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + mock_pvoutput.system.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new-api-key"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == expected_error + + mock_pvoutput.system.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new-api-key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" diff --git a/tests/components/radio_frequency/conftest.py b/tests/components/radio_frequency/common.py similarity index 75% rename from tests/components/radio_frequency/conftest.py rename to tests/components/radio_frequency/common.py index 69538e3e18f580..fd070a56d0b6f7 100644 --- a/tests/components/radio_frequency/conftest.py +++ b/tests/components/radio_frequency/common.py @@ -1,8 +1,9 @@ -"""Common fixtures for the Radio Frequency tests.""" +"""Common test tools for the Radio Frequency integration.""" -from typing import override +from __future__ import annotations + +from typing import NamedTuple, override -import pytest from rf_protocols import ModulationType, RadioFrequencyCommand from homeassistant.components.radio_frequency import ( @@ -14,11 +15,11 @@ from homeassistant.setup import async_setup_component -@pytest.fixture -async def init_integration(hass: HomeAssistant) -> None: - """Set up the Radio Frequency integration for testing.""" - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() +class MockCommand(NamedTuple): + """Data structure to store calls to async_send_command.""" + + command: RadioFrequencyCommand + context: object | None class MockRadioFrequencyCommand(RadioFrequencyCommand): @@ -60,7 +61,7 @@ def __init__( if frequency_ranges is None else frequency_ranges ) - self.send_command_calls: list[RadioFrequencyCommand] = [] + self.send_command_calls: list[MockCommand] = [] @property def supported_frequency_ranges(self) -> list[tuple[int, int]]: @@ -69,14 +70,21 @@ def supported_frequency_ranges(self) -> list[tuple[int, int]]: async def async_send_command(self, command: RadioFrequencyCommand) -> None: """Mock send command.""" - self.send_command_calls.append(command) + self.send_command_calls.append( + MockCommand(command=command, context=self._context) + ) + + +async def init_radio_frequency_fixture_helper(hass: HomeAssistant) -> None: + """Set up the Radio Frequency integration for testing.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() -@pytest.fixture -async def mock_rf_entity( - hass: HomeAssistant, init_integration: None +async def mock_rf_entity_fixture_helper( + hass: HomeAssistant, ) -> MockRadioFrequencyEntity: - """Return a mock radio frequency entity.""" + """Add a mock radio frequency entity to the running integration.""" entity = MockRadioFrequencyEntity("test_rf_transmitter") component = hass.data[DATA_COMPONENT] await component.async_add_entities([entity]) diff --git a/tests/components/radio_frequency/test_init.py b/tests/components/radio_frequency/test_init.py index 35e9129f4549f7..76bac51195defc 100644 --- a/tests/components/radio_frequency/test_init.py +++ b/tests/components/radio_frequency/test_init.py @@ -19,7 +19,7 @@ from homeassistant.util import dt as dt_util from . import ENTITY_ID -from .conftest import MockRadioFrequencyCommand, MockRadioFrequencyEntity +from .common import MockRadioFrequencyCommand, MockRadioFrequencyEntity from tests.common import mock_restore_cache @@ -30,7 +30,7 @@ async def test_get_transmitters_component_not_loaded(hass: HomeAssistant) -> Non async_get_transmitters(hass, 433_920_000, ModulationType.OOK) -@pytest.mark.usefixtures("init_integration") +@pytest.mark.usefixtures("init_radio_frequency") async def test_get_transmitters_no_entities(hass: HomeAssistant) -> None: """Test getting transmitters raises when none are registered.""" with pytest.raises( @@ -80,7 +80,7 @@ async def test_async_send_command_success( await async_send_command(hass, ENTITY_ID, command) assert len(mock_rf_entity.send_command_calls) == 1 - assert mock_rf_entity.send_command_calls[0] is command + assert mock_rf_entity.send_command_calls[0].command is command state = hass.states.get(ENTITY_ID) assert state is not None @@ -110,7 +110,7 @@ async def test_async_send_command_error_does_not_update_state( assert state.state == STATE_UNKNOWN -@pytest.mark.usefixtures("init_integration") +@pytest.mark.usefixtures("init_radio_frequency") async def test_async_send_command_entity_not_found(hass: HomeAssistant) -> None: """Test async_send_command raises error when entity not found.""" command = MockRadioFrequencyCommand(frequency=433_920_000) diff --git a/tests/components/rdw/conftest.py b/tests/components/rdw/conftest.py index 328f347f3ee956..cba499e6236045 100644 --- a/tests/components/rdw/conftest.py +++ b/tests/components/rdw/conftest.py @@ -33,32 +33,19 @@ def mock_setup_entry() -> Generator[None]: @pytest.fixture -def mock_rdw_config_flow() -> Generator[MagicMock]: +def mock_rdw() -> Generator[MagicMock]: """Return a mocked RDW client.""" - with patch( - "homeassistant.components.rdw.config_flow.RDW", autospec=True - ) as rdw_mock: + with ( + patch( + "homeassistant.components.rdw.coordinator.RDW", autospec=True + ) as rdw_mock, + patch("homeassistant.components.rdw.config_flow.RDW", new=rdw_mock), + ): rdw = rdw_mock.return_value rdw.vehicle.return_value = Vehicle.from_json(load_fixture("rdw/11ZKZ3.json")) yield rdw -@pytest.fixture -def mock_rdw(request: pytest.FixtureRequest) -> Generator[MagicMock]: - """Return a mocked WLED client.""" - fixture: str = "rdw/11ZKZ3.json" - if hasattr(request, "param") and request.param: - fixture = request.param - - vehicle = Vehicle.from_json(load_fixture(fixture)) - with patch( - "homeassistant.components.rdw.coordinator.RDW", autospec=True - ) as rdw_mock: - rdw = rdw_mock.return_value - rdw.vehicle.return_value = vehicle - yield rdw - - @pytest.fixture async def init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_rdw: MagicMock diff --git a/tests/components/rdw/snapshots/test_diagnostics.ambr b/tests/components/rdw/snapshots/test_diagnostics.ambr index cc2a344025a4b0..cda685158a628e 100644 --- a/tests/components/rdw/snapshots/test_diagnostics.ambr +++ b/tests/components/rdw/snapshots/test_diagnostics.ambr @@ -8,13 +8,17 @@ 'aantal_zitplaatsen': 4, 'catalogusprijs': 10697, 'cilinderinhoud': 999, + 'datum_eerste_tenaamstelling_in_nederland': None, 'datum_eerste_toelating': '20130104', 'datum_tenaamstelling': '20211104', + 'eerste_kleur': 'Grijs', + 'europese_voertuigcategorie': 'M1', 'export_indicator': 'Nee', 'handelsbenaming': 'Citigo', 'inrichting': 'hatchback', 'jaar_laatste_registratie_tellerstand': 2021, 'kenteken': '11ZKZ3', + 'lengte': 356, 'massa_ledig_voertuig': 840, 'massa_rijklaar': 940, 'merk': 'Skoda', @@ -22,9 +26,12 @@ 'taxi_indicator': None, 'tellerstandoordeel': 'Logisch', 'tenaamstellen_mogelijk': 'Ja', + 'toegestane_maximum_massa_voertuig': 1290, + 'tweede_kleur': None, 'vervaldatum_apk': '20220104', 'voertuigsoort': 'Personenauto', 'wam_verzekerd': 'Nee', + 'wielbasis': 241, 'zuinigheidslabel': 'A', }) # --- diff --git a/tests/components/rdw/test_config_flow.py b/tests/components/rdw/test_config_flow.py index 2aa39f2c2d34fe..7db70a5deb09e0 100644 --- a/tests/components/rdw/test_config_flow.py +++ b/tests/components/rdw/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock +import pytest from vehicle.exceptions import RDWConnectionError, RDWUnknownLicensePlateError from homeassistant.components.rdw.const import CONF_LICENSE_PLATE, DOMAIN @@ -9,81 +10,85 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry -async def test_full_user_flow( - hass: HomeAssistant, mock_rdw_config_flow: MagicMock, mock_setup_entry: MagicMock -) -> None: +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@pytest.mark.usefixtures("mock_rdw") +async def test_full_user_flow(hass: HomeAssistant) -> None: """Test the full user configuration flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={ - CONF_LICENSE_PLATE: "11-ZKZ-3", - }, + user_input={CONF_LICENSE_PLATE: "11-ZKZ-3"}, ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("title") == "11-ZKZ-3" - assert result2.get("data") == {CONF_LICENSE_PLATE: "11ZKZ3"} - - -async def test_full_flow_with_authentication_error( - hass: HomeAssistant, mock_rdw_config_flow: MagicMock, mock_setup_entry: MagicMock + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "11-ZKZ-3" + assert result["data"] == {CONF_LICENSE_PLATE: "11ZKZ3"} + assert result["result"].unique_id == "11ZKZ3" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (RDWUnknownLicensePlateError, {"base": "unknown_license_plate"}), + (RDWConnectionError, {"base": "cannot_connect"}), + ], +) +async def test_user_flow_errors( + hass: HomeAssistant, + mock_rdw: MagicMock, + side_effect: type[Exception], + expected_error: dict[str, str], ) -> None: - """Test the full user configuration flow with incorrect license plate. + """Test the user flow with errors and recovery.""" + mock_rdw.vehicle.side_effect = side_effect - This tests tests a full config flow, with a case the user enters an invalid - license plate, but recover by entering the correct one. - """ result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - - mock_rdw_config_flow.vehicle.side_effect = RDWUnknownLicensePlateError - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={ - CONF_LICENSE_PLATE: "0001TJ", - }, + user_input={CONF_LICENSE_PLATE: "0001TJ"}, ) - assert result2.get("type") is FlowResultType.FORM - assert result2.get("step_id") == "user" - assert result2.get("errors") == {"base": "unknown_license_plate"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == expected_error - mock_rdw_config_flow.vehicle.side_effect = None - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - user_input={ - CONF_LICENSE_PLATE: "11-ZKZ-3", - }, + mock_rdw.vehicle.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LICENSE_PLATE: "11-ZKZ-3"}, ) - assert result3.get("type") is FlowResultType.CREATE_ENTRY - assert result3.get("title") == "11-ZKZ-3" - assert result3.get("data") == {CONF_LICENSE_PLATE: "11ZKZ3"} + assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_connection_error( - hass: HomeAssistant, mock_rdw_config_flow: MagicMock +@pytest.mark.usefixtures("mock_rdw") +async def test_user_flow_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, ) -> None: - """Test API connection error.""" - mock_rdw_config_flow.vehicle.side_effect = RDWConnectionError + """Test the user flow when the vehicle is already configured.""" + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_LICENSE_PLATE: "0001TJ"}, + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LICENSE_PLATE: "11-ZKZ-3"}, ) - assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/remote/test_condition.py b/tests/components/remote/test_condition.py index b3052de5bd7156..04c187f0dd68c4 100644 --- a/tests/components/remote/test_condition.py +++ b/tests/components/remote/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -39,6 +40,31 @@ async def test_remote_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("remote.is_off", {}, True, True), + ("remote.is_on", {}, True, True), + ], +) +async def test_remote_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that remote conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/rflink/test_binary_sensor.py b/tests/components/rflink/test_binary_sensor.py index fd113bceaa0f43..6bce2f6cab25d9 100644 --- a/tests/components/rflink/test_binary_sensor.py +++ b/tests/components/rflink/test_binary_sensor.py @@ -93,14 +93,20 @@ async def test_entity_availability( """If Rflink device is disconnected, entities should become unavailable.""" # Make sure Rflink mock does not 'recover' to quickly from the # disconnect or else the unavailability cannot be measured - config = CONFIG - failures = [True, True] - config[CONF_RECONNECT_INTERVAL] = 60 + config = { + **CONFIG, + "rflink": { + **CONFIG["rflink"], + CONF_RECONNECT_INTERVAL: 60, + }, + } + failures = [False, True] # Create platform and entities event_callback, _, _, disconnect_callback = await mock_rflink( hass, config, DOMAIN, monkeypatch, failures=failures ) + await hass.async_block_till_done() # Entities are unknown by default assert hass.states.get("binary_sensor.test").state == STATE_UNKNOWN diff --git a/tests/components/rflink/test_init.py b/tests/components/rflink/test_init.py index 736ad4c73cf1db..100ed07adf9cce 100644 --- a/tests/components/rflink/test_init.py +++ b/tests/components/rflink/test_init.py @@ -58,7 +58,7 @@ async def create_rflink_connection(*args, **kwargs): # failures can be a list of booleans indicating in which sequence # creating a connection should success or fail if failures: - fail = failures.pop() + fail = failures.pop(0) # removes from left to right else: fail = False diff --git a/tests/components/rflink/test_sensor.py b/tests/components/rflink/test_sensor.py index 2f0164a55f9a1e..05655cd52ae413 100644 --- a/tests/components/rflink/test_sensor.py +++ b/tests/components/rflink/test_sensor.py @@ -137,14 +137,20 @@ async def test_entity_availability( """If Rflink device is disconnected, entities should become unavailable.""" # Make sure Rflink mock does not 'recover' to quickly from the # disconnect or else the unavailability cannot be measured - config = CONFIG - failures = [True, True] - config[CONF_RECONNECT_INTERVAL] = 60 + config = { + **CONFIG, + "rflink": { + **CONFIG["rflink"], + CONF_RECONNECT_INTERVAL: 60, + }, + } + failures = [False, True] # Create platform and entities _, _, _, disconnect_callback = await mock_rflink( hass, config, DOMAIN, monkeypatch, failures=failures ) + await hass.async_block_till_done() # Entities are available by default assert hass.states.get("sensor.test").state == STATE_UNKNOWN diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index d574d495f8a2da..e1146639efbcdb 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -159,6 +159,7 @@ async def return_to_dock_side_effect(): b01_trait.find_me = AsyncMock() b01_trait.set_fan_speed = AsyncMock() b01_trait.set_mode = AsyncMock() + b01_trait.set_clean_path_preference = AsyncMock() b01_trait.set_water_level = AsyncMock() b01_trait.send = AsyncMock() return b01_trait diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index b62697fffc1d6f..e848800ab5eed6 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -5,6 +5,7 @@ from PIL import Image from roborock.data import ( B01Props, + CleanPathPreferenceMapping, CleanRecord, CleanSummary, Consumable, @@ -1558,6 +1559,7 @@ Q7_B01_PROPS = B01Props( status=WorkStatusMapping.SWEEP_MOPING, + clean_path_preference=CleanPathPreferenceMapping.BALANCED, main_brush=5000, side_brush=3000, hypa=1500, diff --git a/tests/components/roborock/snapshots/test_button.ambr b/tests/components/roborock/snapshots/test_button.ambr index 272e822965a0d7..9d376ab726722e 100644 --- a/tests/components/roborock/snapshots/test_button.ambr +++ b/tests/components/roborock/snapshots/test_button.ambr @@ -49,6 +49,106 @@ 'state': 'unknown', }) # --- +# name: test_buttons[button.roborock_s7_2_dock_reset_cleaning_brush_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.roborock_s7_2_dock_reset_cleaning_brush_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reset cleaning brush consumable', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset cleaning brush consumable', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_dock_cleaning_brush_consumable', + 'unique_id': 'reset_dock_cleaning_brush_consumable_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_2_dock_reset_cleaning_brush_consumable-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 2 Dock Reset cleaning brush consumable', + }), + 'context': , + 'entity_id': 'button.roborock_s7_2_dock_reset_cleaning_brush_consumable', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.roborock_s7_2_dock_reset_strainer_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.roborock_s7_2_dock_reset_strainer_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reset strainer consumable', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset strainer consumable', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_dock_strainer_consumable', + 'unique_id': 'reset_dock_strainer_consumable_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_2_dock_reset_strainer_consumable-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 2 Dock Reset strainer consumable', + }), + 'context': , + 'entity_id': 'button.roborock_s7_2_dock_reset_strainer_consumable', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[button.roborock_s7_2_reset_air_filter_consumable-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -349,6 +449,106 @@ 'state': 'unknown', }) # --- +# name: test_buttons[button.roborock_s7_maxv_dock_reset_cleaning_brush_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.roborock_s7_maxv_dock_reset_cleaning_brush_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reset cleaning brush consumable', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset cleaning brush consumable', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_dock_cleaning_brush_consumable', + 'unique_id': 'reset_dock_cleaning_brush_consumable_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_maxv_dock_reset_cleaning_brush_consumable-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 MaxV Dock Reset cleaning brush consumable', + }), + 'context': , + 'entity_id': 'button.roborock_s7_maxv_dock_reset_cleaning_brush_consumable', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.roborock_s7_maxv_dock_reset_strainer_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.roborock_s7_maxv_dock_reset_strainer_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reset strainer consumable', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset strainer consumable', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_dock_strainer_consumable', + 'unique_id': 'reset_dock_strainer_consumable_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_maxv_dock_reset_strainer_consumable-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 MaxV Dock Reset strainer consumable', + }), + 'context': , + 'entity_id': 'button.roborock_s7_maxv_dock_reset_strainer_consumable', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[button.roborock_s7_maxv_reset_air_filter_consumable-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/roborock/test_button.py b/tests/components/roborock/test_button.py index fcbbff13fb02fc..d64366389b6a52 100644 --- a/tests/components/roborock/test_button.py +++ b/tests/components/roborock/test_button.py @@ -4,14 +4,17 @@ import pytest from roborock import RoborockException +from roborock.data import RoborockDockTypeCode +from roborock.devices.traits.v1.consumeable import ConsumableAttribute from roborock.exceptions import RoborockTimeout from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import SERVICE_PRESS +from homeassistant.components.roborock.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import FakeDevice @@ -41,6 +44,46 @@ async def test_buttons( await snapshot_platform(hass, entity_registry, snapshot, setup_entry.entry_id) +@pytest.fixture +def non_wash_n_fill_dock(fake_vacuum: FakeDevice) -> None: + """Override dock_type to a non-wash-n-fill value so dock buttons are gated out.""" + status = fake_vacuum.v1_properties.status + original_refresh = status.refresh.side_effect + + async def patched_refresh() -> None: + await original_refresh() + status.dock_type = RoborockDockTypeCode.auto_empty_dock + + status.refresh.side_effect = patched_refresh + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_dock_buttons_absent_for_non_wash_n_fill_dock( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + non_wash_n_fill_dock: None, + setup_entry: MockConfigEntry, +) -> None: + """Dock consumable buttons must not be created when dock type is not wash-n-fill.""" + for entity_id in ( + "button.roborock_s7_maxv_dock_reset_strainer_consumable", + "button.roborock_s7_maxv_dock_reset_cleaning_brush_consumable", + ): + assert hass.states.get(entity_id) is None + # Non-dock consumable buttons must still exist. + for entity_id in ( + "button.roborock_s7_maxv_reset_sensor_consumable", + "button.roborock_s7_maxv_reset_air_filter_consumable", + "button.roborock_s7_maxv_reset_side_brush_consumable", + "button.roborock_s7_maxv_reset_main_brush_consumable", + ): + assert hass.states.get(entity_id) is not None + # No phantom dock device should be registered for the non-wash-n-fill vacuum. + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, "abc123_dock")}) is None + ) + + @pytest.fixture(name="consumeables_trait", autouse=True) def consumeables_trait_fixture(fake_vacuum: FakeDevice) -> Mock: """Get the fake vacuum device command trait for asserting that commands happened.""" @@ -49,12 +92,32 @@ def consumeables_trait_fixture(fake_vacuum: FakeDevice) -> Mock: @pytest.mark.parametrize( - ("entity_id"), + ("entity_id", "expected_attribute"), [ - ("button.roborock_s7_maxv_reset_sensor_consumable"), - ("button.roborock_s7_maxv_reset_air_filter_consumable"), - ("button.roborock_s7_maxv_reset_side_brush_consumable"), - ("button.roborock_s7_maxv_reset_main_brush_consumable"), + ( + "button.roborock_s7_maxv_reset_sensor_consumable", + ConsumableAttribute.SENSOR_DIRTY_TIME, + ), + ( + "button.roborock_s7_maxv_reset_air_filter_consumable", + ConsumableAttribute.FILTER_WORK_TIME, + ), + ( + "button.roborock_s7_maxv_reset_side_brush_consumable", + ConsumableAttribute.SIDE_BRUSH_WORK_TIME, + ), + ( + "button.roborock_s7_maxv_reset_main_brush_consumable", + ConsumableAttribute.MAIN_BRUSH_WORK_TIME, + ), + ( + "button.roborock_s7_maxv_dock_reset_strainer_consumable", + ConsumableAttribute.STRAINER_WORK_TIME, + ), + ( + "button.roborock_s7_maxv_dock_reset_cleaning_brush_consumable", + ConsumableAttribute.CLEANING_BRUSH_WORK_TIME, + ), ], ) @pytest.mark.freeze_time("2023-10-30 08:50:00") @@ -64,6 +127,7 @@ async def test_update_success( bypass_api_client_fixture: None, setup_entry: MockConfigEntry, entity_id: str, + expected_attribute: ConsumableAttribute, consumeables_trait: Mock, ) -> None: """Test pressing the button entities.""" @@ -75,7 +139,7 @@ async def test_update_success( blocking=True, target={"entity_id": entity_id}, ) - assert consumeables_trait.reset_consumable.assert_called_once + consumeables_trait.reset_consumable.assert_called_once_with(expected_attribute) assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index 7290614fca02d8..d2d0b806529826 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -6,6 +6,7 @@ import pytest from roborock import CleanTypeMapping, RoborockCommand from roborock.data import ( + CleanPathPreferenceMapping, RoborockDockDustCollectionModeCode, WaterLevelMapping, ZeoProgram, @@ -267,6 +268,64 @@ async def test_update_failure_q7_cleaning_mode( ) +async def test_q7_cleaning_route_state( + hass: HomeAssistant, + setup_entry: MockConfigEntry, +) -> None: + """Test Q7 cleaning route select state and options.""" + entity_id = "select.roborock_q7_cleaning_route" + state = hass.states.get(entity_id) + + assert state is not None + assert state.state == "balanced" + assert state.attributes["options"] == ["balanced", "deep"] + + +async def test_update_failure_q7_cleaning_route( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + q7_device: FakeDevice, +) -> None: + """Test failure when setting Q7 cleaning route.""" + assert q7_device.b01_q7_properties + q7_device.b01_q7_properties.set_clean_path_preference.side_effect = ( + RoborockException + ) + + with pytest.raises(HomeAssistantError, match="Error while calling cleaning_route"): + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + service_data={"option": "deep"}, + blocking=True, + target={"entity_id": "select.roborock_q7_cleaning_route"}, + ) + + +async def test_update_success_q7_cleaning_route( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + q7_device: FakeDevice, +) -> None: + """Test allowed changing values for Q7 cleaning route select entity.""" + entity_id = "select.roborock_q7_cleaning_route" + assert hass.states.get(entity_id) is not None + + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + service_data={"option": "deep"}, + blocking=True, + target={"entity_id": entity_id}, + ) + + assert q7_device.b01_q7_properties + assert q7_device.b01_q7_properties.set_clean_path_preference.call_count == 1 + q7_device.b01_q7_properties.set_clean_path_preference.assert_called_with( + CleanPathPreferenceMapping.DEEP + ) + + async def test_update_success_q7_cleaning_mode( hass: HomeAssistant, setup_entry: MockConfigEntry, diff --git a/tests/components/schedule/test_condition.py b/tests/components/schedule/test_condition.py index e9eb1fcf61cc4f..107d83183779ef 100644 --- a/tests/components/schedule/test_condition.py +++ b/tests/components/schedule/test_condition.py @@ -13,6 +13,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -40,6 +41,31 @@ async def test_schedule_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("schedule.is_off", {}, True, True), + ("schedule.is_on", {}, True, True), + ], +) +async def test_schedule_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that schedule conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index a0cf18b8785412..acecc458f6bbb8 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -1929,3 +1929,40 @@ async def test_reload_when_labs_flag_changes( assert hass.states.get(f"script.{active_object_id}") is not None assert hass.services.has_service(script.DOMAIN, active_object_id) + + +async def test_remove_script_entity_unloads_script(hass: HomeAssistant) -> None: + """Test that removing a script entity unloads its underlying script.""" + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test_script": { + "sequence": [{"event": "test_event"}], + } + } + }, + ) + + entity = hass.data[script.DOMAIN].get_entity("script.test_script") + assert entity is not None + assert isinstance(entity, ScriptEntity) + + # Reload with empty config to remove the script + with ( + patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value={script.DOMAIN: {}}, + ), + patch.object( + entity.script, + "async_unload", + wraps=entity.script.async_unload, + ) as script_unload, + ): + await hass.services.async_call(script.DOMAIN, SERVICE_RELOAD, blocking=True) + await hass.async_block_till_done() + + script_unload.assert_called_once() diff --git a/tests/components/select/test_condition.py b/tests/components/select/test_condition.py index edd97c41ee2698..7b5b641b40ca9c 100644 --- a/tests/components/select/test_condition.py +++ b/tests/components/select/test_condition.py @@ -16,6 +16,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -47,6 +48,30 @@ async def test_select_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("select.is_option_selected", {"option": ["option_a"]}, True, False), + ], +) +async def test_select_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that select conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), @@ -218,19 +243,19 @@ async def test_select_condition_evaluates_both_domains( condition_options={CONF_OPTION: ["option_a", "option_b"]}, ) - assert cond(hass) is True + assert cond.async_check() is True # Set one to a non-matching option - "any" behavior should still pass hass.states.async_set(entity_id_select, "option_c") await hass.async_block_till_done() - assert cond(hass) is True + assert cond.async_check() is True # Set both to non-matching options hass.states.async_set(entity_id_input_select, "option_c") await hass.async_block_till_done() - assert cond(hass) is False + assert cond.async_check() is False # --- Schema validation tests --- diff --git a/tests/components/sensor/common.py b/tests/components/sensor/common.py index 4dedababad1ce9..3721e7be220652 100644 --- a/tests/components/sensor/common.py +++ b/tests/components/sensor/common.py @@ -95,6 +95,7 @@ SensorDeviceClass.TEMPERATURE: UnitOfTemperature.CELSIUS, SensorDeviceClass.TEMPERATURE_DELTA: UnitOfTemperature.CELSIUS, SensorDeviceClass.TIMESTAMP: None, + SensorDeviceClass.UPTIME: None, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: CONCENTRATION_PARTS_PER_MILLION, SensorDeviceClass.VOLTAGE: UnitOfElectricPotential.VOLT, diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 67f07e3293a364..59aeb8cc8d5358 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -101,6 +101,7 @@ async def test_get_conditions( SensorDeviceClass.DATE, SensorDeviceClass.ENUM, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, } expected_conditions = [ { @@ -202,6 +203,7 @@ async def test_get_conditions_no_state( SensorDeviceClass.DATE, # No condition SensorDeviceClass.ENUM, # No condition SensorDeviceClass.TIMESTAMP, # No condition + SensorDeviceClass.UPTIME, # No condition SensorDeviceClass.AQI, # No unit of measurement SensorDeviceClass.PH, # No unit of measurement SensorDeviceClass.MONETARY, # No unit of measurement diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index 8b407ac5576c06..d796dd1158a668 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -103,6 +103,7 @@ async def test_get_triggers( SensorDeviceClass.DATE, SensorDeviceClass.ENUM, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, } expected_triggers = [ { diff --git a/tests/components/sensor/test_helpers.py b/tests/components/sensor/test_helpers.py index e197579fa6674b..2fc89f2585577d 100644 --- a/tests/components/sensor/test_helpers.py +++ b/tests/components/sensor/test_helpers.py @@ -6,10 +6,15 @@ from homeassistant.components.sensor.helpers import async_parse_date_datetime -def test_async_parse_datetime(caplog: pytest.LogCaptureFixture) -> None: +@pytest.mark.parametrize( + "device_class", + [SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME], +) +def test_async_parse_datetime( + caplog: pytest.LogCaptureFixture, device_class: SensorDeviceClass +) -> None: """Test async_parse_date_datetime.""" entity_id = "sensor.timestamp" - device_class = SensorDeviceClass.TIMESTAMP assert ( async_parse_date_datetime( "2021-12-12 12:12Z", entity_id, device_class diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index e9ae2ba4f7520d..3a03a608ce8806 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Generator -from datetime import UTC, date, datetime +from datetime import UTC, date, datetime, timedelta from decimal import Decimal import math from typing import Any @@ -23,6 +23,7 @@ DEVICE_CLASS_UNITS, DOMAIN, NON_NUMERIC_DEVICE_CLASSES, + UPTIME_DEFAULT_TOLERANCE_SECONDS, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -283,6 +284,44 @@ async def test_datetime_conversion( assert state.state == test_timestamp.isoformat() +@pytest.mark.parametrize("drift_tolerance", [UPTIME_DEFAULT_TOLERANCE_SECONDS, 10]) +async def test_uptime_device_class_auto_normalizes_drift( + hass: HomeAssistant, drift_tolerance +) -> None: + """Test uptime device class suppresses small drift automatically.""" + initial_uptime = datetime(2026, 2, 14, 9, 30, tzinfo=UTC) + entity = MockSensor( + name="Test", + native_value=initial_uptime, + device_class=SensorDeviceClass.UPTIME, + ) + entity._attr_uptime_drift_tolerance = drift_tolerance + setup_test_component_platform(hass, sensor.DOMAIN, [entity]) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity.entity_id)) + assert state.state == initial_uptime.isoformat(timespec="seconds") + + entity._values["native_value"] = initial_uptime + timedelta( + seconds=drift_tolerance - 1 + ) + entity.async_write_ha_state() + await hass.async_block_till_done() + + assert (state := hass.states.get(entity.entity_id)) + assert state.state == initial_uptime.isoformat(timespec="seconds") + + updated_uptime = initial_uptime + timedelta(seconds=drift_tolerance + 1) + entity._values["native_value"] = updated_uptime + entity.async_write_ha_state() + await hass.async_block_till_done() + + assert (state := hass.states.get(entity.entity_id)) + assert state.state == updated_uptime.isoformat(timespec="seconds") + + async def test_a_sensor_with_a_non_numeric_device_class( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -2200,6 +2239,7 @@ async def test_invalid_enumeration_entity_without_device_class( SensorDeviceClass.DATE, SensorDeviceClass.ENUM, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, ], ) async def test_non_numeric_device_class_with_unit_of_measurement( @@ -2554,6 +2594,7 @@ async def test_device_classes_with_invalid_state_class( (SensorDeviceClass.ENUM, None, None, None, False), (SensorDeviceClass.DATE, None, None, None, False), (SensorDeviceClass.TIMESTAMP, None, None, None, False), + (SensorDeviceClass.UPTIME, None, None, None, False), ("custom", None, None, None, False), (SensorDeviceClass.POWER, None, "V", None, True), (None, SensorStateClass.MEASUREMENT, None, None, True), @@ -3097,6 +3138,7 @@ def test_device_class_units_are_complete() -> None: SensorDeviceClass.ENUM, SensorDeviceClass.MONETARY, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, } unit_device_classes = { device_class.value for device_class in SensorDeviceClass @@ -3126,6 +3168,7 @@ def test_device_class_converters_are_complete() -> None: SensorDeviceClass.SIGNAL_STRENGTH, SensorDeviceClass.SOUND_PRESSURE, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, SensorDeviceClass.WIND_DIRECTION, } converter_device_classes = { diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 8ded53a6f583f7..4b79fe9b79dba2 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -67,7 +67,7 @@ async def init_integration( if not skip_setup: await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) return entry diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 137d5dc9f3e06e..62941e0ffbe9fb 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -519,21 +519,20 @@ def events(hass: HomeAssistant): async def mock_block_device(model: str = MODEL_1): """Mock block (Gen1, CoAP) device.""" with patch("aioshelly.block_device.BlockDevice.create") as block_device_mock: + _update_listener = None - def update(): - block_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, BlockUpdateType.COAP_PERIODIC - ) + def _update(): + _update_listener({}, BlockUpdateType.COAP_PERIODIC) - def update_reply(): - block_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, BlockUpdateType.COAP_REPLY - ) + def _update_reply(): + _update_listener({}, BlockUpdateType.COAP_REPLY) - def online(): - block_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, BlockUpdateType.ONLINE - ) + def _online(): + _update_listener({}, BlockUpdateType.ONLINE) + + def _subscribe_updates(listener): + nonlocal _update_listener + _update_listener = listener device = Mock( spec=BlockDevice, @@ -550,11 +549,14 @@ def online(): ) type(device).name = PropertyMock(return_value="Test name") block_device_mock.return_value = device - block_device_mock.return_value.mock_update = Mock(side_effect=update) + block_device_mock.return_value.mock_update = Mock(side_effect=_update) block_device_mock.return_value.mock_update_reply = Mock( - side_effect=update_reply + side_effect=_update_reply + ) + block_device_mock.return_value.mock_online = Mock(side_effect=_online) + block_device_mock.return_value.subscribe_updates = Mock( + side_effect=_subscribe_updates ) - block_device_mock.return_value.mock_online = Mock(side_effect=online) yield block_device_mock.return_value @@ -622,31 +624,26 @@ async def mock_rpc_device(): patch("aioshelly.rpc_device.RpcDevice.create") as rpc_device_mock, patch("homeassistant.components.shelly.bluetooth.async_start_scanner"), ): + _update_listener = None - def update(): - rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, RpcUpdateType.STATUS - ) + def _update(): + _update_listener({}, RpcUpdateType.STATUS) - def event(): - rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, RpcUpdateType.EVENT - ) + def _event(): + _update_listener({}, RpcUpdateType.EVENT) - def online(): - rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, RpcUpdateType.ONLINE - ) + def _online(): + _update_listener({}, RpcUpdateType.ONLINE) - def disconnected(): - rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, RpcUpdateType.DISCONNECTED - ) + def _disconnected(): + _update_listener({}, RpcUpdateType.DISCONNECTED) - def initialized(): - rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, RpcUpdateType.INITIALIZED - ) + def _initialized(): + _update_listener({}, RpcUpdateType.INITIALIZED) + + def _subscribe_updates(listener): + nonlocal _update_listener + _update_listener = listener current_pos = iter(range(50, -1, -10)) # from 50 to 0 in steps of 10 @@ -657,14 +654,17 @@ async def update_cover_status(cover_id: int): device = _mock_rpc_device() rpc_device_mock.return_value = device - rpc_device_mock.return_value.mock_disconnected = Mock(side_effect=disconnected) - rpc_device_mock.return_value.mock_update = Mock(side_effect=update) - rpc_device_mock.return_value.mock_event = Mock(side_effect=event) - rpc_device_mock.return_value.mock_online = Mock(side_effect=online) - rpc_device_mock.return_value.mock_initialized = Mock(side_effect=initialized) + rpc_device_mock.return_value.mock_disconnected = Mock(side_effect=_disconnected) + rpc_device_mock.return_value.mock_update = Mock(side_effect=_update) + rpc_device_mock.return_value.mock_event = Mock(side_effect=_event) + rpc_device_mock.return_value.mock_online = Mock(side_effect=_online) + rpc_device_mock.return_value.mock_initialized = Mock(side_effect=_initialized) rpc_device_mock.return_value.update_cover_status = AsyncMock( side_effect=update_cover_status ) + rpc_device_mock.return_value.subscribe_updates = Mock( + side_effect=_subscribe_updates + ) yield rpc_device_mock.return_value @@ -749,31 +749,26 @@ async def mock_sleepy_rpc_device(): Initialize the device when initialize() method is called. """ with patch("aioshelly.rpc_device.RpcDevice.create") as rpc_device_mock: + _update_listener = None - def update(): - rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, RpcUpdateType.STATUS - ) + def _update(): + _update_listener({}, RpcUpdateType.STATUS) - def event(): - rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, RpcUpdateType.EVENT - ) + def _event(): + _update_listener({}, RpcUpdateType.EVENT) - def online(): - rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, RpcUpdateType.ONLINE - ) + def _online(): + _update_listener({}, RpcUpdateType.ONLINE) - def disconnected(): - rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, RpcUpdateType.DISCONNECTED - ) + def _disconnected(): + _update_listener({}, RpcUpdateType.DISCONNECTED) - def initialized(): - rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, RpcUpdateType.INITIALIZED - ) + def _initialized(): + _update_listener({}, RpcUpdateType.INITIALIZED) + + def _subscribe_updates(listener): + nonlocal _update_listener + _update_listener = listener def _initialize(): initialize_sleepy_rpc_device(device) @@ -782,11 +777,14 @@ def _initialize(): device.initialize = AsyncMock(side_effect=_initialize) rpc_device_mock.return_value = device - rpc_device_mock.return_value.mock_disconnected = Mock(side_effect=disconnected) - rpc_device_mock.return_value.mock_update = Mock(side_effect=update) - rpc_device_mock.return_value.mock_event = Mock(side_effect=event) - rpc_device_mock.return_value.mock_online = Mock(side_effect=online) - rpc_device_mock.return_value.mock_initialized = Mock(side_effect=initialized) + rpc_device_mock.return_value.mock_disconnected = Mock(side_effect=_disconnected) + rpc_device_mock.return_value.mock_update = Mock(side_effect=_update) + rpc_device_mock.return_value.mock_event = Mock(side_effect=_event) + rpc_device_mock.return_value.mock_online = Mock(side_effect=_online) + rpc_device_mock.return_value.mock_initialized = Mock(side_effect=_initialized) + rpc_device_mock.return_value.subscribe_updates = Mock( + side_effect=_subscribe_updates + ) yield rpc_device_mock.return_value diff --git a/tests/components/shelly/snapshots/test_devices.ambr b/tests/components/shelly/snapshots/test_devices.ambr index 24b3175f2e0568..1db1a473fb493f 100644 --- a/tests/components/shelly/snapshots/test_devices.ambr +++ b/tests/components/shelly/snapshots/test_devices.ambr @@ -443,57 +443,6 @@ 'state': 'living_room', }) # --- -# name: test_device[cury_gen4][sensor.test_name_last_restart-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.test_name_last_restart', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Last restart', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Last restart', - 'platform': 'shelly', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'last_restart', - 'unique_id': '123456789ABC-sys-uptime', - 'unit_of_measurement': None, - }) -# --- -# name: test_device[cury_gen4][sensor.test_name_last_restart-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'Test name Last restart', - }), - 'context': , - 'entity_id': 'sensor.test_name_last_restart', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2025-05-26T12:31:05+00:00', - }) -# --- # name: test_device[cury_gen4][sensor.test_name_left_slot_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -757,6 +706,57 @@ 'state': '-49', }) # --- +# name: test_device[cury_gen4][sensor.test_name_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_name_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Uptime', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_device[cury_gen4][sensor.test_name_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'uptime', + 'friendly_name': 'Test name Uptime', + }), + 'context': , + 'entity_id': 'sensor.test_name_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-05-26T12:31:05+00:00', + }) +# --- # name: test_device[cury_gen4][switch.test_name_away_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1354,57 +1354,6 @@ 'state': 'off', }) # --- -# name: test_device[duo_bulb_gen3][sensor.test_name_last_restart-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.test_name_last_restart', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Last restart', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Last restart', - 'platform': 'shelly', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'last_restart', - 'unique_id': '123456789ABC-sys-uptime', - 'unit_of_measurement': None, - }) -# --- -# name: test_device[duo_bulb_gen3][sensor.test_name_last_restart-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'Test name Last restart', - }), - 'context': , - 'entity_id': 'sensor.test_name_last_restart', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2025-05-24T02:30:09+00:00', - }) -# --- # name: test_device[duo_bulb_gen3][sensor.test_name_living_room_lamp_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1579,6 +1528,57 @@ 'state': '-50', }) # --- +# name: test_device[duo_bulb_gen3][sensor.test_name_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_name_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Uptime', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_device[duo_bulb_gen3][sensor.test_name_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'uptime', + 'friendly_name': 'Test name Uptime', + }), + 'context': , + 'entity_id': 'sensor.test_name_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-05-24T02:30:09+00:00', + }) +# --- # name: test_device[duo_bulb_gen3][update.test_name_beta_firmware-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -3563,57 +3563,6 @@ 'state': '231.2', }) # --- -# name: test_device[power_strip_gen4][sensor.test_name_last_restart-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.test_name_last_restart', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Last restart', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Last restart', - 'platform': 'shelly', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'last_restart', - 'unique_id': '123456789ABC-sys-uptime', - 'unit_of_measurement': None, - }) -# --- -# name: test_device[power_strip_gen4][sensor.test_name_last_restart-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'Test name Last restart', - }), - 'context': , - 'entity_id': 'sensor.test_name_last_restart', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2025-04-02T18:10:04+00:00', - }) -# --- # name: test_device[power_strip_gen4][sensor.test_name_output_0_current-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -4499,6 +4448,57 @@ 'state': '-68', }) # --- +# name: test_device[power_strip_gen4][sensor.test_name_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_name_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Uptime', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_device[power_strip_gen4][sensor.test_name_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'uptime', + 'friendly_name': 'Test name Uptime', + }), + 'context': , + 'entity_id': 'sensor.test_name_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-02T18:10:04+00:00', + }) +# --- # name: test_device[power_strip_gen4][switch.switch_1_name-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -5143,57 +5143,6 @@ 'state': 'twilight', }) # --- -# name: test_device[presence_gen4][sensor.test_name_last_restart-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.test_name_last_restart', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Last restart', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Last restart', - 'platform': 'shelly', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'last_restart', - 'unique_id': '123456789ABC-sys-uptime', - 'unit_of_measurement': None, - }) -# --- -# name: test_device[presence_gen4][sensor.test_name_last_restart-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'Test name Last restart', - }), - 'context': , - 'entity_id': 'sensor.test_name_last_restart', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2025-05-26T15:55:14+00:00', - }) -# --- # name: test_device[presence_gen4][sensor.test_name_my_zone_detected_objects-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -5411,6 +5360,57 @@ 'state': '-60', }) # --- +# name: test_device[presence_gen4][sensor.test_name_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_name_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Uptime', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_device[presence_gen4][sensor.test_name_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'uptime', + 'friendly_name': 'Test name Uptime', + }), + 'context': , + 'entity_id': 'sensor.test_name_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-05-26T15:55:14+00:00', + }) +# --- # name: test_device[presence_gen4][update.test_name_beta_firmware-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -6075,6 +6075,61 @@ 'state': 'unknown', }) # --- +# name: test_device[wall_display_xl][media_player.test_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-media-media', + 'unit_of_measurement': None, + }) +# --- +# name: test_device[wall_display_xl][media_player.test_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test name', + 'media_content_type': , + 'supported_features': , + 'volume_level': 0.7, + }), + 'context': , + 'entity_id': 'media_player.test_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- # name: test_device[wall_display_xl][sensor.test_name_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -6250,13 +6305,15 @@ 'state': 'twilight', }) # --- -# name: test_device[wall_display_xl][sensor.test_name_last_restart-entry] +# name: test_device[wall_display_xl][sensor.test_name_signal_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -6264,7 +6321,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.test_name_last_restart', + 'entity_id': 'sensor.test_name_signal_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -6272,36 +6329,38 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Last restart', + 'object_id_base': 'Signal strength', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Last restart', + 'original_name': 'Signal strength', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'last_restart', - 'unique_id': '123456789ABC-sys-uptime', - 'unit_of_measurement': None, + 'translation_key': None, + 'unique_id': '123456789ABC-wifi-rssi', + 'unit_of_measurement': 'dBm', }) # --- -# name: test_device[wall_display_xl][sensor.test_name_last_restart-state] +# name: test_device[wall_display_xl][sensor.test_name_signal_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'Test name Last restart', + 'device_class': 'signal_strength', + 'friendly_name': 'Test name Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', }), 'context': , - 'entity_id': 'sensor.test_name_last_restart', + 'entity_id': 'sensor.test_name_signal_strength', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2025-05-15T21:33:41+00:00', + 'state': '-48', }) # --- -# name: test_device[wall_display_xl][sensor.test_name_signal_strength-entry] +# name: test_device[wall_display_xl][sensor.test_name_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -6316,8 +6375,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.test_name_signal_strength', + 'entity_category': None, + 'entity_id': 'sensor.test_name_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -6325,54 +6384,55 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Signal strength', + 'object_id_base': 'Temperature', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Signal strength', + 'original_name': 'Temperature', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456789ABC-wifi-rssi', - 'unit_of_measurement': 'dBm', + 'unique_id': '123456789ABC-temperature:0-temperature_tc', + 'unit_of_measurement': , }) # --- -# name: test_device[wall_display_xl][sensor.test_name_signal_strength-state] +# name: test_device[wall_display_xl][sensor.test_name_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'signal_strength', - 'friendly_name': 'Test name Signal strength', + 'device_class': 'temperature', + 'friendly_name': 'Test name Temperature', 'state_class': , - 'unit_of_measurement': 'dBm', + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.test_name_signal_strength', + 'entity_id': 'sensor.test_name_temperature', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-48', + 'state': '-275.149993896484', }) # --- -# name: test_device[wall_display_xl][sensor.test_name_temperature-entry] +# name: test_device[wall_display_xl][sensor.test_name_uptime-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, ]), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_name_temperature', + 'entity_category': , + 'entity_id': 'sensor.test_name_uptime', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -6380,38 +6440,33 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Temperature', + 'object_id_base': 'Uptime', 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Temperature', + 'original_name': 'Uptime', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456789ABC-temperature:0-temperature_tc', - 'unit_of_measurement': , + 'unique_id': '123456789ABC-sys-uptime', + 'unit_of_measurement': None, }) # --- -# name: test_device[wall_display_xl][sensor.test_name_temperature-state] +# name: test_device[wall_display_xl][sensor.test_name_uptime-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Test name Temperature', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'uptime', + 'friendly_name': 'Test name Uptime', }), 'context': , - 'entity_id': 'sensor.test_name_temperature', + 'entity_id': 'sensor.test_name_uptime', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-275.149993896484', + 'state': '2025-05-15T21:33:41+00:00', }) # --- # name: test_device[wall_display_xl][switch.test_name-entry] @@ -7279,57 +7334,6 @@ 'state': '50.0', }) # --- -# name: test_shelly_2pm_gen3_cover[sensor.test_name_last_restart-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.test_name_last_restart', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Last restart', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Last restart', - 'platform': 'shelly', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'last_restart', - 'unique_id': '123456789ABC-sys-uptime', - 'unit_of_measurement': None, - }) -# --- -# name: test_shelly_2pm_gen3_cover[sensor.test_name_last_restart-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'Test name Last restart', - }), - 'context': , - 'entity_id': 'sensor.test_name_last_restart', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2025-05-26T15:57:39+00:00', - }) -# --- # name: test_shelly_2pm_gen3_cover[sensor.test_name_power-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -7501,6 +7505,57 @@ 'state': '36.4', }) # --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_name_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Uptime', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'uptime', + 'friendly_name': 'Test name Uptime', + }), + 'context': , + 'entity_id': 'sensor.test_name_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-05-26T15:57:39+00:00', + }) +# --- # name: test_shelly_2pm_gen3_cover[sensor.test_name_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -8348,57 +8403,6 @@ 'state': 'unknown', }) # --- -# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_last_restart-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.test_name_last_restart', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Last restart', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Last restart', - 'platform': 'shelly', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'last_restart', - 'unique_id': '123456789ABC-sys-uptime', - 'unit_of_measurement': None, - }) -# --- -# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_last_restart-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'Test name Last restart', - }), - 'context': , - 'entity_id': 'sensor.test_name_last_restart', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2025-05-26T16:02:17+00:00', - }) -# --- # name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_output_0_current-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -9400,6 +9404,57 @@ 'state': '-52', }) # --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_name_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Uptime', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'uptime', + 'friendly_name': 'Test name Uptime', + }), + 'context': , + 'entity_id': 'sensor.test_name_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-05-26T16:02:17+00:00', + }) +# --- # name: test_shelly_2pm_gen3_no_relay_names[switch.test_name_output_0-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -10017,57 +10072,6 @@ 'state': '0.0', }) # --- -# name: test_shelly_pro_3em[sensor.test_name_last_restart-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.test_name_last_restart', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Last restart', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Last restart', - 'platform': 'shelly', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'last_restart', - 'unique_id': '123456789ABC-sys-uptime', - 'unit_of_measurement': None, - }) -# --- -# name: test_shelly_pro_3em[sensor.test_name_last_restart-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'Test name Last restart', - }), - 'context': , - 'entity_id': 'sensor.test_name_last_restart', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2025-05-20T20:42:37+00:00', - }) -# --- # name: test_shelly_pro_3em[sensor.test_name_neutral_current-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -11695,6 +11699,57 @@ 'state': '46.3', }) # --- +# name: test_shelly_pro_3em[sensor.test_name_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_name_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Uptime', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'uptime', + 'friendly_name': 'Test name Uptime', + }), + 'context': , + 'entity_id': 'sensor.test_name_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-05-20T20:42:37+00:00', + }) +# --- # name: test_shelly_pro_3em[update.test_name_beta_firmware-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/shelly/snapshots/test_media_player.ambr b/tests/components/shelly/snapshots/test_media_player.ambr new file mode 100644 index 00000000000000..d4b527d52867c1 --- /dev/null +++ b/tests/components/shelly/snapshots/test_media_player.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_rpc_media_player[media_player.test_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-media-media', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_media_player[media_player.test_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'entity_picture': 'https://www.radio_station.pl/icon.png', + 'friendly_name': 'Test name', + 'media_content_type': , + 'media_title': 'Radio Station', + 'supported_features': , + 'volume_level': 0.5, + }), + 'context': , + 'entity_id': 'media_player.test_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index 695e0a11eb23fe..9fe05b75bb0d80 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -1,7 +1,7 @@ """Tests for Shelly binary sensor platform.""" from copy import deepcopy -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock, patch from aioshelly.const import ( MODEL_BLU_GATEWAY_G3, @@ -10,6 +10,7 @@ MODEL_MOTION, MODEL_PLUS_SMOKE, ) +from aioshelly.exceptions import DeviceConnectionError from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -335,7 +336,13 @@ async def test_rpc_sleeping_binary_sensor( entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_cloud" monkeypatch.setattr(mock_rpc_device, "connected", False) monkeypatch.setitem(mock_rpc_device.status["sys"], "wakeup_period", 1000) - config_entry = await init_integration(hass, 2, sleep_period=1000) + with patch.object( + mock_rpc_device, + "initialize", + new_callable=AsyncMock, + side_effect=DeviceConnectionError, + ): + config_entry = await init_integration(hass, 2, sleep_period=1000) # Sensor should be created when device is online assert hass.states.get(entity_id) is None @@ -376,7 +383,13 @@ async def test_rpc_sleeping_binary_sensor_with_channel_name( entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_test_channel_name_smoke" monkeypatch.setattr(mock_rpc_device, "connected", False) monkeypatch.setitem(mock_rpc_device.status["sys"], "wakeup_period", 1000) - await init_integration(hass, 2, sleep_period=1000, model=MODEL_PLUS_SMOKE) + with patch.object( + mock_rpc_device, + "initialize", + new_callable=AsyncMock, + side_effect=DeviceConnectionError, + ): + await init_integration(hass, 2, sleep_period=1000, model=MODEL_PLUS_SMOKE) # Sensor should be created when device is online assert hass.states.get(entity_id) is None diff --git a/tests/components/shelly/test_button.py b/tests/components/shelly/test_button.py index a32ab642df0840..c82e71eacccf54 100644 --- a/tests/components/shelly/test_button.py +++ b/tests/components/shelly/test_button.py @@ -1,7 +1,7 @@ """Tests for Shelly button platform.""" from copy import deepcopy -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock, patch from aioshelly.const import MODEL_BLU_GATEWAY_G3, MODEL_PLUS_SMOKE, MODEL_WALL_DISPLAY from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError @@ -494,7 +494,13 @@ async def test_rpc_smoke_mute_alarm_button( monkeypatch.setitem(mock_rpc_device.status["sys"], "wakeup_period", 1000) monkeypatch.setattr(mock_rpc_device, "config", {"smoke:0": {"id": 0, "name": None}}) monkeypatch.setattr(mock_rpc_device, "connected", False) - await init_integration(hass, 2, sleep_period=1000, model=MODEL_PLUS_SMOKE) + with patch.object( + mock_rpc_device, + "initialize", + new_callable=AsyncMock, + side_effect=DeviceConnectionError, + ): + await init_integration(hass, 2, sleep_period=1000, model=MODEL_PLUS_SMOKE) # Sensor should be created when device is online assert hass.states.get(entity_id) is None diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index d1cb61dd8dc63c..391b85b35c079d 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -2758,8 +2758,14 @@ async def test_zeroconf_sleeping_device_not_triggers_refresh( }, ) entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + with patch.object( + mock_rpc_device, + "initialize", + new_callable=AsyncMock, + side_effect=DeviceConnectionError, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) @@ -2811,10 +2817,14 @@ async def test_zeroconf_sleeping_device_attempts_configure( }, ) entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - mock_rpc_device.mock_disconnected() - await hass.async_block_till_done() + with patch.object( + mock_rpc_device, + "initialize", + new_callable=AsyncMock, + side_effect=DeviceConnectionError, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) @@ -2877,10 +2887,14 @@ async def test_zeroconf_sleeping_device_attempts_configure_ws_disabled( }, ) entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - mock_rpc_device.mock_disconnected() - await hass.async_block_till_done() + with patch.object( + mock_rpc_device, + "initialize", + new_callable=AsyncMock, + side_effect=DeviceConnectionError, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) @@ -2943,10 +2957,14 @@ async def test_zeroconf_sleeping_device_attempts_configure_no_url_available( }, ) entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - mock_rpc_device.mock_disconnected() - await hass.async_block_till_done() + with patch.object( + mock_rpc_device, + "initialize", + new_callable=AsyncMock, + side_effect=DeviceConnectionError, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index d0d41dda76b7e7..19dd50edb2cfa0 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -1099,8 +1099,14 @@ async def test_rpc_sleeping_device_late_setup( register_device(device_registry, entry) monkeypatch.setattr(mock_rpc_device, "connected", False) monkeypatch.setattr(mock_rpc_device, "initialized", False) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + with patch.object( + mock_rpc_device, + "initialize", + new_callable=AsyncMock, + side_effect=DeviceConnectionError, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) monkeypatch.setattr(mock_rpc_device, "initialized", True) mock_rpc_device.mock_online() diff --git a/tests/components/shelly/test_device_trigger.py b/tests/components/shelly/test_device_trigger.py index b23f56ef4a9796..b88418d71f1a79 100644 --- a/tests/components/shelly/test_device_trigger.py +++ b/tests/components/shelly/test_device_trigger.py @@ -402,6 +402,8 @@ async def test_rpc_no_runtime_data( ) -> None: """Test the device trigger for the RPC device when there is no runtime_data in the entry.""" entry = await init_integration(hass, 2) + # Cache initial runtime_data + runtime_data = entry.runtime_data monkeypatch.delattr(entry, "runtime_data") device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] @@ -437,6 +439,9 @@ async def test_rpc_no_runtime_data( assert len(service_calls) == 1 assert service_calls[0].data["some"] == "test_trigger_single_push" + # Restore runtime_data to avoid issues on cleanup + entry.runtime_data = runtime_data + async def test_block_no_runtime_data( hass: HomeAssistant, @@ -447,6 +452,8 @@ async def test_block_no_runtime_data( ) -> None: """Test the device trigger for the block device when there is no runtime_data in the entry.""" entry = await init_integration(hass, 1) + # Cache initial runtime_data + runtime_data = entry.runtime_data monkeypatch.delattr(entry, "runtime_data") device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] @@ -481,3 +488,6 @@ async def test_block_no_runtime_data( assert len(service_calls) == 1 assert service_calls[0].data["some"] == "test_trigger_single" + + # Restore runtime_data to avoid issues on cleanup + entry.runtime_data = runtime_data diff --git a/tests/components/shelly/test_media_player.py b/tests/components/shelly/test_media_player.py new file mode 100644 index 00000000000000..d1f1b6ce74e35c --- /dev/null +++ b/tests/components/shelly/test_media_player.py @@ -0,0 +1,633 @@ +"""Tests for Shelly media player platform.""" + +from copy import deepcopy +from unittest.mock import Mock + +from aioshelly.const import MODEL_WALL_DISPLAY +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError +import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.media_player import ( + ATTR_MEDIA_ALBUM_NAME, + ATTR_MEDIA_ARTIST, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_DURATION, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_TITLE, + ATTR_MEDIA_VOLUME_LEVEL, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_STOP, + SERVICE_PLAY_MEDIA, + SERVICE_VOLUME_SET, +) +from homeassistant.components.shelly.media_player import ( + CONTENT_TYPE_AUDIO, + CONTENT_TYPE_RADIO, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_BUFFERING, + STATE_IDLE, + STATE_PLAYING, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import init_integration, patch_platforms + +from tests.typing import ClientSessionGenerator, WebSocketGenerator + +ENTITY_ID = f"{MEDIA_PLAYER_DOMAIN}.test_name" + +AUDIO_FILES = [ + { + "album": "Album Placeholder", + "artist": "Artist Placeholder", + "duration": 106000, + "filename": "track_alpha.mp3", + "id": 16, + "index": 0, + "preview": "https://example.com/media/thumb?id=16&_t=track_alpha.mp3", + "size": 3390000, + "title": "Track Alpha", + "track": 0, + "type": "AUDIO", + "valid": True, + "year": 0, + }, + { + "album": "Album Placeholder", + "artist": "Artist Placeholder", + "duration": 138000, + "filename": "track_beta.mp3", + "id": 15, + "index": 0, + "preview": "https://example.com/media/thumb?id=15&_t=track_beta.mp3", + "size": 4425000, + "title": "Track Beta", + "track": 0, + "type": "AUDIO", + "valid": True, + "year": 0, + }, + { + "filename": "ringtone_gamma.mp3", + "id": 17, + "index": 0, + "preview": "https://example.com/media/thumb?id=17&_t=ringtone_gamma.mp3", + "size": 552000, + "title": "Ringtone Gamma", + "type": "RINGTONE", + "valid": True, + }, +] + +RADIO_STATIONS = [ + { + "id": 0, + "name": "Station Alpha", + "country_code": "XX", + "icon": "https://example.com/icons/alpha.png", + }, + { + "id": 1, + "name": "Station Beta", + "country_code": "XX", + "icon": "https://example.com/icons/beta.png", + }, + { + "id": 2, + "name": "Station Gamma", + "country_code": "XX", + "icon": "https://example.com/icons/gamma.png", + }, + { + "id": 3, + "name": "Station Delta", + "country_code": "XX", + "icon": "https://example.com/icons/delta.png", + }, +] +STATUS_RADIO_STATION = { + "playback": { + "enable": True, + "buffering": False, + "volume": 5, + "media_meta": { + "thumb": "https://www.radio_station.pl/icon.png", + "title": "Radio Station", + }, + "media_type": "RADIO", + }, +} +STATUS_AUDIO_FILE = { + "playback": { + "buffering": False, + "enable": True, + "volume": 2, + "media_meta": { + "album": "Album Name", + "artist": "Artist", + "duration": 132415, + "position": 64644, + "thumb": "data:image/webp;base64,UklGRkAAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAAAAFZQOCAYAAAAMAEAnQEqAQABAAFAJiWkAANwAP79NmgA", + "title": "Title", + }, + "media_type": "AUDIO", + } +} + + +@pytest.fixture(autouse=True) +def fixture_platforms(): + """Limit platforms under test.""" + with patch_platforms([Platform.MEDIA_PLAYER]): + yield + + +async def test_rpc_media_player( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test a Shelly RPC media player.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_RADIO_STATION + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + assert (state := hass.states.get(ENTITY_ID)) + assert state == snapshot( + name=f"{ENTITY_ID}-state", exclude=props("entity_picture_local") + ) + + assert (entry := entity_registry.async_get(ENTITY_ID)) + assert entry == snapshot(name=f"{ENTITY_ID}-entry") + + monkeypatch.setitem(mock_rpc_device.status["media"]["playback"], "enable", False) + monkeypatch.setitem(mock_rpc_device.status["media"]["playback"], "buffering", True) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_BUFFERING + + +async def test_rpc_media_player_audio_file( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test a Shelly RPC media player.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_AUDIO_FILE + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_PLAYING + assert state.attributes[ATTR_MEDIA_TITLE] == "Title" + assert state.attributes[ATTR_MEDIA_ARTIST] == "Artist" + assert state.attributes[ATTR_MEDIA_ALBUM_NAME] == "Album Name" + assert state.attributes[ATTR_MEDIA_DURATION] == 132 + assert state.attributes[ATTR_MEDIA_POSITION] == 64 + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.2 + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["media"]["playback"], "enable", False) + mock_rpc_device.mock_update() + + mock_rpc_device.media_play_or_pause.assert_called_once() + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_IDLE + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["media"]["playback"], "enable", True) + mock_rpc_device.mock_update() + + assert len(mock_rpc_device.media_play_or_pause.mock_calls) == 2 + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_PLAYING + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_STOP, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["media"]["playback"], "enable", False) + mock_rpc_device.mock_update() + + mock_rpc_device.media_stop.assert_called_once() + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_IDLE + + +async def test_rpc_media_player_actions( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test a Shelly RPC media player.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_AUDIO_FILE + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mock_rpc_device.media_next.assert_called_once() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PREVIOUS_TRACK, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_rpc_device.mock_update() + + mock_rpc_device.media_previous.assert_called_once() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.5}, + blocking=True, + ) + + mock_rpc_device.media_set_volume.assert_called_once_with(5) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: CONTENT_TYPE_AUDIO, + ATTR_MEDIA_CONTENT_ID: "12", + }, + blocking=True, + ) + + mock_rpc_device.media_play_media.assert_called_once_with(12) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: CONTENT_TYPE_RADIO, + ATTR_MEDIA_CONTENT_ID: "2", + }, + blocking=True, + ) + + mock_rpc_device.media_play_radio_station.assert_called_once_with(2) + + +async def test_rpc_media_player_play_media_errors( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test a Shelly RPC errors in play media method.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_AUDIO_FILE + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + with pytest.raises( + HomeAssistantError, match="Unsupported media ID for Shelly device: invalid" + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: CONTENT_TYPE_RADIO, + ATTR_MEDIA_CONTENT_ID: "invalid", + }, + blocking=True, + ) + + with pytest.raises( + HomeAssistantError, match="Unsupported media type for Shelly device: invalid" + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: "invalid", + ATTR_MEDIA_CONTENT_ID: "1", + }, + blocking=True, + ) + + +async def test_get_image_http( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test get image via http command.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_AUDIO_FILE + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + state = hass.states.get(ENTITY_ID) + assert "entity_picture_local" not in state.attributes + + client = await hass_client_no_auth() + + resp = await client.get(state.attributes["entity_picture"]) + content = await resp.read() + + assert isinstance(content, bytes) + + +async def test_get_image_http_base64_decode_error( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test get image via http command base64 decode error.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_AUDIO_FILE + status["media"]["playback"]["media_meta"]["thumb"] = "data:image/webp;base64,0" + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + state = hass.states.get(ENTITY_ID) + assert "entity_picture_local" not in state.attributes + + client = await hass_client_no_auth() + + resp = await client.get(state.attributes["entity_picture"]) + content = await resp.read() + + assert isinstance(content, bytes) + + +async def test_rpc_media_player_browse_media_root( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test Shelly media player browse media root.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_AUDIO_FILE + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": ENTITY_ID, + } + ) + + msg = await websocket_client.receive_json() + + assert msg["success"] + assert msg["result"]["title"] == "Shelly" + assert msg["result"]["media_class"] == "directory" + assert msg["result"]["media_content_id"] == "" + assert [child["title"] for child in msg["result"]["children"]] == [ + "Radio stations", + "Audio files", + ] + assert [child["media_content_type"] for child in msg["result"]["children"]] == [ + CONTENT_TYPE_RADIO, + CONTENT_TYPE_AUDIO, + ] + + +async def test_rpc_media_player_browse_media_radio_stations( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test Shelly media player browse media radio stations.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_RADIO_STATION + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.media_list_radio_stations.return_value = RADIO_STATIONS + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": ENTITY_ID, + "media_content_type": CONTENT_TYPE_RADIO, + "media_content_id": CONTENT_TYPE_RADIO, + } + ) + + msg = await websocket_client.receive_json() + + assert msg["success"] + assert msg["result"]["title"] == "Radio stations" + assert msg["result"]["media_class"] == "directory" + assert msg["result"]["media_content_type"] == CONTENT_TYPE_RADIO + assert [child["title"] for child in msg["result"]["children"]] == [ + station["name"] for station in RADIO_STATIONS + ] + assert [child["media_content_id"] for child in msg["result"]["children"]] == [ + str(station["id"]) for station in RADIO_STATIONS + ] + assert [child["thumbnail"] for child in msg["result"]["children"]] == [ + station["icon"] for station in RADIO_STATIONS + ] + + +async def test_rpc_media_player_browse_media_audio_files( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test Shelly media player browse media audio files.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_AUDIO_FILE + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.media_list_media.return_value = AUDIO_FILES + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": ENTITY_ID, + "media_content_type": CONTENT_TYPE_AUDIO, + "media_content_id": CONTENT_TYPE_AUDIO, + } + ) + + msg = await websocket_client.receive_json() + + assert msg["success"] + assert msg["result"]["title"] == "Audio files" + assert msg["result"]["media_class"] == "directory" + assert msg["result"]["media_content_type"] == CONTENT_TYPE_AUDIO + assert [child["title"] for child in msg["result"]["children"]] == [ + item["title"] for item in AUDIO_FILES if item["type"] == "AUDIO" + ] + assert [child["media_content_id"] for child in msg["result"]["children"]] == [ + str(item["id"]) for item in AUDIO_FILES if item["type"] == "AUDIO" + ] + assert [child["thumbnail"] for child in msg["result"]["children"]] == [ + item["preview"] for item in AUDIO_FILES if item["type"] == "AUDIO" + ] + + +async def test_rpc_media_player_browse_media_unsupported_media_type( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test Shelly media player browse media returns unsupported media content type.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_AUDIO_FILE + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.media_list_media.return_value = AUDIO_FILES + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": ENTITY_ID, + "media_content_type": "invalid", + "media_content_id": CONTENT_TYPE_AUDIO, + } + ) + + msg = await websocket_client.receive_json() + + assert msg["error"] + assert msg["error"]["code"] == "home_assistant_error" + assert msg["error"]["message"] == ( + "Unsupported media content type for Shelly device: invalid" + ) + + +@pytest.mark.parametrize( + ("side_effect", "expected_message"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for media_player.test_name of Test name", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling action for media_player.test_name of Test name", + ), + ( + InvalidAuthError, + "Authentication failed for Test name, please update your credentials", + ), + ], +) +async def test_rpc_media_player_browse_media_errors( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + hass_ws_client: WebSocketGenerator, + side_effect: Exception, + expected_message: str, +) -> None: + """Test Shelly media player browse media returns errors.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_AUDIO_FILE + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.media_list_media.side_effect = side_effect + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": ENTITY_ID, + "media_content_type": CONTENT_TYPE_AUDIO, + "media_content_id": CONTENT_TYPE_AUDIO, + } + ) + + msg = await websocket_client.receive_json() + + assert msg["error"] + assert msg["error"]["code"] == "home_assistant_error" + assert msg["error"]["message"] == expected_message + + +async def test_rpc_media_player_no_media_meta( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test a Shelly RPC media player with no media metadata.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_AUDIO_FILE + status["media"]["playback"].pop("media_meta") + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_PLAYING + assert state.attributes.get(ATTR_MEDIA_TITLE) is None + assert state.attributes.get(ATTR_MEDIA_ARTIST) is None + assert state.attributes.get(ATTR_MEDIA_ALBUM_NAME) is None + assert state.attributes.get(ATTR_MEDIA_DURATION) is None + assert state.attributes.get(ATTR_MEDIA_POSITION) is None diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index 8007ecc361534d..f418b7a34a9f61 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -1,6 +1,6 @@ """Tests for Shelly update platform.""" -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from freezegun.api import FrozenDateTimeFactory @@ -420,7 +420,13 @@ async def test_rpc_sleeping_update( }, ) entity_id = f"{UPDATE_DOMAIN}.test_name_firmware" - await init_integration(hass, 2, sleep_period=1000) + with patch.object( + mock_rpc_device, + "initialize", + new_callable=AsyncMock, + side_effect=DeviceConnectionError, + ): + await init_integration(hass, 2, sleep_period=1000) # Entity should be created when device is online assert hass.states.get(entity_id) is None diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index 93c086d6da54a2..b9d37ad1156f01 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -22,7 +22,6 @@ GEN1_RELEASE_URL, GEN2_BETA_RELEASE_URL, GEN2_RELEASE_URL, - UPTIME_DEVIATION, WALL_DISPLAY_RELEASE_URL, ) from homeassistant.components.shelly.utils import ( @@ -30,7 +29,6 @@ get_block_device_sleep_period, get_block_input_triggers, get_block_number_of_channels, - get_device_uptime, get_host, get_release_url, get_rpc_channel_name, @@ -39,7 +37,6 @@ is_block_momentary_input, mac_address_from_name, ) -from homeassistant.util import dt as dt_util DEVICE_BLOCK_ID = 4 @@ -149,19 +146,6 @@ async def test_get_block_device_sleep_period( assert get_block_device_sleep_period(settings) == sleep_period -@pytest.mark.freeze_time("2019-01-10 18:43:00+00:00") -async def test_get_device_uptime() -> None: - """Test block test get device uptime.""" - assert get_device_uptime( - 55, dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:42:00+00:00")) - ) == dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:42:00+00:00")) - - assert get_device_uptime( - 55 - UPTIME_DEVIATION, - dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:42:00+00:00")), - ) == dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:43:05+00:00")) - - async def test_get_block_input_triggers( mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/components/shopping_list/test_init.py b/tests/components/shopping_list/test_init.py index 250f110a8ddb0d..413c6c2c9daf56 100644 --- a/tests/components/shopping_list/test_init.py +++ b/tests/components/shopping_list/test_init.py @@ -25,8 +25,9 @@ TYPE_RESULT, ) from homeassistant.const import ATTR_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import intent, issue_registry as ir +from homeassistant.setup import async_setup_component from tests.common import async_capture_events from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -909,3 +910,15 @@ async def test_sort_list_service( assert _get_shopping_data(hass).items[1][ATTR_NAME] == "ddd" assert _get_shopping_data(hass).items[2][ATTR_NAME] == "aaa" assert len(events) == 2 + + +async def test_config_import_deprecation( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test yaml config import is deprecated.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + await hass.async_block_till_done(True) + + assert (HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}") in issue_registry.issues diff --git a/tests/components/siren/test_condition.py b/tests/components/siren/test_condition.py index 8da90f57b97d73..3399208cf7a9f1 100644 --- a/tests/components/siren/test_condition.py +++ b/tests/components/siren/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -39,6 +40,31 @@ async def test_siren_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("siren.is_off", {}, True, True), + ("siren.is_on", {}, True, True), + ], +) +async def test_siren_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that siren conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/solaredge/test_coordinator.py b/tests/components/solaredge/test_coordinator.py index 5e21a39febcd23..982ccad2c92b08 100644 --- a/tests/components/solaredge/test_coordinator.py +++ b/tests/components/solaredge/test_coordinator.py @@ -2,7 +2,7 @@ import asyncio from datetime import datetime, timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -41,7 +41,7 @@ def enable_all_entities(entity_registry_enabled_by_default: None) -> None: @patch("homeassistant.components.solaredge.SolarEdge") async def test_solaredgeoverviewdataservice_energy_values_validity( - mock_solaredge, + mock_solaredge: MagicMock, recorder_mock: Recorder, hass: HomeAssistant, freezer: FrozenDateTimeFactory, diff --git a/tests/components/solaredge/test_sensor.py b/tests/components/solaredge/test_sensor.py new file mode 100644 index 00000000000000..0dd514d0e80ba9 --- /dev/null +++ b/tests/components/solaredge/test_sensor.py @@ -0,0 +1,503 @@ +"""Tests for the SolarEdge sensors.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.recorder import Recorder +from homeassistant.components.solaredge.const import ( + CONF_SITE_ID, + DEFAULT_NAME, + DOMAIN, + INVENTORY_UPDATE_DELAY, +) +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import API_KEY, SITE_ID + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: + """Make sure all entities are enabled.""" + + +@pytest.fixture +def mock_solaredge_api() -> AsyncMock: + """Return a mocked SolarEdge API with common defaults.""" + api = AsyncMock() + api.get_details = AsyncMock(return_value={"details": {"status": "active"}}) + api.get_overview = AsyncMock( + return_value={ + "overview": { + "lifeTimeData": {"energy": 100000}, + "lastYearData": {"energy": 50000}, + "lastMonthData": {"energy": 10000}, + "lastDayData": {"energy": 0.0}, + "currentPower": {"power": 0.0}, + } + } + ) + api.get_inventory = AsyncMock( + return_value={"Inventory": {"batteries": [{"SN": "BAT001"}]}} + ) + api.get_current_power_flow = AsyncMock( + return_value={ + "siteCurrentPowerFlow": { + "unit": "W", + "connections": [], + } + } + ) + api.get_energy_details = AsyncMock( + return_value={"energyDetails": {"unit": "Wh", "meters": []}} + ) + api.get_storage_data = AsyncMock(return_value=STORAGE_DATA_SINGLE_BATTERY) + return api + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a default mocked config entry for storage tests.""" + return MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_NAME, + data={CONF_NAME: DEFAULT_NAME, CONF_SITE_ID: SITE_ID, CONF_API_KEY: API_KEY}, + ) + + +STORAGE_DATA_SINGLE_BATTERY = { + "storageData": { + "batteries": [ + { + "serialNumber": "BAT001", + "telemetries": [ + { + "timeStamp": "2025-01-01 00:00:00", + "lifeTimeEnergyCharged": 1000.0, + "lifeTimeEnergyDischarged": 500.0, + "batteryPercentageState": 50.0, + "power": 100.0, + }, + { + "timeStamp": "2025-01-01 12:00:00", + "lifeTimeEnergyCharged": 1500.0, + "lifeTimeEnergyDischarged": 800.0, + "batteryPercentageState": 75.0, + "power": 200.0, + }, + ], + } + ] + } +} + +STORAGE_DATA_MULTI_BATTERY = { + "storageData": { + "batteries": [ + { + "serialNumber": "BAT001", + "telemetries": [ + { + "timeStamp": "2025-01-01 00:00:00", + "lifeTimeEnergyCharged": 1000.0, + "lifeTimeEnergyDischarged": 500.0, + "batteryPercentageState": 50.0, + "power": 100.0, + }, + { + "timeStamp": "2025-01-01 12:00:00", + "lifeTimeEnergyCharged": 1500.0, + "lifeTimeEnergyDischarged": 800.0, + "batteryPercentageState": 75.0, + "power": 200.0, + }, + ], + }, + { + "serialNumber": "BAT002", + "telemetries": [ + { + "timeStamp": "2025-01-01 00:00:00", + "lifeTimeEnergyCharged": 2000.0, + "lifeTimeEnergyDischarged": 1000.0, + "batteryPercentageState": 40.0, + "power": 150.0, + }, + { + "timeStamp": "2025-01-01 12:00:00", + "lifeTimeEnergyCharged": 2700.0, + "lifeTimeEnergyDischarged": 1400.0, + "batteryPercentageState": 80.0, + "power": 250.0, + }, + ], + }, + ] + } +} + + +@patch("homeassistant.components.solaredge.SolarEdge") +async def test_storage_data_service( + mock_solaredge: MagicMock, + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_solaredge_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test storage data service fetches battery charge/discharge energy.""" + mock_solaredge.return_value = mock_solaredge_api + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Aggregate sensors + charge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" + ) + discharge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy" + ) + assert charge_entry is not None + assert discharge_entry is not None + + state = hass.states.get(charge_entry) + assert state is not None + assert float(state.state) == 500.0 # 1500 - 1000 + + state = hass.states.get(discharge_entry) + assert state is not None + assert float(state.state) == 300.0 # 800 - 500 + + # Per-battery entities + bat_charge = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_BAT001_battery_charge_energy" + ) + bat_discharge = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_BAT001_battery_discharge_energy" + ) + bat_soc = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_BAT001_battery_state_of_charge" + ) + bat_power = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_BAT001_battery_power" + ) + assert bat_charge is not None + assert bat_discharge is not None + assert bat_soc is not None + assert bat_power is not None + + state = hass.states.get(bat_charge) + assert state is not None + assert float(state.state) == 500.0 + + state = hass.states.get(bat_discharge) + assert state is not None + assert float(state.state) == 300.0 + + state = hass.states.get(bat_soc) + assert state is not None + assert float(state.state) == 75.0 + + state = hass.states.get(bat_power) + assert state is not None + assert float(state.state) == 200.0 + + +@patch("homeassistant.components.solaredge.SolarEdge") +async def test_storage_data_service_multi_battery( + mock_solaredge: MagicMock, + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_solaredge_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test storage data service aggregates data across multiple batteries.""" + mock_solaredge_api.get_inventory = AsyncMock( + return_value={"Inventory": {"batteries": [{"SN": "BAT001"}, {"SN": "BAT002"}]}} + ) + mock_solaredge_api.get_storage_data = AsyncMock( + return_value=STORAGE_DATA_MULTI_BATTERY + ) + mock_solaredge.return_value = mock_solaredge_api + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + charge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" + ) + discharge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy" + ) + assert charge_entry is not None + assert discharge_entry is not None + + # BAT001: charge=500 (1500-1000), discharge=300 (800-500) + # BAT002: charge=700 (2700-2000), discharge=400 (1400-1000) + state = hass.states.get(charge_entry) + assert state is not None + assert float(state.state) == 1200.0 # 500 + 700 + + state = hass.states.get(discharge_entry) + assert state is not None + assert float(state.state) == 700.0 # 300 + 400 + + # Per-battery entities for BAT001 + bat1_soc = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_BAT001_battery_state_of_charge" + ) + assert bat1_soc is not None + state = hass.states.get(bat1_soc) + assert state is not None + assert float(state.state) == 75.0 + + # Per-battery entities for BAT002 + bat2_charge = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_BAT002_battery_charge_energy" + ) + bat2_soc = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_BAT002_battery_state_of_charge" + ) + assert bat2_charge is not None + assert bat2_soc is not None + + state = hass.states.get(bat2_charge) + assert state is not None + assert float(state.state) == 700.0 + + state = hass.states.get(bat2_soc) + assert state is not None + assert float(state.state) == 80.0 + + +@patch("homeassistant.components.solaredge.SolarEdge") +async def test_storage_data_service_no_batteries( + mock_solaredge: MagicMock, + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_solaredge_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test storage service is not created when no batteries in inventory.""" + mock_solaredge_api.get_inventory = AsyncMock( + return_value={"Inventory": {"batteries": []}} + ) + mock_solaredge.return_value = mock_solaredge_api + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Sensors should not exist when inventory reports no batteries + charge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" + ) + discharge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy" + ) + assert charge_entry is None + assert discharge_entry is None + + +@patch("homeassistant.components.solaredge.SolarEdge") +async def test_storage_data_service_api_error( + mock_solaredge: MagicMock, + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_solaredge_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test storage data service handles API errors gracefully.""" + mock_solaredge_api.get_storage_data = AsyncMock(side_effect=Exception("API error")) + mock_solaredge.return_value = mock_solaredge_api + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + charge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" + ) + discharge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy" + ) + assert charge_entry is not None + assert discharge_entry is not None + + # Sensors should be unavailable when the API returns an error + state = hass.states.get(charge_entry) + assert state is not None + assert state.state == "unavailable" + + state = hass.states.get(discharge_entry) + assert state is not None + assert state.state == "unavailable" + + +@patch("homeassistant.components.solaredge.SolarEdge") +async def test_storage_data_missing_keys_in_response( + mock_solaredge: MagicMock, + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_solaredge_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test storage service raises UpdateFailed when response is missing required keys.""" + # API returns a response but without the storageData key + mock_solaredge_api.get_storage_data = AsyncMock(return_value={"unexpected": {}}) + mock_solaredge.return_value = mock_solaredge_api + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + charge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" + ) + discharge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy" + ) + assert charge_entry is not None + assert discharge_entry is not None + + # Sensors should be unavailable due to UpdateFailed from missing key + state = hass.states.get(charge_entry) + assert state is not None + assert state.state == "unavailable" + + state = hass.states.get(discharge_entry) + assert state is not None + assert state.state == "unavailable" + + +@patch("homeassistant.components.solaredge.SolarEdge") +async def test_storage_data_missing_batteries_key( + mock_solaredge: MagicMock, + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_solaredge_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test storage service raises UpdateFailed when batteries key is missing.""" + # API returns storageData but without batteries key + mock_solaredge_api.get_storage_data = AsyncMock( + return_value={"storageData": {"otherField": "value"}} + ) + mock_solaredge.return_value = mock_solaredge_api + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + charge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" + ) + assert charge_entry is not None + + state = hass.states.get(charge_entry) + assert state is not None + assert state.state == "unavailable" + + +@patch("homeassistant.components.solaredge.SolarEdge") +async def test_storage_service_deferred_after_inventory_failure( + mock_solaredge: MagicMock, + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_solaredge_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test storage service is created after inventory recovers from failure.""" + # Initial inventory fetch fails + mock_solaredge_api.get_inventory = AsyncMock(side_effect=KeyError("Inventory")) + mock_solaredge.return_value = mock_solaredge_api + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Storage sensors should not exist yet + charge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" + ) + assert charge_entry is None + + # Now inventory recovers and reports batteries + mock_solaredge_api.get_inventory = AsyncMock( + return_value={"Inventory": {"batteries": [{"SN": "BAT001"}]}} + ) + mock_solaredge_api.get_storage_data = AsyncMock( + return_value=STORAGE_DATA_SINGLE_BATTERY + ) + + # Trigger inventory coordinator refresh + freezer.tick(INVENTORY_UPDATE_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Storage sensors should now exist + charge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" + ) + discharge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy" + ) + assert charge_entry is not None + assert discharge_entry is not None + + +@patch("homeassistant.components.solaredge.SolarEdge") +async def test_storage_service_not_created_when_inventory_has_no_batteries( + mock_solaredge: MagicMock, + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_solaredge_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test storage service is not retried when inventory succeeds with no batteries.""" + # Initial inventory fails + mock_solaredge_api.get_inventory = AsyncMock(side_effect=KeyError("Inventory")) + mock_solaredge.return_value = mock_solaredge_api + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Inventory recovers but reports zero batteries + mock_solaredge_api.get_inventory = AsyncMock( + return_value={"Inventory": {"batteries": []}} + ) + + freezer.tick(INVENTORY_UPDATE_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Storage sensors should still not exist + charge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" + ) + assert charge_entry is None diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index eb554f2cf19659..8006b94b50e3d5 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -334,10 +334,12 @@ def av_open_side_effect(*args, **kwargs): # Request stream. Enable retries which are disabled by default in tests. should_retry.return_value = True await stream.start() + # Capture the thread reference before yielding to the event loop, since + # worker_finished() may clear stream._thread once the worker exits. + worker_thread = stream._thread await open_future1 await open_future2 - await hass.async_add_executor_job(stream._thread.join) - stream._thread = None + await hass.async_add_executor_job(worker_thread.join) assert av_open.call_count == 2 await hass.async_block_till_done() diff --git a/tests/components/switch/test_condition.py b/tests/components/switch/test_condition.py index 16154bc027e356..9ac54a987c4b5a 100644 --- a/tests/components/switch/test_condition.py +++ b/tests/components/switch/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -46,6 +47,31 @@ async def test_switch_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("switch.is_off", {}, True, True), + ("switch.is_on", {}, True, True), + ], +) +async def test_switch_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that switch conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), @@ -247,15 +273,15 @@ async def test_switch_condition_evaluates_both_domains( ) # Both off - condition should be false - assert condition(hass) is False + assert condition.async_check() is False # switch entity turns on - condition should be true hass.states.async_set(entity_id_switch, STATE_ON) await hass.async_block_till_done() - assert condition(hass) is True + assert condition.async_check() is True # Reset switch, turn on input_boolean - condition should still be true hass.states.async_set(entity_id_switch, STATE_OFF) hass.states.async_set(entity_id_input_boolean, STATE_ON) await hass.async_block_till_done() - assert condition(hass) is True + assert condition.async_check() is True diff --git a/tests/components/switcher_kis/test_config_flow.py b/tests/components/switcher_kis/test_config_flow.py index 3a5baec71ef25b..054a050e1ad575 100644 --- a/tests/components/switcher_kis/test_config_flow.py +++ b/tests/components/switcher_kis/test_config_flow.py @@ -234,7 +234,9 @@ async def test_reauth_successful( assert result["reason"] == "reauth_successful" -async def test_reauth_invalid_auth(hass: HomeAssistant) -> None: +async def test_reauth_invalid_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test reauthentication flow with invalid credentials.""" entry = MockConfigEntry( domain=DOMAIN, @@ -269,3 +271,6 @@ async def test_reauth_invalid_auth(hass: HomeAssistant) -> None: assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "reauth_successful" + + await hass.async_block_till_done() + mock_setup_entry.assert_awaited_once() diff --git a/tests/components/synology_dsm/test_sensor.py b/tests/components/synology_dsm/test_sensor.py index f636dbb79a83c9..0d0c77dc9c4264 100644 --- a/tests/components/synology_dsm/test_sensor.py +++ b/tests/components/synology_dsm/test_sensor.py @@ -15,7 +15,7 @@ CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .common import ( mock_dsm_external_usb_devices_usb0, @@ -356,3 +356,17 @@ async def test_no_external_usb( """Test Synology DSM without USB.""" sensor = hass.states.get("sensor.nas_meontheinternet_com_usb_disk_1_device_size") assert sensor is None + + +async def test_hub_device_info_mac_connections( + hass: HomeAssistant, + setup_dsm_with_usb: MagicMock, +) -> None: + """Test that the hub DeviceInfo includes MAC address connections.""" + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_device(identifiers={(DOMAIN, SERIAL)}) + assert device is not None + assert device.connections == { + ("mac", "00:11:32:xx:xx:59"), + ("mac", "00:11:32:xx:xx:5a"), + } diff --git a/tests/components/temperature/test_condition.py b/tests/components/temperature/test_condition.py index 96199ea2c881e7..fc78895e75892f 100644 --- a/tests/components/temperature/test_condition.py +++ b/tests/components/temperature/test_condition.py @@ -14,6 +14,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, assert_numerical_condition_unit_conversion, parametrize_numerical_attribute_condition_above_below_all, parametrize_numerical_attribute_condition_above_below_any, @@ -61,6 +62,38 @@ async def test_temperature_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +_CELSIUS_THRESHOLD = { + "threshold": { + "type": "above", + "value": {"number": 20, "unit_of_measurement": "\u00b0C"}, + } +} + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("temperature.is_value", _CELSIUS_THRESHOLD, True, False), + ], +) +async def test_temperature_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that temperature conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 8338421c72abf4..6284af157e2619 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -708,6 +708,7 @@ async def test_config_flow_device( "min": 0, "max": 100, "step": 0.1, + "device_class": "distance", "unit_of_measurement": "cm", "set_value": { "action": "input_number.set_value", @@ -719,7 +720,8 @@ async def test_config_flow_device( "min": 0, "max": 100, "step": 0.1, - "unit_of_measurement": "cm", + "device_class": "current", + "unit_of_measurement": "mA", "set_value": { "action": "input_number.set_value", "target": {"entity_id": "input_number.test"}, @@ -727,7 +729,7 @@ async def test_config_flow_device( }, }, "state", - None, + "distance", ), ( "alarm_control_panel", @@ -901,27 +903,76 @@ async def test_options( ) +@pytest.mark.parametrize( + ( + "template_type", + "old_state_template", + "new_state_template", + "input_states", + "extra_options", + "suggested_device_class", + ), + [ + ( + "binary_sensor", + { + "state": "{{ states('binary_sensor.one') == 'on' or states('binary_sensor.two') == 'on' }}" + }, + { + "state": "{{ states('binary_sensor.one') == 'on' and states('binary_sensor.two') == 'on' }}" + }, + {"one": "on", "two": "off"}, + {"device_class": "motion"}, + "motion", + ), + ( + "number", + {"state": "{{ states('number.one') }}"}, + {"state": "{{ states('number.two') }}"}, + {"one": "30.0", "two": "20.0"}, + { + "min": 0, + "max": 100, + "step": 0.1, + "device_class": "distance", + "unit_of_measurement": "cm", + "set_value": { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"value": "{{ value }}"}, + }, + }, + "distance", + ), + ], +) @pytest.mark.freeze_time("2024-07-09 00:00:00+00:00") -async def test_options_binary_sensor_remove_device_class(hass: HomeAssistant) -> None: - """Test removing the binary sensor device class in options.""" - hass.states.async_set("binary_sensor.one", "on", {}) - hass.states.async_set("binary_sensor.two", "off", {}) +async def test_options_remove_device_class( + hass: HomeAssistant, + template_type: str, + old_state_template: dict[str, Any], + new_state_template: dict[str, Any], + input_states: dict[str, Any], + extra_options: dict[str, Any], + suggested_device_class: str | None, +) -> None: + """Test removing the device class in options.""" - old_state_template = { - "state": "{{ states('binary_sensor.one') == 'on' or states('binary_sensor.two') == 'on' }}" - } - new_state_template = { - "state": "{{ states('binary_sensor.one') == 'on' and states('binary_sensor.two') == 'on' }}" - } + input_entities = ["one", "two"] + + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", input_states[input_entity], {} + ) config_entry = MockConfigEntry( data={}, domain=DOMAIN, options={ "name": "My template", - "template_type": "binary_sensor", + "template_type": template_type, **old_state_template, - "device_class": "motion", + **extra_options, }, title="My template", ) @@ -932,28 +983,32 @@ async def test_options_binary_sensor_remove_device_class(hass: HomeAssistant) -> result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "binary_sensor" + assert result["step_id"] == template_type assert ( get_schema_suggested_value(result["data_schema"].schema, "device_class") - == "motion" + == suggested_device_class ) + extra_options.pop("device_class") result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ **new_state_template, + **extra_options, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { "name": "My template", - "template_type": "binary_sensor", + "template_type": template_type, **new_state_template, + **extra_options, } assert config_entry.options == { "name": "My template", - "template_type": "binary_sensor", + "template_type": template_type, **new_state_template, + **extra_options, } assert "device_class" not in config_entry.options diff --git a/tests/components/template/test_helpers.py b/tests/components/template/test_helpers.py index e4303af31f8705..b7be50eefdbaa5 100644 --- a/tests/components/template/test_helpers.py +++ b/tests/components/template/test_helpers.py @@ -59,6 +59,7 @@ from homeassistant.components.template.vacuum import ( LEGACY_FIELDS as VACUUM_LEGACY_FIELDS, SCRIPT_FIELDS as VACUUM_SCRIPT_FIELDS, + SERVICE_CLEAN_AREA as VACUUM_SERVICE_CLEAN_AREA, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady @@ -578,8 +579,14 @@ async def _setup_and_test_yaml_device_action( ), ( "vacuum", - VACUUM_SCRIPT_FIELDS, - {"fan_speeds": ["low", "medium", "high"]}, + [ + service + for service in VACUUM_SCRIPT_FIELDS + if service != VACUUM_SERVICE_CLEAN_AREA + ], + { + "fan_speeds": ["low", "medium", "high"], + }, ( ("start", {}), ("pause", {}), @@ -773,7 +780,11 @@ async def test_yaml_device_actions_modern_config( ), ( "vacuum", - VACUUM_SCRIPT_FIELDS, + [ + service + for service in VACUUM_SCRIPT_FIELDS + if service != VACUUM_SERVICE_CLEAN_AREA + ], { "fan_speeds": ["low", "medium", "high"], "state": "{{ 'on' }}", diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index a0a1d4c9c36efb..58e6a1b8e8ae54 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -625,6 +625,7 @@ async def test_yaml_reload_when_labs_flag_changes( }, ) assert await async_setup_component(hass, labs.DOMAIN, {}) + await hass.async_block_till_done() assert hass.states.get("sensor.hello") is not None assert hass.states.get("sensor.bye") is None listeners = hass.bus.async_listeners() diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index 4d3ed4f7f50dba..4b44baaaba5fb5 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -564,3 +564,36 @@ async def test_nested_unique_id( await setup_and_test_nested_unique_id( hass, TEST_NUMBER, style, entity_registry, TEST_REQUIRED, "{{ 0 }}" ) + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("config", "expected_device_class"), + [ + ( + { + **TEST_REQUIRED, + "unit_of_measurement": "°C", + "device_class": "temperature", + }, + "temperature", + ), + ( + TEST_REQUIRED, + None, + ), + ], +) +@pytest.mark.usefixtures("setup_number") +async def test_setup_valid_device_class( + hass: HomeAssistant, expected_device_class: str | None +) -> None: + """Test setup with valid device_class.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "75") + assert ( + hass.states.get(TEST_NUMBER.entity_id).attributes.get("device_class") + == expected_device_class + ) diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 5036760ef4541c..8cbc0c1e1e027a 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -1,18 +1,30 @@ """The tests for the Template vacuum platform.""" +from dataclasses import asdict from typing import Any import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import template, vacuum +from homeassistant.components.template.vacuum import ( + CONF_SEGMENTS_TEMPLATE, + SERVICE_CLEAN_AREA, +) from homeassistant.components.vacuum import ( ATTR_BATTERY_LEVEL, ATTR_FAN_SPEED, + Segment, VacuumActivity, VacuumEntityFeature, ) -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + CONF_UNIQUE_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir @@ -60,6 +72,7 @@ ) START_ACTION = make_test_action("start") STOP_ACTION = make_test_action("stop") +CLEAN_AREA_ACTION = make_test_action("clean_area", {"segment_ids": "{{ segment_ids }}"}) TEMPLATE_VACUUM_ACTIONS = { **START_ACTION, @@ -1027,6 +1040,311 @@ async def test_not_optimistic( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + ("count", "vacuum_config"), + [ + ( + 1, + { + "unique_id": TEST_VACUUM.entity_id, + "start": [], + **CLEAN_AREA_ACTION, + "segments_template": "{{ [{'id': '1', 'name': 'Livingroom'}, {'id': '2', 'name': 'Kitchen'}] }}", + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_vacuum") +async def test_clean_area( + hass: HomeAssistant, + calls: list[ServiceCall], + entity_registry: er.EntityRegistry, +) -> None: + """Test clean area passes segment IDs to action.""" + entity_registry.async_update_entity_options( + TEST_VACUUM.entity_id, + vacuum.DOMAIN, + { + "area_mapping": {"area_1": ["1", "2"]}, + "last_seen_segments": [ + {"id": "1", "name": "Livingroom"}, + {"id": "2", "name": "Kitchen"}, + ], + }, + ) + + await common.async_clean_area(hass, ["area_1"], TEST_VACUUM.entity_id) + await hass.async_block_till_done() + assert_action(TEST_VACUUM, calls, 1, "clean_area", segment_ids=["1", "2"]) + + state = hass.states.get(TEST_VACUUM.entity_id) + assert state is not None + assert state.attributes["supported_features"] & VacuumEntityFeature.CLEAN_AREA + + +@pytest.mark.parametrize( + ("count", "vacuum_config"), + [ + ( + 1, + { + "start": [], + **CLEAN_AREA_ACTION, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("extra_config", "expected_segments"), + [ + ( + { + "unique_id": TEST_VACUUM.entity_id, + "segments_template": "{{ [" + "{'id': '1', 'name': 'Kitchen'}, " + "{'id': '2', 'name': 'Bedroom', 'group': 'Upstairs'}" + "] }}", + }, + [ + Segment(id="1", name="Kitchen"), + Segment(id="2", name="Bedroom", group="Upstairs"), + ], + ), + ], +) +@pytest.mark.usefixtures("setup_test_vacuum_with_extra_config") +async def test_get_segments( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + expected_segments: list[Segment], +) -> None: + """Test get_segments returns segments from template.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, VacuumActivity.DOCKED) + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "vacuum/get_segments", "entity_id": TEST_VACUUM.entity_id} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "segments": [asdict(segment) for segment in expected_segments] + } + + +@pytest.mark.parametrize( + ("count", "vacuum_config"), + [ + ( + 1, + { + "start": [], + **CLEAN_AREA_ACTION, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("extra_config", "err_msg"), + [ + ( + { + "unique_id": TEST_VACUUM.entity_id, + "segments_template": "{{ [ {'id': '1'} ] }}", + }, + "expected dictionary with keys id, name and optional group and string values", + ), + ( + { + "unique_id": TEST_VACUUM.entity_id, + "segments_template": "{{ [ {'name': 'kitchen'} ] }}", + }, + "expected dictionary with keys id, name and optional group and string values", + ), + ( + { + "unique_id": TEST_VACUUM.entity_id, + "segments_template": "{{ [ {} ] }}", + }, + "expected dictionary with keys id, name and optional group and string values", + ), + ( + { + "unique_id": TEST_VACUUM.entity_id, + "segments_template": "{{ [ {'id': '1', 'name': 'Kitchen', 'extra_key': 'value'} ] }}", + }, + "expected dictionary with keys id, name and optional group and string values", + ), + ( + {"unique_id": TEST_VACUUM.entity_id, "segments_template": "{{ [[]] }}"}, + "expected dictionary with keys id, name and optional group and string values", + ), + ( + { + "unique_id": TEST_VACUUM.entity_id, + "segments_template": "{{ [ {'id': '1', 'name': 'Kitchen'}, [] ] }}", + }, + "expected dictionary with keys id, name and optional group and string values", + ), + ], +) +@pytest.mark.usefixtures("setup_test_vacuum_with_extra_config") +async def test_invalid_segments_template( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog_setup_text, + caplog: pytest.LogCaptureFixture, + err_msg: str, +) -> None: + """Test that errors are logged if parsing segment template fails.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, "Works") + await hass.async_block_till_done() + + assert err_msg in caplog_setup_text or err_msg in caplog.text + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "vacuum/get_segments", "entity_id": TEST_VACUUM.entity_id} + ) + msg = await client.receive_json() + assert msg["result"] == {"segments": []} + + +@pytest.mark.parametrize( + ("count", "vacuum_config"), + [ + ( + 1, + { + "unique_id": TEST_VACUUM.entity_id, + "start": [], + **CLEAN_AREA_ACTION, + "segments_template": "{{ [ {'id': '1', 'name': 'Kitchen'}, {'id': '2', 'name': states('sensor.test_attribute')}] }}", + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_vacuum") +async def test_raise_segments_changed_issue( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test that issue is raised on segments change.""" + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "Bedroom") + await hass.async_block_till_done() + + entity_registry.async_update_entity_options( + TEST_VACUUM.entity_id, + vacuum.DOMAIN, + { + "last_seen_segments": [ + {"id": "1", "name": "Kitchen"}, + {"id": "2", "name": "Bedroom"}, + ], + }, + ) + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "Bathroom") + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) != 0 + + +@pytest.mark.parametrize( + ("vacuum_config", "err_msg"), + [ + ( + {**START_ACTION}, + f"Options `{CONF_SEGMENTS_TEMPLATE}` and `{SERVICE_CLEAN_AREA}` must both exist", + ), + ], +) +@pytest.mark.parametrize( + ("count", "extra_config"), + [ + ( + 0, + { + "segments_template": "{{ [{'id': '1', 'name': 'Kitchen'}] }}", + }, + ), + ( + 0, + {**CLEAN_AREA_ACTION}, + ), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_test_vacuum_with_extra_config") +async def test_segments_part_config( + hass: HomeAssistant, + caplog_setup_text, + caplog: pytest.LogCaptureFixture, + count: int, + err_msg: str, +) -> None: + """Test creating vacuum with segments, missing required options.""" + assert len(hass.states.async_all(vacuum.DOMAIN)) == count + assert err_msg in caplog_setup_text or err_msg in caplog.text + + +@pytest.mark.parametrize( + ("vacuum_config"), + [ + { + **START_ACTION, + **CLEAN_AREA_ACTION, + "segments_template": "{{ [{'id': '1', 'name': 'Kitchen'}] }}", + }, + ], +) +@pytest.mark.parametrize( + ("count", "extra_config", "err_msg"), + [ + ( + 0, + {}, + f'key "{CONF_SEGMENTS_TEMPLATE}" requires key "{CONF_UNIQUE_ID}" to exist', + ), + (1, {"unique_id": TEST_VACUUM.entity_id}, ""), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_test_vacuum_with_extra_config") +async def test_segments_unique_id( + hass: HomeAssistant, + caplog_setup_text, + caplog: pytest.LogCaptureFixture, + count: int, + err_msg: str, +) -> None: + """Test creating vacuum with segments, missing required options.""" + assert len(hass.states.async_all(vacuum.DOMAIN)) == count + assert err_msg in caplog_setup_text or err_msg in caplog.text + + async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion, diff --git a/tests/components/teslemetry/snapshots/test_init.ambr b/tests/components/teslemetry/snapshots/test_init.ambr index c3f8bd8ee05d59..722aacd989b88b 100644 --- a/tests/components/teslemetry/snapshots/test_init.ambr +++ b/tests/components/teslemetry/snapshots/test_init.ambr @@ -4,7 +4,7 @@ 'area_id': None, 'config_entries': , 'config_entries_subentries': , - 'configuration_url': 'https://teslemetry.com/console', + 'configuration_url': 'https://teslemetry.com/console/energy/123456', 'connections': set({ }), 'disabled_by': None, @@ -35,7 +35,7 @@ 'area_id': None, 'config_entries': , 'config_entries_subentries': , - 'configuration_url': 'https://teslemetry.com/console', + 'configuration_url': 'https://teslemetry.com/console/vehicle/LRW3F7EK4NC700000', 'connections': set({ }), 'disabled_by': None, diff --git a/tests/components/text/test_condition.py b/tests/components/text/test_condition.py index 292a5e0b5bc01b..6de73517bd0b90 100644 --- a/tests/components/text/test_condition.py +++ b/tests/components/text/test_condition.py @@ -21,6 +21,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -48,6 +49,30 @@ async def test_text_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("text.is_equal_to", {"value": "hello"}, True, False), + ], +) +async def test_text_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that text conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + CONDITION_STATES_ANY = [ *parametrize_condition_states_any( condition="text.is_equal_to", @@ -217,9 +242,9 @@ async def test_text_condition_fires_for_both_domains( }, ) - assert checker(hass) is True + assert checker.async_check() is True # Change input_text to non-matching - all behavior should fail hass.states.async_set(entity_id_input_text, "world") await hass.async_block_till_done() - assert checker(hass) is False + assert checker.async_check() is False diff --git a/tests/components/tibber/conftest.py b/tests/components/tibber/conftest.py index b6943067d0cbc0..ae19505e1078fd 100644 --- a/tests/components/tibber/conftest.py +++ b/tests/components/tibber/conftest.py @@ -18,6 +18,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry from tests.typing import RecorderInstanceContextManager @@ -145,6 +146,57 @@ def create_tibber_device( return tibber.data_api.TibberDevice(device_data, home_id=home_id) +def create_tibber_home( + *, + current_price: float | None = 1.25, + price_total: dict[str, float] | None = None, +) -> MagicMock: + """Create a mocked Tibber home with an active subscription.""" + home = MagicMock() + home.home_id = "home-id" + home.name = "Home" + home.currency = "NOK" + home.price_unit = "NOK/kWh" + home.price_total = price_total or {} + home.has_active_subscription = True + home.has_real_time_consumption = False + home.last_data_timestamp = None + home.update_info = AsyncMock(return_value=None) + home.update_info_and_price_info = AsyncMock(return_value=None) + home.current_price_data = MagicMock( + return_value=(current_price, dt_util.utcnow(), 0.4) + ) + home.current_attributes = MagicMock( + return_value={ + "max_price": 1.8, + "avg_price": 1.2, + "min_price": 0.8, + "off_peak_1": 0.9, + "peak": 1.7, + "off_peak_2": 1.0, + } + ) + home.month_cost = 111.1 + home.peak_hour = 2.5 + home.peak_hour_time = dt_util.utcnow() + home.month_cons = 222.2 + home.hourly_consumption_data = [] + home.hourly_production_data = [] + home.info = { + "viewer": { + "home": { + "appNickname": "Home", + "address": {"address1": "Street 1"}, + "meteringPointData": { + "gridCompany": "GridCo", + "estimatedAnnualConsumption": 12000, + }, + } + } + } + return home + + @pytest.fixture def config_entry(hass: HomeAssistant) -> MockConfigEntry: """Tibber config entry.""" diff --git a/tests/components/tibber/test_coordinator.py b/tests/components/tibber/test_coordinator.py new file mode 100644 index 00000000000000..96e84cf32bc12c --- /dev/null +++ b/tests/components/tibber/test_coordinator.py @@ -0,0 +1,252 @@ +"""Tests for the Tibber coordinators.""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +import tibber + +from homeassistant.components.recorder import Recorder +from homeassistant.components.tibber.const import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from .conftest import create_tibber_home + +from tests.common import MockConfigEntry, async_fire_time_changed + + +def _prices_for_days(*days: str) -> dict[str, float]: + """Return price data keyed by ISO timestamps for the given days.""" + return {f"{day}T12:00:00+00:00": 1.0 for day in days} + + +async def _async_setup_price_sensor( + hass: HomeAssistant, + config_entry: MockConfigEntry, + tibber_mock: MagicMock, + entity_registry: er.EntityRegistry, + home: MagicMock, +) -> str: + """Set up the Tibber config entry and return the price sensor entity id.""" + tibber_mock.get_homes.return_value = [home] + config_entry.data["token"]["expires_at"] = dt_util.utcnow().timestamp() + 86400 + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, home.home_id) + assert entity_id is not None + assert hass.states.get(entity_id) is not None + return entity_id + + +async def _async_fire_coordinator_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + delta: timedelta, +) -> None: + """Move time forward and fire scheduled coordinator updates.""" + freezer.tick(delta) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + +async def test_price_fetch_refreshes_when_today_prices_are_missing( + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry: MockConfigEntry, + tibber_mock: MagicMock, + setup_credentials: None, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test price fetching when cached prices do not include today.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2026-04-26 00:10:00+00:00") + home = create_tibber_home(price_total=_prices_for_days("2026-04-25")) + + async def update_info_and_price_info() -> None: + home.price_total = _prices_for_days("2026-04-26") + + home.update_info_and_price_info.side_effect = update_info_and_price_info + + await _async_setup_price_sensor( + hass, config_entry, tibber_mock, entity_registry, home + ) + + # Update immediately when no prices are available for today + assert home.update_info_and_price_info.await_count == 1 + + await _async_fire_coordinator_update(hass, freezer, timedelta(hours=12)) + + # No update after 12 hours + assert home.update_info_and_price_info.await_count == 1 + + +async def test_price_fetch_waits_until_tomorrow_price_polling_window( + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry: MockConfigEntry, + tibber_mock: MagicMock, + setup_credentials: None, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test price fetching waits until the tomorrow-price polling window.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2026-04-26 13:00:00+00:00") + home = create_tibber_home(price_total=_prices_for_days("2026-04-26")) + + async def update_info_and_price_info() -> None: + home.price_total = _prices_for_days("2026-04-26", "2026-04-27") + + home.update_info_and_price_info.side_effect = update_info_and_price_info + + await _async_setup_price_sensor( + hass, config_entry, tibber_mock, entity_registry, home + ) + + assert home.update_info_and_price_info.await_count == 0 + + await _async_fire_coordinator_update(hass, freezer, timedelta(minutes=10)) + + # No update before the price polling window has passed + assert home.update_info_and_price_info.await_count == 0 + + await _async_fire_coordinator_update(hass, freezer, timedelta(hours=9, minutes=50)) + + # Update after the price polling window has passed + assert home.update_info_and_price_info.await_count == 1 + + await _async_fire_coordinator_update(hass, freezer, timedelta(hours=10)) + + # Ensure we only update once + assert home.update_info_and_price_info.await_count == 1 + + +async def test_price_fetch_skips_update_when_tomorrow_prices_exist( + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry: MockConfigEntry, + tibber_mock: MagicMock, + setup_credentials: None, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test price fetching skips homes that already have tomorrow prices.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2026-04-26 23:00:00+00:00") + home = create_tibber_home(price_total=_prices_for_days("2026-04-26", "2026-04-27")) + + await _async_setup_price_sensor( + hass, config_entry, tibber_mock, entity_registry, home + ) + + await _async_fire_coordinator_update(hass, freezer, timedelta(hours=10)) + + # No update when tomorrow prices are already available + assert home.update_info_and_price_info.await_count == 0 + + +@pytest.mark.parametrize( + ("exception", "expected_message"), + [ + pytest.param( + tibber.exceptions.RateLimitExceededError( + 429, "Too many requests", "RATE_LIMIT", 123 + ), + "Rate limit exceeded, retry after 123 seconds", + id="rate_limit", + ), + pytest.param( + tibber.exceptions.HttpExceptionError(503, "Service unavailable"), + "Error communicating with API (Service unavailable)", + id="http_error", + ), + ], +) +async def test_price_fetch_refresh_handles_update_exceptions( + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry: MockConfigEntry, + tibber_mock: MagicMock, + setup_credentials: None, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, + exception: Exception, + expected_message: str, +) -> None: + """Test handled exceptions during price fetching coordinator refresh.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2026-04-26 23:00:00+00:00") + home = create_tibber_home(price_total=_prices_for_days("2026-04-26")) + + entity_id = await _async_setup_price_sensor( + hass, config_entry, tibber_mock, entity_registry, home + ) + state = hass.states.get(entity_id) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + initial_update_count = home.update_info_and_price_info.await_count + home.update_info_and_price_info.side_effect = exception + + await _async_fire_coordinator_update(hass, freezer, timedelta(hours=1)) + + assert home.update_info_and_price_info.await_count == initial_update_count + 1 + assert ( + f"Error fetching {DOMAIN} price fetch data: {expected_message}" in caplog.text + ) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + +async def test_price_sensor_unavailable_when_cached_prices_run_out( + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry: MockConfigEntry, + tibber_mock: MagicMock, + setup_credentials: None, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test price sensor becomes unavailable when cached prices no longer apply.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2026-04-26 23:50:00+00:00") + home = create_tibber_home(price_total=_prices_for_days("2026-04-26")) + + def current_price_data() -> tuple[float | None, datetime | None, float | None]: + if dt_util.now().date() == date(2026, 4, 26): + return (1.25, dt_util.utcnow(), 0.4) + return (None, None, None) + + home.current_price_data.side_effect = current_price_data + + entity_id = await _async_setup_price_sensor( + hass, config_entry, tibber_mock, entity_registry, home + ) + + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == 1.25 + + await _async_fire_coordinator_update(hass, freezer, timedelta(minutes=10)) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR] diff --git a/tests/components/tibber/test_sensor.py b/tests/components/tibber/test_sensor.py index e29464287512b4..64db3e647d9236 100644 --- a/tests/components/tibber/test_sensor.py +++ b/tests/components/tibber/test_sensor.py @@ -11,59 +11,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.util import dt as dt_util -from .conftest import create_tibber_device +from .conftest import create_tibber_device, create_tibber_home from tests.common import MockConfigEntry -def _create_home(*, current_price: float | None = 1.25) -> MagicMock: - """Create a mocked Tibber home with an active subscription.""" - home = MagicMock() - home.home_id = "home-id" - home.name = "Home" - home.currency = "NOK" - home.price_unit = "NOK/kWh" - home.has_active_subscription = True - home.has_real_time_consumption = False - home.last_data_timestamp = None - home.update_info = AsyncMock(return_value=None) - home.update_info_and_price_info = AsyncMock(return_value=None) - home.current_price_data = MagicMock( - return_value=(current_price, dt_util.utcnow(), 0.4) - ) - home.current_attributes = MagicMock( - return_value={ - "max_price": 1.8, - "avg_price": 1.2, - "min_price": 0.8, - "off_peak_1": 0.9, - "peak": 1.7, - "off_peak_2": 1.0, - } - ) - home.month_cost = 111.1 - home.peak_hour = 2.5 - home.peak_hour_time = dt_util.utcnow() - home.month_cons = 222.2 - home.hourly_consumption_data = [] - home.hourly_production_data = [] - home.info = { - "viewer": { - "home": { - "appNickname": "Home", - "address": {"address1": "Street 1"}, - "meteringPointData": { - "gridCompany": "GridCo", - "estimatedAnnualConsumption": 12000, - }, - } - } - } - return home - - async def test_price_sensor_state_unit_and_attributes( recorder_mock: Recorder, hass: HomeAssistant, @@ -73,7 +26,7 @@ async def test_price_sensor_state_unit_and_attributes( entity_registry: er.EntityRegistry, ) -> None: """Test price sensor state and attributes.""" - home = _create_home(current_price=1.25) + home = create_tibber_home(current_price=1.25) tibber_mock.get_homes.return_value = [home] await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/timer/test_condition.py b/tests/components/timer/test_condition.py index 3a60edca4c0c6e..781c088cf03197 100644 --- a/tests/components/timer/test_condition.py +++ b/tests/components/timer/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -40,6 +41,32 @@ async def test_timer_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("timer.is_active", {}, True, True), + ("timer.is_paused", {}, True, True), + ("timer.is_idle", {}, True, True), + ], +) +async def test_timer_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that timer conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index 42d80dcbe040fc..ad417df43f71c4 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -11,6 +11,7 @@ from homeassistant.components.timer import ( ATTR_DURATION, ATTR_FINISHES_AT, + ATTR_LAST_TRANSITION, ATTR_REMAINING, ATTR_RESTORE, CONF_DURATION, @@ -133,8 +134,9 @@ async def test_config_options(hass: HomeAssistant) -> None: assert state_1.state == STATUS_IDLE assert state_1.attributes == { - ATTR_EDITABLE: False, ATTR_DURATION: "0:00:00", + ATTR_EDITABLE: False, + ATTR_LAST_TRANSITION: None, } assert state_2.state == STATUS_IDLE @@ -143,12 +145,14 @@ async def test_config_options(hass: HomeAssistant) -> None: ATTR_EDITABLE: False, ATTR_FRIENDLY_NAME: "Hello World", ATTR_ICON: "mdi:work", + ATTR_LAST_TRANSITION: None, } assert state_3.state == STATUS_IDLE assert state_3.attributes == { ATTR_DURATION: str(cv.time_period(DEFAULT_DURATION)), ATTR_EDITABLE: False, + ATTR_LAST_TRANSITION: None, } @@ -165,6 +169,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None: assert state.attributes == { ATTR_DURATION: "0:00:10", ATTR_EDITABLE: False, + ATTR_LAST_TRANSITION: None, } results: list[tuple[Event, State | None]] = [] @@ -191,6 +196,7 @@ def fake_event_listener(event: Event): "expected_state": STATUS_ACTIVE, "expected_extra_attributes": { ATTR_FINISHES_AT: finish_10, + ATTR_LAST_TRANSITION: "started", ATTR_REMAINING: "0:00:10", }, "expected_event": EVENT_TIMER_STARTED, @@ -199,7 +205,10 @@ def fake_event_listener(event: Event): "call": SERVICE_PAUSE, "call_data": {}, "expected_state": STATUS_PAUSED, - "expected_extra_attributes": {ATTR_REMAINING: "0:00:10"}, + "expected_extra_attributes": { + ATTR_LAST_TRANSITION: "paused", + ATTR_REMAINING: "0:00:10", + }, "expected_event": EVENT_TIMER_PAUSED, }, { @@ -208,6 +217,7 @@ def fake_event_listener(event: Event): "expected_state": STATUS_ACTIVE, "expected_extra_attributes": { ATTR_FINISHES_AT: finish_10, + ATTR_LAST_TRANSITION: "restarted", ATTR_REMAINING: "0:00:10", }, "expected_event": EVENT_TIMER_RESTARTED, @@ -216,14 +226,14 @@ def fake_event_listener(event: Event): "call": SERVICE_CANCEL, "call_data": {}, "expected_state": STATUS_IDLE, - "expected_extra_attributes": {}, + "expected_extra_attributes": {ATTR_LAST_TRANSITION: "cancelled"}, "expected_event": EVENT_TIMER_CANCELLED, }, { "call": SERVICE_CANCEL, "call_data": {}, "expected_state": STATUS_IDLE, - "expected_extra_attributes": {}, + "expected_extra_attributes": {ATTR_LAST_TRANSITION: "cancelled"}, "expected_event": None, }, { @@ -232,6 +242,7 @@ def fake_event_listener(event: Event): "expected_state": STATUS_ACTIVE, "expected_extra_attributes": { ATTR_FINISHES_AT: finish_10, + ATTR_LAST_TRANSITION: "started", ATTR_REMAINING: "0:00:10", }, "expected_event": EVENT_TIMER_STARTED, @@ -240,14 +251,14 @@ def fake_event_listener(event: Event): "call": SERVICE_FINISH, "call_data": {}, "expected_state": STATUS_IDLE, - "expected_extra_attributes": {}, + "expected_extra_attributes": {ATTR_LAST_TRANSITION: "finished"}, "expected_event": EVENT_TIMER_FINISHED, }, { "call": SERVICE_FINISH, "call_data": {}, "expected_state": STATUS_IDLE, - "expected_extra_attributes": {}, + "expected_extra_attributes": {ATTR_LAST_TRANSITION: "finished"}, "expected_event": None, }, { @@ -256,6 +267,7 @@ def fake_event_listener(event: Event): "expected_state": STATUS_ACTIVE, "expected_extra_attributes": { ATTR_FINISHES_AT: finish_10, + ATTR_LAST_TRANSITION: "started", ATTR_REMAINING: "0:00:10", }, "expected_event": EVENT_TIMER_STARTED, @@ -264,14 +276,17 @@ def fake_event_listener(event: Event): "call": SERVICE_PAUSE, "call_data": {}, "expected_state": STATUS_PAUSED, - "expected_extra_attributes": {ATTR_REMAINING: "0:00:10"}, + "expected_extra_attributes": { + ATTR_LAST_TRANSITION: "paused", + ATTR_REMAINING: "0:00:10", + }, "expected_event": EVENT_TIMER_PAUSED, }, { "call": SERVICE_CANCEL, "call_data": {}, "expected_state": STATUS_IDLE, - "expected_extra_attributes": {}, + "expected_extra_attributes": {ATTR_LAST_TRANSITION: "cancelled"}, "expected_event": EVENT_TIMER_CANCELLED, }, { @@ -280,6 +295,7 @@ def fake_event_listener(event: Event): "expected_state": STATUS_ACTIVE, "expected_extra_attributes": { ATTR_FINISHES_AT: finish_10, + ATTR_LAST_TRANSITION: "started", ATTR_REMAINING: "0:00:10", }, "expected_event": EVENT_TIMER_STARTED, @@ -290,6 +306,7 @@ def fake_event_listener(event: Event): "expected_state": STATUS_ACTIVE, "expected_extra_attributes": { ATTR_FINISHES_AT: finish_5, + ATTR_LAST_TRANSITION: "started", # Change does not set last_transition ATTR_REMAINING: "0:00:05", }, "expected_event": EVENT_TIMER_CHANGED, @@ -300,6 +317,7 @@ def fake_event_listener(event: Event): "expected_state": STATUS_ACTIVE, "expected_extra_attributes": { ATTR_FINISHES_AT: finish_5, + ATTR_LAST_TRANSITION: "restarted", ATTR_REMAINING: "0:00:05", }, "expected_event": EVENT_TIMER_RESTARTED, @@ -308,14 +326,17 @@ def fake_event_listener(event: Event): "call": SERVICE_PAUSE, "call_data": {}, "expected_state": STATUS_PAUSED, - "expected_extra_attributes": {ATTR_REMAINING: "0:00:05"}, + "expected_extra_attributes": { + ATTR_LAST_TRANSITION: "paused", + ATTR_REMAINING: "0:00:05", + }, "expected_event": EVENT_TIMER_PAUSED, }, { "call": SERVICE_FINISH, "call_data": {}, "expected_state": STATUS_IDLE, - "expected_extra_attributes": {}, + "expected_extra_attributes": {ATTR_LAST_TRANSITION: "finished"}, "expected_event": EVENT_TIMER_FINISHED, }, ] @@ -372,6 +393,7 @@ async def test_start_service(hass: HomeAssistant) -> None: assert state.attributes == { ATTR_EDITABLE: False, ATTR_DURATION: "0:00:10", + ATTR_LAST_TRANSITION: None, } await hass.services.async_call( @@ -385,6 +407,7 @@ async def test_start_service(hass: HomeAssistant) -> None: ATTR_EDITABLE: False, ATTR_DURATION: "0:00:10", ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(), + ATTR_LAST_TRANSITION: "started", ATTR_REMAINING: "0:00:10", } @@ -398,6 +421,7 @@ async def test_start_service(hass: HomeAssistant) -> None: assert state.attributes == { ATTR_EDITABLE: False, ATTR_DURATION: "0:00:10", + ATTR_LAST_TRANSITION: "cancelled", } with pytest.raises(HomeAssistantError): @@ -422,6 +446,7 @@ async def test_start_service(hass: HomeAssistant) -> None: ATTR_EDITABLE: False, ATTR_DURATION: "0:00:15", ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=15)).isoformat(), + ATTR_LAST_TRANSITION: "started", ATTR_REMAINING: "0:00:15", } @@ -460,6 +485,7 @@ async def test_start_service(hass: HomeAssistant) -> None: ATTR_EDITABLE: False, ATTR_DURATION: "0:00:15", ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=12)).isoformat(), + ATTR_LAST_TRANSITION: "started", # Change does not set last_transition ATTR_REMAINING: "0:00:12", } @@ -476,6 +502,7 @@ async def test_start_service(hass: HomeAssistant) -> None: ATTR_EDITABLE: False, ATTR_DURATION: "0:00:15", ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=14)).isoformat(), + ATTR_LAST_TRANSITION: "started", # Change does not set last_transition ATTR_REMAINING: "0:00:14", } @@ -489,6 +516,7 @@ async def test_start_service(hass: HomeAssistant) -> None: assert state.attributes == { ATTR_EDITABLE: False, ATTR_DURATION: "0:00:10", + ATTR_LAST_TRANSITION: "cancelled", } with pytest.raises( @@ -508,6 +536,7 @@ async def test_start_service(hass: HomeAssistant) -> None: assert state.attributes == { ATTR_EDITABLE: False, ATTR_DURATION: "0:00:10", + ATTR_LAST_TRANSITION: "cancelled", # Change does not set last_transition } @@ -526,6 +555,7 @@ async def test_wait_till_timer_expires( assert state.attributes == { ATTR_DURATION: "0:00:20", ATTR_EDITABLE: False, + ATTR_LAST_TRANSITION: None, } results = [] @@ -553,6 +583,7 @@ def fake_event_listener(event): ATTR_DURATION: "0:00:20", ATTR_EDITABLE: False, ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=20)).isoformat(), + ATTR_LAST_TRANSITION: "started", ATTR_REMAINING: "0:00:20", } @@ -574,6 +605,7 @@ def fake_event_listener(event): ATTR_DURATION: "0:00:20", ATTR_EDITABLE: False, ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=15)).isoformat(), + ATTR_LAST_TRANSITION: "started", ATTR_REMAINING: "0:00:15", } @@ -591,6 +623,7 @@ def fake_event_listener(event): ATTR_DURATION: "0:00:20", ATTR_EDITABLE: False, ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=5)).isoformat(), + ATTR_LAST_TRANSITION: "started", ATTR_REMAINING: "0:00:15", } @@ -604,6 +637,7 @@ def fake_event_listener(event): assert state.attributes == { ATTR_DURATION: "0:00:20", ATTR_EDITABLE: False, + ATTR_LAST_TRANSITION: "finished", } assert results[-1].event_type == EVENT_TIMER_FINISHED @@ -622,6 +656,7 @@ async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> Non assert state.attributes == { ATTR_DURATION: "0:00:10", ATTR_EDITABLE: False, + ATTR_LAST_TRANSITION: None, } @@ -668,6 +703,7 @@ async def test_config_reload( assert state_1.attributes == { ATTR_DURATION: "0:00:00", ATTR_EDITABLE: False, + ATTR_LAST_TRANSITION: None, } assert state_2.state == STATUS_IDLE @@ -676,6 +712,7 @@ async def test_config_reload( ATTR_EDITABLE: False, ATTR_FRIENDLY_NAME: "Hello World", ATTR_ICON: "mdi:work", + ATTR_LAST_TRANSITION: None, } with patch( @@ -726,12 +763,14 @@ async def test_config_reload( ATTR_EDITABLE: False, ATTR_FRIENDLY_NAME: "Hello World reloaded", ATTR_ICON: "mdi:work-reloaded", + ATTR_LAST_TRANSITION: None, } assert state_3.state == STATUS_IDLE assert state_3.attributes == { ATTR_DURATION: "0:00:00", ATTR_EDITABLE: False, + ATTR_LAST_TRANSITION: None, } @@ -748,6 +787,7 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None: assert state.attributes == { ATTR_DURATION: "0:00:10", ATTR_EDITABLE: False, + ATTR_LAST_TRANSITION: None, } results = [] @@ -774,6 +814,7 @@ def fake_event_listener(event): ATTR_DURATION: "0:00:10", ATTR_EDITABLE: False, ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(), + ATTR_LAST_TRANSITION: "started", ATTR_REMAINING: "0:00:10", } @@ -791,6 +832,7 @@ def fake_event_listener(event): ATTR_DURATION: "0:00:10", ATTR_EDITABLE: False, ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(), + ATTR_LAST_TRANSITION: "restarted", ATTR_REMAINING: "0:00:10", } @@ -807,6 +849,7 @@ def fake_event_listener(event): assert state.attributes == { ATTR_DURATION: "0:00:10", ATTR_EDITABLE: False, + ATTR_LAST_TRANSITION: "paused", ATTR_REMAINING: "0:00:10", } @@ -824,6 +867,7 @@ def fake_event_listener(event): ATTR_DURATION: "0:00:10", ATTR_EDITABLE: False, ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(), + ATTR_LAST_TRANSITION: "restarted", ATTR_REMAINING: "0:00:10", } @@ -844,6 +888,7 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None: assert state.attributes == { ATTR_DURATION: "0:00:10", ATTR_EDITABLE: False, + ATTR_LAST_TRANSITION: None, } results = [] @@ -866,6 +911,7 @@ def fake_event_listener(event): ATTR_DURATION: "0:00:10", ATTR_EDITABLE: False, ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(), + ATTR_LAST_TRANSITION: "started", ATTR_REMAINING: "0:00:10", } @@ -883,6 +929,7 @@ def fake_event_listener(event): ATTR_DURATION: "0:00:10", ATTR_EDITABLE: False, ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(), + ATTR_LAST_TRANSITION: "restarted", ATTR_REMAINING: "0:00:10", } @@ -890,6 +937,78 @@ def fake_event_listener(event): assert len(results) == 2 +@pytest.mark.freeze_time("2023-06-05 17:47:50") +async def test_last_transition_after_restarted_timer_expires( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test that last_transition changes from restarted to finished when timer expires.""" + hass.set_state(CoreState.starting) + + await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}}) + + # Start the timer + await hass.services.async_call( + DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}, blocking=True + ) + await hass.async_block_till_done() + + # Restart the timer + await hass.services.async_call( + DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}, blocking=True + ) + await hass.async_block_till_done() + + state = hass.states.get("timer.test1") + assert state.state == STATUS_ACTIVE + assert state.attributes[ATTR_LAST_TRANSITION] == "restarted" + + # Let the timer expire + freezer.tick(15) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("timer.test1") + assert state.state == STATUS_IDLE + assert state.attributes[ATTR_LAST_TRANSITION] == "finished" + + +@pytest.mark.freeze_time("2023-06-05 17:47:50") +async def test_last_transition_persists_across_config_update( + hass: HomeAssistant, +) -> None: + """Test that last_transition is preserved when the timer config is updated.""" + hass.set_state(CoreState.starting) + + await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}}) + + # Start and cancel to set last_transition to "cancelled" + await hass.services.async_call( + DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}, blocking=True + ) + await hass.services.async_call( + DOMAIN, SERVICE_CANCEL, {CONF_ENTITY_ID: "timer.test1"}, blocking=True + ) + await hass.async_block_till_done() + + state = hass.states.get("timer.test1") + assert state.state == STATUS_IDLE + assert state.attributes[ATTR_LAST_TRANSITION] == "cancelled" + + # Reload with a new duration — last_transition should persist + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value={DOMAIN: {"test1": {CONF_DURATION: 20}}}, + ): + await hass.services.async_call(DOMAIN, SERVICE_RELOAD, blocking=True) + await hass.async_block_till_done() + + state = hass.states.get("timer.test1") + assert state.state == STATUS_IDLE + assert state.attributes[ATTR_DURATION] == "0:00:20" + assert state.attributes[ATTR_LAST_TRANSITION] == "cancelled" + + async def test_load_from_storage(hass: HomeAssistant, storage_setup) -> None: """Test set up from storage.""" assert await storage_setup() @@ -899,6 +1018,7 @@ async def test_load_from_storage(hass: HomeAssistant, storage_setup) -> None: ATTR_DURATION: "0:00:00", ATTR_EDITABLE: True, ATTR_FRIENDLY_NAME: "timer from storage", + ATTR_LAST_TRANSITION: None, } @@ -912,6 +1032,7 @@ async def test_editable_state_attribute(hass: HomeAssistant, storage_setup) -> N ATTR_DURATION: "0:00:00", ATTR_EDITABLE: True, ATTR_FRIENDLY_NAME: "timer from storage", + ATTR_LAST_TRANSITION: None, } state = hass.states.get(f"{DOMAIN}.from_yaml") @@ -919,6 +1040,7 @@ async def test_editable_state_attribute(hass: HomeAssistant, storage_setup) -> N assert state.attributes == { ATTR_DURATION: "0:00:00", ATTR_EDITABLE: False, + ATTR_LAST_TRANSITION: None, } @@ -993,6 +1115,7 @@ async def test_update( ATTR_DURATION: "0:00:00", ATTR_EDITABLE: True, ATTR_FRIENDLY_NAME: "timer from storage", + ATTR_LAST_TRANSITION: None, } assert ( entity_registry.async_get_entity_id(DOMAIN, DOMAIN, timer_id) == timer_entity_id @@ -1028,6 +1151,7 @@ async def test_update( ATTR_DURATION: "0:00:33", ATTR_EDITABLE: True, ATTR_FRIENDLY_NAME: "timer from storage", + ATTR_LAST_TRANSITION: None, ATTR_RESTORE: True, } @@ -1067,6 +1191,7 @@ async def test_ws_create( ATTR_DURATION: "0:00:42", ATTR_EDITABLE: True, ATTR_FRIENDLY_NAME: "New Timer", + ATTR_LAST_TRANSITION: None, } assert ( entity_registry.async_get_entity_id(DOMAIN, DOMAIN, timer_id) == timer_entity_id @@ -1092,6 +1217,46 @@ async def test_setup_no_config(hass: HomeAssistant, hass_admin_user: MockUser) - assert count_start == len(hass.states.async_entity_ids()) +@pytest.mark.parametrize("last_transition", [None, "cancelled", "finished"]) +async def test_restore_idle(hass: HomeAssistant, last_transition: str | None) -> None: + """Test entity restore logic when timer is idle.""" + utc_now = utcnow() + attrs: dict[str, Any] = {ATTR_DURATION: "0:00:30"} + if last_transition is not None: + attrs[ATTR_LAST_TRANSITION] = last_transition + stored_state = StoredState( + State("timer.test", STATUS_IDLE, attrs), + None, + utc_now, + ) + + data = async_get(hass) + await data.store.async_save([stored_state.as_dict()]) + await data.async_load() + + entity = Timer.from_storage( + { + CONF_ID: "test", + CONF_NAME: "test", + CONF_DURATION: "0:01:00", + CONF_RESTORE: True, + } + ) + entity.hass = hass + entity.entity_id = "timer.test" + + await entity.async_added_to_hass() + await hass.async_block_till_done() + assert entity.state == STATUS_IDLE + assert entity.extra_state_attributes == { + # Idle timers reset to the configured duration, not the stored one + ATTR_DURATION: "0:01:00", + ATTR_EDITABLE: True, + ATTR_LAST_TRANSITION: last_transition, + ATTR_RESTORE: True, + } + + @pytest.mark.freeze_time("2023-06-05 17:47:50") async def test_restore_paused(hass: HomeAssistant) -> None: """Test entity restore logic when timer is paused.""" @@ -1100,7 +1265,11 @@ async def test_restore_paused(hass: HomeAssistant) -> None: State( "timer.test", STATUS_PAUSED, - {ATTR_DURATION: "0:00:30", ATTR_REMAINING: "0:00:15"}, + { + ATTR_DURATION: "0:00:30", + ATTR_LAST_TRANSITION: "paused", + ATTR_REMAINING: "0:00:15", + }, ), None, utc_now, @@ -1127,13 +1296,17 @@ async def test_restore_paused(hass: HomeAssistant) -> None: assert entity.extra_state_attributes == { ATTR_DURATION: "0:00:30", ATTR_EDITABLE: True, + ATTR_LAST_TRANSITION: "paused", ATTR_REMAINING: "0:00:15", ATTR_RESTORE: True, } @pytest.mark.freeze_time("2023-06-05 17:47:50") -async def test_restore_active_resume(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("last_transition", [None, "started", "restarted"]) +async def test_restore_active_resume( + hass: HomeAssistant, last_transition: str | None +) -> None: """Test entity restore logic when timer is active and end time is after startup.""" events = async_capture_events(hass, EVENT_TIMER_RESTARTED) assert not events @@ -1144,7 +1317,11 @@ async def test_restore_active_resume(hass: HomeAssistant) -> None: State( "timer.test", STATUS_ACTIVE, - {ATTR_DURATION: "0:00:30", ATTR_FINISHES_AT: finish.isoformat()}, + { + ATTR_DURATION: "0:00:30", + ATTR_FINISHES_AT: finish.isoformat(), + ATTR_LAST_TRANSITION: last_transition, + }, ), None, utc_now, @@ -1178,13 +1355,17 @@ async def test_restore_active_resume(hass: HomeAssistant) -> None: ATTR_DURATION: "0:00:30", ATTR_EDITABLE: True, ATTR_FINISHES_AT: finish.isoformat(), + ATTR_LAST_TRANSITION: "restarted", ATTR_REMAINING: "0:00:15", ATTR_RESTORE: True, } assert len(events) == 1 -async def test_restore_active_finished_outside_grace(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("last_transition", [None, "started", "restarted"]) +async def test_restore_active_finished_outside_grace( + hass: HomeAssistant, last_transition: str | None +) -> None: """Test entity restore logic: timer is active, ended while Home Assistant was stopped.""" events = async_capture_events(hass, EVENT_TIMER_FINISHED) assert not events @@ -1195,7 +1376,11 @@ async def test_restore_active_finished_outside_grace(hass: HomeAssistant) -> Non State( "timer.test", STATUS_ACTIVE, - {ATTR_DURATION: "0:00:30", ATTR_FINISHES_AT: finish.isoformat()}, + { + ATTR_DURATION: "0:00:30", + ATTR_FINISHES_AT: finish.isoformat(), + ATTR_LAST_TRANSITION: last_transition, + }, ), None, utc_now, @@ -1226,6 +1411,7 @@ async def test_restore_active_finished_outside_grace(hass: HomeAssistant) -> Non assert entity.extra_state_attributes == { ATTR_DURATION: "0:01:00", ATTR_EDITABLE: True, + ATTR_LAST_TRANSITION: "finished", ATTR_RESTORE: True, } assert len(events) == 1 diff --git a/tests/components/todo/test_condition.py b/tests/components/todo/test_condition.py index 26a0ef33566fb6..9723d1cc2a063a 100644 --- a/tests/components/todo/test_condition.py +++ b/tests/components/todo/test_condition.py @@ -11,6 +11,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -38,6 +39,30 @@ async def test_todo_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("todo.all_completed", {}, True, True), + ], +) +async def test_todo_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that todo conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/tractive/conftest.py b/tests/components/tractive/conftest.py index 1a9a865c1c1897..c4a03a081e7b83 100644 --- a/tests/components/tractive/conftest.py +++ b/tests/components/tractive/conftest.py @@ -89,6 +89,10 @@ def send_server_unavailable_event(hass: HomeAssistant) -> None: patch( "homeassistant.components.tractive.aiotractive.Tractive", autospec=True ) as mock_client, + patch( + "homeassistant.components.tractive.asyncio.sleep", + new_callable=AsyncMock, + ), ): client = mock_client.return_value client.authenticate.return_value = {"user_id": "12345"} diff --git a/tests/components/tuya/test_init.py b/tests/components/tuya/test_init.py index daf8b596c7421d..8c93ab69f16725 100644 --- a/tests/components/tuya/test_init.py +++ b/tests/components/tuya/test_init.py @@ -2,9 +2,10 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import MagicMock, patch from syrupy.assertion import SnapshotAssertion +from tuya_device_handlers import TUYA_QUIRKS_REGISTRY from tuya_sharing import CustomerDevice, Manager from homeassistant.components.tuya.const import ( @@ -142,24 +143,45 @@ async def test_device_registry( ) +@patch.object( + TUYA_QUIRKS_REGISTRY, + "initialise_device_quirk", + wraps=TUYA_QUIRKS_REGISTRY.initialise_device_quirk, +) +@patch("homeassistant.components.tuya.PLATFORMS", []) async def test_dynamic_add_device( + mock_initialise_device_quirk: MagicMock, hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_manager: Manager, notification_helper: TuyaNotificationHelper, device_registry: dr.DeviceRegistry, ) -> None: - """Ensure add device event works correctly.""" - # Initialize with a single device + """Ensure add device event works correctly. + + - the device should be added to the device registry + even if there are no platforms (i.e. no entities created) + - the device should have the quirk applied + """ main_device = await create_device(hass, "mcs_8yhypbo7") second_device = await create_device(hass, "clkg_y7j64p60glp8qpx7") + + # Initialize with a single device await initialize_entry(hass, mock_manager, mock_config_entry, [main_device]) + + # Should now have one device in the registry all_entries = dr.async_entries_for_config_entry( device_registry, mock_config_entry.entry_id ) assert len(all_entries) == 1 + assert any( + (DOMAIN, main_device.id) in device_registry_entry.identifiers + for device_registry_entry in all_entries + ) + mock_initialise_device_quirk.assert_called_once_with(main_device) # Trigger add second device from the manager + mock_initialise_device_quirk.reset_mock() await notification_helper.async_send_add_device(second_device) # Should now have two devices in the registry @@ -167,10 +189,15 @@ async def test_dynamic_add_device( device_registry, mock_config_entry.entry_id ) assert len(all_entries) == 2 + assert any( + (DOMAIN, main_device.id) in device_registry_entry.identifiers + for device_registry_entry in all_entries + ) assert any( (DOMAIN, second_device.id) in device_registry_entry.identifiers for device_registry_entry in all_entries ) + mock_initialise_device_quirk.assert_called_once_with(second_device) async def test_dynamic_remove_device( diff --git a/tests/components/twinkly/test_init.py b/tests/components/twinkly/test_init.py index 0a76a399b63415..8c96f28a1be6ec 100644 --- a/tests/components/twinkly/test_init.py +++ b/tests/components/twinkly/test_init.py @@ -59,7 +59,7 @@ async def test_mac_migration( unique_id="unique_id", data={ CONF_HOST: "192.168.0.123", - CONF_ID: id, + CONF_ID: "00000000-0000-0000-0000-000000000000", CONF_NAME: "Tree 1", CONF_MODEL: TEST_MODEL, }, diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index 7cbefee6760ebd..b06624a22e9fed 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -7,7 +7,7 @@ from datetime import timedelta from types import MappingProxyType from typing import Any, Protocol -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiounifi.models.message import MessageKey import orjson @@ -69,10 +69,18 @@ def __call__( @pytest.fixture(autouse=True, name="mock_discovery") def fixture_discovery(): """No real network traffic allowed.""" - with patch( - "homeassistant.components.unifi.config_flow._async_discover_unifi", - return_value=None, - ) as mock: + with ( + patch( + "homeassistant.components.unifi.config_flow._async_discover_unifi", + return_value=None, + ) as mock, + patch( + "homeassistant.components.unifi_discovery.discovery.AIOUnifiScanner", + return_value=MagicMock( + async_scan=AsyncMock(return_value=[]), found_devices=[] + ), + ), + ): yield mock diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index a27af008b33f8e..b3887f1774ac2d 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -32,7 +32,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.device_registry import format_mac from .conftest import ConfigEntryFactoryType @@ -492,33 +492,78 @@ async def test_simple_option_flow( } -async def test_form_ssdp(hass: HomeAssistant) -> None: - """Test we get the form with ssdp source.""" +async def test_discover_unifi_positive(hass: HomeAssistant) -> None: + """Verify positive run of UniFi discovery.""" + with patch("socket.gethostbyname", return_value="192.168.1.1"): + assert await _async_discover_unifi(hass) == "192.168.1.1" + + +async def test_discover_unifi_negative(hass: HomeAssistant) -> None: + """Verify negative run of UniFi discovery.""" + with patch("socket.gethostbyname", side_effect=socket.gaierror): + assert await _async_discover_unifi(hass) is None + + +INTEGRATION_DISCOVERY_INFO = { + "source_ip": "10.0.0.1", + "hw_addr": "e0:63:da:20:14:a9", + "hostname": "UniFi-Dream-Machine", + "platform": "UCG-Ultra", + "direct_connect_domain": "x.ui.direct", +} + + +async def test_flow_integration_discovery(hass: HomeAssistant) -> None: + """Test we get the form with integration discovery source.""" result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=SsdpServiceInfo( - ssdp_usn="mock_usn", - ssdp_st="mock_st", - ssdp_location="http://192.168.208.1:41417/rootDesc.xml", - upnp={ - "friendlyName": "UniFi Dream Machine", - "modelDescription": "UniFi Dream Machine Pro", - "serialNumber": "e0:63:da:20:14:a9", - }, - ), + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=INTEGRATION_DISCOVERY_INFO, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 + context = next( + flow["context"] + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + assert context["title_placeholders"] == { + "host": "x.ui.direct", + "site": "default", + } + assert context["configuration_url"] == "https://x.ui.direct" + - assert ( - flows[0].get("context", {}).get("configuration_url") - == "https://192.168.208.1:443" +@pytest.mark.usefixtures("config_entry") +async def test_flow_integration_discovery_aborts_if_host_already_exists( + hass: HomeAssistant, +) -> None: + """Test we abort if the host is already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + **INTEGRATION_DISCOVERY_INFO, + "source_ip": "1.2.3.4", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_integration_discovery_uses_direct_connect_domain( + hass: HomeAssistant, +) -> None: + """Test discovery prefers direct_connect_domain over source_ip.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=INTEGRATION_DISCOVERY_INFO, ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" context = next( flow["context"] @@ -526,56 +571,82 @@ async def test_form_ssdp(hass: HomeAssistant) -> None: if flow["flow_id"] == result["flow_id"] ) assert context["title_placeholders"] == { - "host": "192.168.208.1", + "host": "x.ui.direct", "site": "default", } + schema_defaults = { + marker.schema: marker.default() + for marker in result["data_schema"].schema + if hasattr(marker, "default") and callable(marker.default) + } + assert schema_defaults[CONF_HOST] == "x.ui.direct" + assert schema_defaults[CONF_VERIFY_SSL] is True + @pytest.mark.usefixtures("config_entry") -async def test_form_ssdp_aborts_if_host_already_exists(hass: HomeAssistant) -> None: - """Test we abort if the host is already configured.""" +async def test_flow_integration_discovery_aborts_on_direct_connect_host( + hass: HomeAssistant, +) -> None: + """Test we abort if the direct connect domain matches a configured host.""" result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=SsdpServiceInfo( - ssdp_usn="mock_usn", - ssdp_st="mock_st", - ssdp_location="http://1.2.3.4:1234/rootDesc.xml", - upnp={ - "friendlyName": "UniFi Dream Machine", - "modelDescription": "UniFi Dream Machine Pro", - "serialNumber": "e0:63:da:20:14:a9", - }, - ), + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + **INTEGRATION_DISCOVERY_INFO, + "source_ip": "10.0.0.1", + "direct_connect_domain": "1.2.3.4", + }, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("config_entry") -async def test_form_ssdp_aborts_if_serial_already_exists(hass: HomeAssistant) -> None: - """Test we abort if the serial is already configured.""" +async def test_flow_integration_discovery_updates_existing_entry_on_rediscovery( + hass: HomeAssistant, +) -> None: + """Test that an existing entry's host is refreshed when rediscovered with the same MAC.""" + old_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=format_mac(INTEGRATION_DISCOVERY_INFO["hw_addr"]), + data={ + CONF_HOST: "old.host", + CONF_VERIFY_SSL: False, + }, + ) + old_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=SsdpServiceInfo( - ssdp_usn="mock_usn", - ssdp_st="mock_st", - ssdp_location="http://1.2.3.4:1234/rootDesc.xml", - upnp={ - "friendlyName": "UniFi Dream Machine", - "modelDescription": "UniFi Dream Machine Pro", - "serialNumber": "1", - }, - ), + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=INTEGRATION_DISCOVERY_INFO, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + assert old_entry.data[CONF_HOST] == "x.ui.direct" + assert old_entry.data[CONF_VERIFY_SSL] is True + + +async def test_flow_integration_discovery_aborts_without_source_ip( + hass: HomeAssistant, +) -> None: + """Test we abort discovery when source_ip is missing.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + **INTEGRATION_DISCOVERY_INFO, + "source_ip": None, + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" -async def test_form_ssdp_gets_form_with_ignored_entry(hass: HomeAssistant) -> None: - """Test we can still setup if there is an ignored never configured entry.""" +async def test_flow_integration_discovery_gets_form_with_ignored_entry( + hass: HomeAssistant, +) -> None: + """Test we can still set up if there is an ignored never configured entry.""" entry = MockConfigEntry( domain=DOMAIN, data={"not_controller_key": None}, @@ -584,17 +655,8 @@ async def test_form_ssdp_gets_form_with_ignored_entry(hass: HomeAssistant) -> No entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=SsdpServiceInfo( - ssdp_usn="mock_usn", - ssdp_st="mock_st", - ssdp_location="http://1.2.3.4:1234/rootDesc.xml", - upnp={ - "friendlyName": "UniFi Dream Machine New", - "modelDescription": "UniFi Dream Machine Pro", - "serialNumber": "1", - }, - ), + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=INTEGRATION_DISCOVERY_INFO, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -605,18 +667,6 @@ async def test_form_ssdp_gets_form_with_ignored_entry(hass: HomeAssistant) -> No if flow["flow_id"] == result["flow_id"] ) assert context["title_placeholders"] == { - "host": "1.2.3.4", + "host": "x.ui.direct", "site": "default", } - - -async def test_discover_unifi_positive(hass: HomeAssistant) -> None: - """Verify positive run of UniFi discovery.""" - with patch("socket.gethostbyname", return_value=True): - assert await _async_discover_unifi(hass) - - -async def test_discover_unifi_negative(hass: HomeAssistant) -> None: - """Verify negative run of UniFi discovery.""" - with patch("socket.gethostbyname", side_effect=socket.gaierror): - assert await _async_discover_unifi(hass) is None diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index e99989d4abb85b..a9123d11684064 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -155,6 +155,7 @@ async def get_nvr(*args: Any, **kwargs: Any) -> NVR: client.get_bootstrap = AsyncMock(return_value=bootstrap) client.update = AsyncMock(return_value=bootstrap) client.async_disconnect_ws = AsyncMock() + client.has_public_bootstrap = False return client @@ -185,8 +186,17 @@ def subscribe_websocket_state( ufp.ws_state_subscription = ws_state_subscription return Mock() + def subscribe_devices_websocket( + ws_callback: Callable[[WSSubscriptionMessage], None], + ) -> Any: + ufp.devices_ws_subscription = ws_callback + return Mock() + ufp_client.subscribe_websocket = subscribe ufp_client.subscribe_websocket_state = subscribe_websocket_state + ufp_client.subscribe_devices_websocket = subscribe_devices_websocket + ufp_client.update_public = AsyncMock() + ufp_client.has_public_bootstrap = False yield ufp diff --git a/tests/components/unifiprotect/test_alarm_control_panel.py b/tests/components/unifiprotect/test_alarm_control_panel.py new file mode 100644 index 00000000000000..f54686cef2819c --- /dev/null +++ b/tests/components/unifiprotect/test_alarm_control_panel.py @@ -0,0 +1,315 @@ +"""Test the UniFi Protect alarm control panel platform.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock + +import pytest +from uiprotect.data import NVR, NvrArmMode, NvrArmModeStatus, PublicBootstrap +from uiprotect.exceptions import GlobalAlarmManagerError +from uiprotect.websocket import WebsocketState + +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_DOMAIN, + AlarmControlPanelState, +) +from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_ID, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_DISARM, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .utils import MockUFPFixture, assert_entity_counts, init_entry + +ALARM_ENTITY_ID = "alarm_control_panel.unifiprotect_alarm_manager" + + +def _make_arm_mode(status: NvrArmModeStatus) -> Mock: + """Create a NvrArmMode object for testing.""" + arm_mode = Mock(spec=NvrArmMode) + arm_mode.status = status + return arm_mode + + +def _make_public_bootstrap(arm_mode: Mock | None) -> Mock: + """Create a PublicBootstrap with the given arm_mode.""" + pb = Mock(spec=PublicBootstrap) + pb.arm_mode = arm_mode + pb.arm_profiles = {} + pb.relays = {} + pb.sirens = {} + return pb + + +async def test_alarm_panel_not_created_without_public_bootstrap( + hass: HomeAssistant, + ufp: MockUFPFixture, +) -> None: + """Alarm panel entity is NOT created when has_public_bootstrap is False.""" + ufp.api.has_public_bootstrap = False + + await init_entry(hass, ufp, []) + assert_entity_counts(hass, Platform.ALARM_CONTROL_PANEL, 0, 0) + + +async def test_alarm_panel_created_with_public_bootstrap( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + nvr: NVR, +) -> None: + """Alarm panel entity IS created when has_public_bootstrap is True.""" + arm_mode = _make_arm_mode(NvrArmModeStatus.DISABLED) + pb = _make_public_bootstrap(arm_mode) + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = pb + + await init_entry(hass, ufp, []) + assert_entity_counts(hass, Platform.ALARM_CONTROL_PANEL, 1, 1) + + entity = entity_registry.async_get(ALARM_ENTITY_ID) + assert entity is not None + assert entity.unique_id == f"{nvr.mac}_alarm" + + state = hass.states.get(ALARM_ENTITY_ID) + assert state is not None + assert state.state == AlarmControlPanelState.DISARMED + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +@pytest.mark.parametrize( + ("nvr_status", "expected_state"), + [ + (NvrArmModeStatus.DISABLED, AlarmControlPanelState.DISARMED), + (NvrArmModeStatus.UNKNOWN, AlarmControlPanelState.DISARMED), + (NvrArmModeStatus.ARMING, AlarmControlPanelState.ARMING), + (NvrArmModeStatus.ARMED, AlarmControlPanelState.ARMED_AWAY), + (NvrArmModeStatus.BREACH, AlarmControlPanelState.TRIGGERED), + ], +) +async def test_alarm_panel_state_mapping( + hass: HomeAssistant, + ufp: MockUFPFixture, + nvr_status: NvrArmModeStatus, + expected_state: AlarmControlPanelState, +) -> None: + """Test that NvrArmModeStatus maps to correct AlarmControlPanelState.""" + arm_mode = _make_arm_mode(nvr_status) + pb = _make_public_bootstrap(arm_mode) + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = pb + + await init_entry(hass, ufp, []) + + state = hass.states.get(ALARM_ENTITY_ID) + assert state is not None + assert state.state == expected_state + + +async def test_alarm_panel_not_created_without_arm_mode( + hass: HomeAssistant, + ufp: MockUFPFixture, +) -> None: + """Alarm panel entity is NOT created on old firmware (arm_mode is None).""" + pb = _make_public_bootstrap(arm_mode=None) + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = pb + + await init_entry(hass, ufp, []) + assert_entity_counts(hass, Platform.ALARM_CONTROL_PANEL, 0, 0) + + +async def test_alarm_panel_disarm( + hass: HomeAssistant, + ufp: MockUFPFixture, +) -> None: + """Test that disarm service calls disable_arm_alarm_public.""" + arm_mode = _make_arm_mode(NvrArmModeStatus.ARMED) + pb = _make_public_bootstrap(arm_mode) + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = pb + ufp.api.disable_arm_alarm_public = AsyncMock() + + await init_entry(hass, ufp, []) + + await hass.services.async_call( + ALARM_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: ALARM_ENTITY_ID}, + blocking=True, + ) + + ufp.api.disable_arm_alarm_public.assert_called_once() + + +async def test_alarm_panel_arm_away( + hass: HomeAssistant, + ufp: MockUFPFixture, +) -> None: + """Test that arm_away service calls enable_arm_alarm_public.""" + arm_mode = _make_arm_mode(NvrArmModeStatus.DISABLED) + pb = _make_public_bootstrap(arm_mode) + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = pb + ufp.api.enable_arm_alarm_public = AsyncMock() + + await init_entry(hass, ufp, []) + + await hass.services.async_call( + ALARM_DOMAIN, + SERVICE_ALARM_ARM_AWAY, + {ATTR_ENTITY_ID: ALARM_ENTITY_ID}, + blocking=True, + ) + + ufp.api.enable_arm_alarm_public.assert_called_once() + + +async def test_alarm_panel_disarm_global_manager_error( + hass: HomeAssistant, + ufp: MockUFPFixture, +) -> None: + """Test that GlobalAlarmManagerError on disarm raises HomeAssistantError.""" + arm_mode = _make_arm_mode(NvrArmModeStatus.ARMED) + pb = _make_public_bootstrap(arm_mode) + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = pb + ufp.api.disable_arm_alarm_public = AsyncMock(side_effect=GlobalAlarmManagerError()) + + await init_entry(hass, ufp, []) + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + ALARM_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: ALARM_ENTITY_ID}, + blocking=True, + ) + assert exc_info.value.translation_key == "global_alarm_manager" + + +async def test_alarm_panel_arm_away_global_manager_error( + hass: HomeAssistant, + ufp: MockUFPFixture, +) -> None: + """Test that GlobalAlarmManagerError on arm raises HomeAssistantError.""" + arm_mode = _make_arm_mode(NvrArmModeStatus.DISABLED) + pb = _make_public_bootstrap(arm_mode) + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = pb + ufp.api.enable_arm_alarm_public = AsyncMock(side_effect=GlobalAlarmManagerError()) + + await init_entry(hass, ufp, []) + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + ALARM_DOMAIN, + SERVICE_ALARM_ARM_AWAY, + {ATTR_ENTITY_ID: ALARM_ENTITY_ID}, + blocking=True, + ) + assert exc_info.value.translation_key == "global_alarm_manager" + + +async def test_alarm_panel_state_update_via_ws( + hass: HomeAssistant, + ufp: MockUFPFixture, + nvr: NVR, +) -> None: + """Test that public devices WS update triggers state refresh.""" + arm_mode = _make_arm_mode(NvrArmModeStatus.DISABLED) + pb = _make_public_bootstrap(arm_mode) + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = pb + + await init_entry(hass, ufp, []) + + state = hass.states.get(ALARM_ENTITY_ID) + assert state is not None + assert state.state == AlarmControlPanelState.DISARMED + + # Simulate arm state change via the public devices websocket + armed_arm_mode = _make_arm_mode(NvrArmModeStatus.ARMED) + pb.arm_mode = armed_arm_mode + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.old_obj = nvr + mock_msg.new_obj = nvr + assert ufp.devices_ws_subscription is not None + ufp.devices_ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(ALARM_ENTITY_ID) + assert state is not None + assert state.state == AlarmControlPanelState.ARMED_AWAY + + +async def test_alarm_panel_unavailable_when_arm_mode_disappears( + hass: HomeAssistant, + ufp: MockUFPFixture, + nvr: NVR, +) -> None: + """Entity becomes unavailable when arm_mode disappears after a WS update.""" + arm_mode = _make_arm_mode(NvrArmModeStatus.ARMED) + pb = _make_public_bootstrap(arm_mode) + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = pb + + await init_entry(hass, ufp, []) + + state = hass.states.get(ALARM_ENTITY_ID) + assert state is not None + assert state.state == AlarmControlPanelState.ARMED_AWAY + + # Simulate firmware downgrade / global mode switch: arm_mode becomes None + pb.arm_mode = None + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.old_obj = nvr + mock_msg.new_obj = nvr + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(ALARM_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_alarm_panel_unavailable_on_ws_disconnect( + hass: HomeAssistant, + ufp: MockUFPFixture, +) -> None: + """Entity becomes unavailable when the private WebSocket disconnects.""" + arm_mode = _make_arm_mode(NvrArmModeStatus.ARMED) + pb = _make_public_bootstrap(arm_mode) + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = pb + + await init_entry(hass, ufp, []) + + state = hass.states.get(ALARM_ENTITY_ID) + assert state is not None + assert state.state == AlarmControlPanelState.ARMED_AWAY + + ufp.ws_state_subscription(WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + + state = hass.states.get(ALARM_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + ufp.ws_state_subscription(WebsocketState.CONNECTED) + await hass.async_block_till_done() + + state = hass.states.get(ALARM_ENTITY_ID) + assert state is not None + assert state.state == AlarmControlPanelState.ARMED_AWAY diff --git a/tests/components/unifiprotect/test_relay.py b/tests/components/unifiprotect/test_relay.py new file mode 100644 index 00000000000000..0669a8621f4ce7 --- /dev/null +++ b/tests/components/unifiprotect/test_relay.py @@ -0,0 +1,465 @@ +"""Tests for the UniFi Protect relay (Public API) switch entities.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock + +import pytest +from uiprotect.data import ( + ModelType, + PublicBootstrap, + PublicRelayOutput, + Relay, + RelayOutputState, +) +from uiprotect.exceptions import ClientError, NotAuthorized +from uiprotect.websocket import WebsocketState + +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .utils import MockUFPFixture, init_entry + +RELAY_ID = "relay-id-1" +RELAY_MAC = "AA:BB:CC:DD:EE:01" +RELAY_NAME = "Garage Relay" +OUTPUT_ID = 1 +OUTPUT_NAME = "output1" + +SWITCH_ENTITY_ID = "switch.garage_relay_output_output1" + + +def _make_output( + output_id: int = OUTPUT_ID, + name: str | None = OUTPUT_NAME, + state: RelayOutputState | None = RelayOutputState.OFF, +) -> Mock: + """Build a mock :class:`PublicRelayOutput`.""" + output = Mock(spec=PublicRelayOutput) + output.id = output_id + output.name = name + output.state = state + return output + + +def _make_relay( + *, + outputs: list[Mock] | None = None, +) -> Mock: + """Build a mock :class:`Relay` whose ``activate_output`` is awaitable.""" + relay = Mock(spec=Relay) + relay.id = RELAY_ID + relay.mac = RELAY_MAC + relay.name = RELAY_NAME + relay.model = ModelType.RELAY + relay.outputs = outputs if outputs is not None else [_make_output()] + + def get_output(output_id: int) -> Mock | None: + return next((o for o in relay.outputs if o.id == output_id), None) + + relay.get_output = get_output + relay.activate_output = AsyncMock() + return relay + + +def _make_public_bootstrap(relay: Mock | None) -> Mock: + """Build a public bootstrap mock holding the given relay.""" + pb = Mock(spec=PublicBootstrap) + pb.relays = {relay.id: relay} if relay is not None else {} + pb.arm_mode = None + pb.arm_profiles = {} + pb.sirens = {} + return pb + + +@pytest.fixture(name="ufp_with_relay") +def _ufp_with_relay(ufp: MockUFPFixture) -> tuple[MockUFPFixture, Mock]: + """Configure ufp fixture with a single relay accessible via public API.""" + relay = _make_relay() + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = _make_public_bootstrap(relay) + return ufp, relay + + +# --------------------------------------------------------------------------- +# Switch +# --------------------------------------------------------------------------- + + +async def test_relay_switch_not_created_without_public_bootstrap( + hass: HomeAssistant, ufp: MockUFPFixture +) -> None: + """No relay output switch is created when public bootstrap is unavailable.""" + ufp.api.has_public_bootstrap = False + await init_entry(hass, ufp, []) + + assert hass.states.get(SWITCH_ENTITY_ID) is None + + +async def test_relay_switch_created_with_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """Relay output switch is created and reflects the cached state.""" + ufp, relay = ufp_with_relay + relay.outputs[0].state = RelayOutputState.ON + + await init_entry(hass, ufp, []) + + entry = entity_registry.async_get(SWITCH_ENTITY_ID) + assert entry is not None + assert entry.unique_id == f"{RELAY_MAC}_relay_output_{OUTPUT_ID}" + + state = hass.states.get(SWITCH_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + +async def test_relay_switch_off_otp_is_off( + hass: HomeAssistant, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """OFF_OTP (over-temperature protection) is treated as ``off``.""" + ufp, relay = ufp_with_relay + relay.outputs[0].state = RelayOutputState.OFF_OTP + + await init_entry(hass, ufp, []) + + state = hass.states.get(SWITCH_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + + +async def test_relay_switch_unknown_state_is_unknown( + hass: HomeAssistant, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """Unknown relay state should leave the switch state as ``unknown``.""" + ufp, relay = ufp_with_relay + relay.outputs[0].state = RelayOutputState.UNKNOWN + + await init_entry(hass, ufp, []) + + state = hass.states.get(SWITCH_ENTITY_ID) + assert state is not None + # ``is_on`` is None while ``available`` is True → state is "unknown". + # "unavailable" would mean the device is unreachable; UNKNOWN output state + # means state data was received but cannot be interpreted. + assert state.state == STATE_UNKNOWN + + +async def test_relay_switch_turn_on_off( + hass: HomeAssistant, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """Calling ``turn_on``/``turn_off`` invokes the public-API helper.""" + ufp, relay = ufp_with_relay + await init_entry(hass, ufp, []) + + await hass.services.async_call( + Platform.SWITCH, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, + blocking=True, + ) + relay.activate_output.assert_awaited_once_with(OUTPUT_ID, state="on") + relay.activate_output.reset_mock() + + await hass.services.async_call( + Platform.SWITCH, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, + blocking=True, + ) + relay.activate_output.assert_awaited_once_with(OUTPUT_ID, state="off") + + +async def test_relay_switch_state_updates_from_public_ws( + hass: HomeAssistant, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """A public devices WS update for the relay refreshes the switch state.""" + ufp, relay = ufp_with_relay + relay.outputs[0].state = RelayOutputState.OFF + await init_entry(hass, ufp, []) + + state = hass.states.get(SWITCH_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + + relay.outputs[0].state = RelayOutputState.ON + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.old_obj = relay + mock_msg.new_obj = relay + assert ufp.devices_ws_subscription is not None + ufp.devices_ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(SWITCH_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + +async def test_relay_switch_creates_one_entity_per_output( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, +) -> None: + """Multiple outputs on a single relay yield multiple switch entities.""" + relay = _make_relay( + outputs=[ + _make_output(output_id=1, name="output1"), + _make_output(output_id=2, name="output2"), + ], + ) + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = _make_public_bootstrap(relay) + + await init_entry(hass, ufp, []) + + assert entity_registry.async_get("switch.garage_relay_output_output1") is not None + assert entity_registry.async_get("switch.garage_relay_output_output2") is not None + + +async def test_relay_switch_command_error_raises( + hass: HomeAssistant, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """``activate_output`` errors are surfaced as :class:`HomeAssistantError`.""" + ufp, relay = ufp_with_relay + await init_entry(hass, ufp, []) + + relay.activate_output.side_effect = NotAuthorized("denied") + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + Platform.SWITCH, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, + blocking=True, + ) + + +async def test_relay_switch_client_error_raises( + hass: HomeAssistant, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """``ClientError`` from ``activate_output`` is wrapped as HomeAssistantError.""" + ufp, relay = ufp_with_relay + await init_entry(hass, ufp, []) + + relay.activate_output.side_effect = ClientError("timeout") + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + Platform.SWITCH, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, + blocking=True, + ) + + +async def test_relay_switch_command_when_relay_gone( + hass: HomeAssistant, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """Command raises HomeAssistantError when the relay is no longer in bootstrap.""" + ufp, _relay = ufp_with_relay + await init_entry(hass, ufp, []) + + # Remove relay from bootstrap after setup. + ufp.api.public_bootstrap.relays = {} + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + Platform.SWITCH, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, + blocking=True, + ) + + +async def test_relay_switch_command_when_bootstrap_unavailable( + hass: HomeAssistant, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """Command raises HomeAssistantError when has_public_bootstrap is False.""" + ufp, _relay = ufp_with_relay + await init_entry(hass, ufp, []) + + ufp.api.has_public_bootstrap = False + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + Platform.SWITCH, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, + blocking=True, + ) + + +async def test_relay_switch_ws_update_no_state_change( + hass: HomeAssistant, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """WS update with the same state does not trigger an unnecessary state write.""" + ufp, relay = ufp_with_relay + relay.outputs[0].state = RelayOutputState.ON + await init_entry(hass, ufp, []) + + assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_ON # type: ignore[union-attr] + + # Fire update with identical state — entity state must not change. + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.old_obj = relay + mock_msg.new_obj = relay + assert ufp.devices_ws_subscription is not None + ufp.devices_ws_subscription(mock_msg) + await hass.async_block_till_done() + + assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_ON # type: ignore[union-attr] + + +async def test_relay_switch_becomes_unavailable_when_relay_removed( + hass: HomeAssistant, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """Entity becomes unavailable when the relay disappears from the bootstrap.""" + ufp, relay = ufp_with_relay + relay.outputs[0].state = RelayOutputState.OFF + await init_entry(hass, ufp, []) + + # Drop the relay from the public bootstrap. + ufp.api.public_bootstrap.relays = {} + + # Send a WS update whose output list is still valid; the entity must still + # become unavailable because _relay now resolves to None. + relay2 = _make_relay() + relay2.id = relay.id + relay2.mac = relay.mac + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.old_obj = relay2 + mock_msg.new_obj = relay2 + assert ufp.devices_ws_subscription is not None + ufp.devices_ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(SWITCH_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_relay_switch_availability_follows_websocket_state( + hass: HomeAssistant, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """Relay switch becomes unavailable on WS disconnect and recovers on reconnect.""" + ufp, relay = ufp_with_relay + relay.outputs[0].state = RelayOutputState.ON + await init_entry(hass, ufp, []) + + assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_ON # type: ignore[union-attr] + + assert ufp.ws_state_subscription is not None + ufp.ws_state_subscription(WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + + state = hass.states.get(SWITCH_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + ufp.ws_state_subscription(WebsocketState.CONNECTED) + await hass.async_block_till_done() + + state = hass.states.get(SWITCH_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + +async def test_relay_public_ws_message_with_none_new_obj( + hass: HomeAssistant, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """Public WS message with new_obj=None is silently ignored.""" + ufp, _ = ufp_with_relay + await init_entry(hass, ufp, []) + + state_before = hass.states.get(SWITCH_ENTITY_ID) + assert state_before is not None + + mock_msg = Mock() + mock_msg.new_obj = None + + assert ufp.devices_ws_subscription is not None + ufp.devices_ws_subscription(mock_msg) + await hass.async_block_till_done() + + # Entity state must be unchanged. + assert hass.states.get(SWITCH_ENTITY_ID) == state_before + + +async def test_relay_switch_output_removed_from_relay_update( + hass: HomeAssistant, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """WS update where the output is no longer present marks the entity unavailable.""" + ufp, relay = ufp_with_relay + relay.outputs[0].state = RelayOutputState.ON + await init_entry(hass, ufp, []) + + assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_ON # type: ignore[union-attr] + + # Build a relay WS update that no longer contains any outputs. + relay_no_outputs = _make_relay(outputs=[]) + relay_no_outputs.id = relay.id + relay_no_outputs.mac = relay.mac + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.old_obj = relay_no_outputs + mock_msg.new_obj = relay_no_outputs + + assert ufp.devices_ws_subscription is not None + ufp.devices_ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(SWITCH_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_relay_switch_command_when_output_gone( + hass: HomeAssistant, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """Command raises HomeAssistantError when the relay output channel is no longer present.""" + ufp, relay = ufp_with_relay + await init_entry(hass, ufp, []) + + # Remove all outputs from the relay so get_output returns None. + relay.outputs = [] + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + Platform.SWITCH, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, + blocking=True, + ) diff --git a/tests/components/unifiprotect/test_siren.py b/tests/components/unifiprotect/test_siren.py new file mode 100644 index 00000000000000..07042d440bfae1 --- /dev/null +++ b/tests/components/unifiprotect/test_siren.py @@ -0,0 +1,637 @@ +"""Tests for the UniFi Protect siren (Public API) entities.""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import AsyncMock, Mock + +import pytest +from uiprotect.data import ( + ModelType, + PublicBootstrap, + PublicSirenStatus, + Siren, + SirenDuration, +) +from uiprotect.exceptions import ClientError, NotAuthorized +from uiprotect.websocket import WebsocketState + +from homeassistant.components.siren import ATTR_DURATION, ATTR_VOLUME_LEVEL +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from .utils import MockUFPFixture, assert_entity_counts, init_entry + +from tests.common import async_fire_time_changed + +SIREN_ID = "siren-id-1" +SIREN_MAC = "AA:BB:CC:DD:EE:02" +SIREN_NAME = "Garage Siren" + +SIREN_ENTITY_ID = "siren.garage_siren" + + +def _make_siren(*, is_active: bool = False) -> Mock: + """Build a mock :class:`Siren`.""" + status = Mock(spec=PublicSirenStatus) + status.is_active = is_active + status.activated_at = None + status.duration = None + status.turn_off_at = None + siren = Mock(spec=Siren) + siren.id = SIREN_ID + siren.mac = SIREN_MAC + siren.name = SIREN_NAME + siren.model = ModelType.SIREN + siren.volume = 50 + siren.siren_status = status + siren.is_active = is_active + siren.play = AsyncMock() + siren.stop = AsyncMock() + siren.set_volume = AsyncMock() + return siren + + +def _make_public_bootstrap(siren: Mock | None) -> Mock: + """Build a public bootstrap mock with the given siren.""" + pb = Mock(spec=PublicBootstrap) + pb.sirens = {siren.id: siren} if siren is not None else {} + pb.relays = {} + pb.arm_mode = None + pb.arm_profiles = {} + return pb + + +def _make_ws_msg(siren: Mock, *, deleted: bool = False) -> Mock: + """Build a minimal WS subscription message for siren tests.""" + msg = Mock() + msg.changed_data = {} + msg.old_obj = siren + msg.new_obj = None if deleted else siren + return msg + + +@pytest.fixture(name="siren") +def _siren_fixture() -> Mock: + """Build a mock Siren.""" + return _make_siren() + + +@pytest.fixture(name="ufp_with_siren") +def _ufp_with_siren(ufp: MockUFPFixture, siren: Mock) -> MockUFPFixture: + """Configure ufp fixture with a single siren accessible via public API.""" + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = _make_public_bootstrap(siren) + return ufp + + +# --------------------------------------------------------------------------- +# Setup +# --------------------------------------------------------------------------- + + +async def test_siren_not_created_without_public_bootstrap( + hass: HomeAssistant, ufp: MockUFPFixture +) -> None: + """No siren entity is created when public bootstrap is unavailable.""" + ufp.api.has_public_bootstrap = False + await init_entry(hass, ufp, []) + + assert_entity_counts(hass, Platform.SIREN, 0, 0) + + +async def test_siren_created_off( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp_with_siren: MockUFPFixture, +) -> None: + """Siren entity is created with state off when siren is idle.""" + await init_entry(hass, ufp_with_siren, []) + + entry = entity_registry.async_get(SIREN_ENTITY_ID) + assert entry is not None + assert entry.unique_id == f"{SIREN_MAC}_siren" + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + + +async def test_siren_created_on( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, + siren: Mock, +) -> None: + """Siren entity is created with state on when siren is active.""" + siren.is_active = True + + await init_entry(hass, ufp_with_siren, []) + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + + +async def test_siren_turn_on( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, + siren: Mock, +) -> None: + """Calling turn_on activates the siren via play().""" + await init_entry(hass, ufp_with_siren, []) + + await hass.services.async_call( + Platform.SIREN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SIREN_ENTITY_ID}, + blocking=True, + ) + siren.play.assert_awaited_once_with(duration=None) + + +@pytest.mark.parametrize( + ("seconds", "expected"), + [ + (5, SirenDuration.FIVE), + (10, SirenDuration.TEN), + (20, SirenDuration.TWENTY), + (30, SirenDuration.THIRTY), + ], +) +async def test_siren_turn_on_with_duration( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, + siren: Mock, + seconds: int, + expected: SirenDuration, +) -> None: + """Passing a valid duration to turn_on calls play with the matching SirenDuration.""" + await init_entry(hass, ufp_with_siren, []) + + await hass.services.async_call( + Platform.SIREN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SIREN_ENTITY_ID, ATTR_DURATION: seconds}, + blocking=True, + ) + siren.play.assert_awaited_once_with(duration=expected) + + +async def test_siren_turn_on_invalid_duration( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, + siren: Mock, +) -> None: + """Passing an unsupported duration raises ServiceValidationError.""" + await init_entry(hass, ufp_with_siren, []) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + Platform.SIREN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SIREN_ENTITY_ID, ATTR_DURATION: 15}, + blocking=True, + ) + siren.play.assert_not_awaited() + + +async def test_siren_turn_on_invalid_duration_does_not_set_volume( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, + siren: Mock, +) -> None: + """Duration is validated before set_volume is called. + + When both an invalid duration and a volume are given, neither set_volume nor + play must be called — duration validation must happen first. + """ + await init_entry(hass, ufp_with_siren, []) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + Platform.SIREN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: SIREN_ENTITY_ID, + ATTR_DURATION: 15, + ATTR_VOLUME_LEVEL: 0.5, + }, + blocking=True, + ) + siren.set_volume.assert_not_awaited() + siren.play.assert_not_awaited() + + +async def test_siren_turn_on_with_volume( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, + siren: Mock, +) -> None: + """Passing volume_level to turn_on calls set_volume before play.""" + await init_entry(hass, ufp_with_siren, []) + + await hass.services.async_call( + Platform.SIREN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SIREN_ENTITY_ID, ATTR_VOLUME_LEVEL: 0.75}, + blocking=True, + ) + siren.set_volume.assert_awaited_once_with(75) + siren.play.assert_awaited_once_with(duration=None) + + +async def test_siren_turn_off( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, + siren: Mock, +) -> None: + """Calling turn_off stops the siren via stop() and immediately sets state to off.""" + siren.is_active = True + await init_entry(hass, ufp_with_siren, []) + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + await hass.services.async_call( + Platform.SIREN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: SIREN_ENTITY_ID}, + blocking=True, + ) + siren.stop.assert_awaited_once() + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "exc", + [NotAuthorized("denied"), ClientError("timeout")], +) +async def test_siren_turn_on_api_error( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, + siren: Mock, + exc: Exception, +) -> None: + """API errors from play() are wrapped as HomeAssistantError.""" + await init_entry(hass, ufp_with_siren, []) + + siren.play.side_effect = exc + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + Platform.SIREN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SIREN_ENTITY_ID}, + blocking=True, + ) + + +async def test_siren_turn_on_when_siren_gone( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, +) -> None: + """Command raises HomeAssistantError when siren is no longer in bootstrap.""" + await init_entry(hass, ufp_with_siren, []) + + ufp_with_siren.api.public_bootstrap.sirens = {} + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + Platform.SIREN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SIREN_ENTITY_ID}, + blocking=True, + ) + + +async def test_siren_turn_off_when_bootstrap_unavailable( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, +) -> None: + """Command raises HomeAssistantError when has_public_bootstrap is False.""" + await init_entry(hass, ufp_with_siren, []) + + ufp_with_siren.api.has_public_bootstrap = False + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + Platform.SIREN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: SIREN_ENTITY_ID}, + blocking=True, + ) + + +# --------------------------------------------------------------------------- +# WebSocket state updates +# --------------------------------------------------------------------------- + + +async def test_siren_state_updates_from_public_ws( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, + siren: Mock, +) -> None: + """A public devices WS update for the siren refreshes the entity state.""" + await init_entry(hass, ufp_with_siren, []) + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + + siren.is_active = True + + mock_msg = _make_ws_msg(siren) + assert ufp_with_siren.devices_ws_subscription is not None + ufp_with_siren.devices_ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + +async def test_siren_ws_update_no_state_change( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, + siren: Mock, +) -> None: + """WS update with identical state leaves the entity state unchanged.""" + siren.is_active = True + await init_entry(hass, ufp_with_siren, []) + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + mock_msg = _make_ws_msg(siren) + assert ufp_with_siren.devices_ws_subscription is not None + ufp_with_siren.devices_ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + +async def test_siren_availability_follows_websocket_state( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, +) -> None: + """Siren entity becomes unavailable on WS disconnect and recovers on reconnect.""" + await init_entry(hass, ufp_with_siren, []) + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + + assert ufp_with_siren.ws_state_subscription is not None + ufp_with_siren.ws_state_subscription(WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + ufp_with_siren.ws_state_subscription(WebsocketState.CONNECTED) + await hass.async_block_till_done() + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + + +async def test_siren_auto_off_after_timed_duration( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, + siren: Mock, +) -> None: + """State flips to OFF automatically when a timed duration expires. + + The public devices WS never sends an 'off' event for timed runs, so the + entity must schedule its own callback via async_call_later. + """ + await init_entry(hass, ufp_with_siren, []) + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + + # Simulate a WS update: siren becomes active for 10 seconds. + now = dt_util.utcnow() + + active_status = Mock(spec=PublicSirenStatus) + active_status.is_active = True + active_status.activated_at = int(now.timestamp() * 1000) + active_status.duration = 10000 + active_status.turn_off_at = ( + None # implementation uses activated_at+duration directly + ) + + siren.is_active = True + siren.siren_status = active_status + + mock_msg = _make_ws_msg(siren) + assert ufp_with_siren.devices_ws_subscription is not None + ufp_with_siren.devices_ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + # Advance HA time past turn_off_at — the scheduled callback should fire. + async_fire_time_changed(hass, now + timedelta(seconds=11)) + await hass.async_block_till_done() + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + + +async def test_siren_turn_off_cancels_scheduled_timer( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, + siren: Mock, +) -> None: + """Manual turn_off cancels the pending auto-off timer. + + When a timed run is active the entity holds a scheduled callback. A + manual turn_off must cancel that callback so the timer never fires and + the state stays OFF afterwards. + """ + await init_entry(hass, ufp_with_siren, []) + + # Start a timed run — schedules an auto-off callback 30 s from now. + now = dt_util.utcnow() + active_status = Mock(spec=PublicSirenStatus) + active_status.is_active = True + active_status.activated_at = int(now.timestamp() * 1000) + active_status.duration = 30000 # 30 s — won't expire on its own + active_status.turn_off_at = None + + siren.is_active = True + siren.siren_status = active_status + + mock_msg = _make_ws_msg(siren) + assert ufp_with_siren.devices_ws_subscription is not None + ufp_with_siren.devices_ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + # Manually turn off — must cancel the scheduled timer. + await hass.services.async_call( + Platform.SIREN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: SIREN_ENTITY_ID}, + blocking=True, + ) + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + + # Advance time past the original timer — state must stay OFF. + async_fire_time_changed(hass, now + timedelta(seconds=35)) + await hass.async_block_till_done() + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + + +async def test_siren_auto_off_when_already_expired_at_update( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, + siren: Mock, +) -> None: + """State flips to OFF when a WS update arrives with an already-expired duration. + + On reconnect, the public bootstrap may still report is_active=True with an + activated_at+duration that is already in the past. The entity must treat + delay<=0 as immediately expired and set its state to OFF immediately. + """ + await init_entry(hass, ufp_with_siren, []) + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + + # Build a status whose turn-off time is 5 seconds in the PAST. + now = dt_util.utcnow() + expired_activated_at = int((now.timestamp() - 15) * 1000) # 15 s ago + + expired_status = Mock(spec=PublicSirenStatus) + expired_status.is_active = True + expired_status.activated_at = expired_activated_at + expired_status.duration = 10000 # 10 s → expired 5 s ago + expired_status.turn_off_at = None + + siren.is_active = True + siren.siren_status = expired_status + + mock_msg = _make_ws_msg(siren) + assert ufp_with_siren.devices_ws_subscription is not None + ufp_with_siren.devices_ws_subscription(mock_msg) + await hass.async_block_till_done() + + # Entity stays OFF: delay<=0 overrides is_active=True inline, so the state + # machine never sees ON. + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + + +async def test_siren_unavailable_on_delete_event( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, + siren: Mock, +) -> None: + """Entity becomes UNAVAILABLE when the siren is removed via a WS delete event. + + When the public bootstrap sends new_obj=None (device deleted), data.py + dispatches the last-known Siren object (old_obj) to subscriptions. + The entity then checks self._siren; if it is no longer in the bootstrap + it must override _attr_available to False. + """ + await init_entry(hass, ufp_with_siren, []) + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + + # Remove the siren from the public bootstrap so _siren returns None. + del ufp_with_siren.api.public_bootstrap.sirens[SIREN_ID] + + # Simulate a WS delete event: new_obj=None, old_obj=last-known siren. + mock_msg = _make_ws_msg(siren, deleted=True) + assert ufp_with_siren.devices_ws_subscription is not None + ufp_with_siren.devices_ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_siren_auto_off_timer_scheduled_at_startup( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, + siren: Mock, +) -> None: + """Auto-off timer is scheduled during async_added_to_hass for an already-active siren. + + If a timed run is already in progress when HA starts, the entity must + schedule its own auto-off callback immediately (not wait for a WS update) + so the siren does not remain stuck ON after the run expires. + """ + # Configure the siren as already active with 10 s remaining. + now = dt_util.utcnow() + active_status = Mock(spec=PublicSirenStatus) + active_status.is_active = True + active_status.activated_at = int(now.timestamp() * 1000) + active_status.duration = 10000 + active_status.turn_off_at = None + + siren.is_active = True + siren.siren_status = active_status + + await init_entry(hass, ufp_with_siren, []) + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + # Advance HA time past the expiry — the startup-scheduled timer must fire. + async_fire_time_changed(hass, now + timedelta(seconds=11)) + await hass.async_block_till_done() + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index a99ad68e785bd3..29b52f1ba7871d 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -38,6 +38,7 @@ class MockUFPFixture: api: ProtectApiClient ws_subscription: Callable[[WSSubscriptionMessage], None] | None = None ws_state_subscription: Callable[[WebsocketState], None] | None = None + devices_ws_subscription: Callable[[WSSubscriptionMessage], None] | None = None def ws_msg(self, msg: WSSubscriptionMessage) -> None: """Emit WS message for testing.""" diff --git a/tests/components/update/test_condition.py b/tests/components/update/test_condition.py index e8e839d3a14e0d..6344828f8a1a13 100644 --- a/tests/components/update/test_condition.py +++ b/tests/components/update/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -39,6 +40,31 @@ async def test_update_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("update.is_available", {}, True, True), + ("update.is_not_available", {}, True, True), + ], +) +async def test_update_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that update conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 8fafac70d3e20d..2a49e2a728f8f3 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -7,15 +7,17 @@ from unittest.mock import MagicMock, Mock, call, patch, sentinel import pytest -from serialx import SerialPortInfo +from serialx import SerialPortInfo, create_serial_connection, serial_for_url from homeassistant import config_entries from homeassistant.components import usb from homeassistant.components.usb import DOMAIN, async_scan_serial_ports from homeassistant.components.usb.models import SerialDevice, USBDevice +from homeassistant.components.usb.serial_proxy_stub import HassESPHomeSerialStub from homeassistant.components.usb.utils import usb_device_from_path from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -1167,9 +1169,7 @@ async def test_register_port_event_callback( mock_callback2 = Mock() # Start off with no ports - with ( - patch_scanned_serial_ports(return_value=[]), - ): + with patch_scanned_serial_ports(return_value=[]): assert await async_setup_component(hass, DOMAIN, {"usb": {}}) _cancel1 = usb.async_register_port_event_callback(hass, mock_callback1) @@ -1262,9 +1262,7 @@ async def test_register_port_event_callback_failure( mock_callback2 = Mock(side_effect=RuntimeError("Failure 2")) # Start off with no ports - with ( - patch_scanned_serial_ports(return_value=[]), - ): + with patch_scanned_serial_ports(return_value=[]): assert await async_setup_component(hass, DOMAIN, {"usb": {}}) usb.async_register_port_event_callback(hass, mock_callback1) @@ -1340,6 +1338,9 @@ async def test_async_scan_serial_ports(hass: HomeAssistant) -> None: serial_number="10B41DE589FC", manufacturer="Nabu Casa", description="ZBT-2", + bcd_device=257, + interface_description="Nabu Casa ZBT-2", + interface_num=0, ), ] @@ -1674,13 +1675,22 @@ async def async_step_confirm(self, user_input=None): assert final_flows[0]["handler"] == "test2" +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_list_serial_ports( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - setup_usb: MagicMock, ) -> None: """Test listing serial ports via websocket.""" - setup_usb.return_value = [ + matchers = [ + { + "description": "*cp2102*", + "domain": "homeassistant_sky_connect", + "pid": "EA60", + "vid": "10C4", + }, + {"domain": "custom_component", "vid": "DEAD", "pid": "BEEF"}, + ] + mock_ports = [ USBDevice( device="/dev/ttyUSB0", vid="10C4", @@ -1688,6 +1698,25 @@ async def test_list_serial_ports( serial_number="001234", manufacturer="Silicon Labs", description="CP2102 USB to UART", + bcd_device=257, + interface_description="CP2102 USB to UART Bridge", + interface_num=0, + ), + USBDevice( + device="/dev/ttyUSB1", + vid="DEAD", + pid="BEEF", + serial_number=None, + manufacturer=None, + description="Unknown adapter", + ), + USBDevice( + device="/dev/ttyUSB2", + vid="0000", + pid="0000", + serial_number=None, + manufacturer=None, + description="No matchers", ), SerialDevice( device="/dev/ttyS0", @@ -1697,27 +1726,65 @@ async def test_list_serial_ports( ), ] - ws_client = await hass_ws_client(hass) - await ws_client.send_json({"id": 1, "type": "usb/list_serial_ports"}) - response = await ws_client.receive_json() - - assert response["success"] - result = response["result"] - assert len(result) == 2 + with ( + patch("homeassistant.components.usb.async_get_usb", return_value=matchers), + patch_scanned_serial_ports(return_value=mock_ports), + ): + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) + await hass.async_block_till_done() - assert result[0]["device"] == "/dev/ttyUSB0" - assert result[0]["vid"] == "10C4" - assert result[0]["pid"] == "EA60" - assert result[0]["serial_number"] == "001234" - assert result[0]["manufacturer"] == "Silicon Labs" - assert result[0]["description"] == "CP2102 USB to UART" + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/list_serial_ports"}) + response = await ws_client.receive_json() - assert result[1]["device"] == "/dev/ttyS0" - assert result[1]["serial_number"] is None - assert result[1]["manufacturer"] is None - assert result[1]["description"] == "ttyS0" - assert "vid" not in result[1] - assert "pid" not in result[1] + assert response["success"] + assert response["result"] == [ + { + "device": "/dev/ttyUSB0", + "vid": "10C4", + "pid": "EA60", + "serial_number": "001234", + "manufacturer": "Silicon Labs", + "description": "CP2102 USB to UART", + "bcd_device": 257, + "interface_description": "CP2102 USB to UART Bridge", + "interface_num": 0, + "matching_integrations": ["homeassistant_sky_connect"], + }, + { + "device": "/dev/ttyUSB1", + "vid": "DEAD", + "pid": "BEEF", + "serial_number": None, + "manufacturer": None, + "description": "Unknown adapter", + "bcd_device": None, + "interface_description": None, + "interface_num": None, + "matching_integrations": ["custom_component"], + }, + { + "device": "/dev/ttyUSB2", + "vid": "0000", + "pid": "0000", + "serial_number": None, + "manufacturer": None, + "description": "No matchers", + "bcd_device": None, + "interface_description": None, + "interface_num": None, + "matching_integrations": [], + }, + { + "device": "/dev/ttyS0", + "serial_number": None, + "manufacturer": None, + "description": "ttyS0", + "interface_description": None, + "interface_num": None, + "matching_integrations": [], + }, + ] async def test_list_serial_ports_require_admin( @@ -1752,3 +1819,20 @@ async def test_list_serial_ports_os_error( assert not response["success"] assert response["error"]["code"] == "unknown_error" assert "Permission denied" in response["error"]["message"] + + +async def test_serial_proxy_stub_sync(hass: HomeAssistant) -> None: + """Test ESPHome serial proxy stub.""" + assert await async_setup_component(hass, DOMAIN, {}) + + serial_cls = serial_for_url("esphome-hass-usb://192.0.2.1") + assert isinstance(serial_cls, HassESPHomeSerialStub) + + # Nothing actually opens, it just throws an error + with pytest.raises(ConfigEntryNotReady): + await create_serial_connection( + loop=asyncio.get_running_loop(), + protocol_factory=asyncio.Protocol, + url="esphome-hass-usb://192.0.2.1", + baudrate=115200, + ) diff --git a/tests/components/vacuum/test_condition.py b/tests/components/vacuum/test_condition.py index 7d3abfc38117ec..366a882e117a70 100644 --- a/tests/components/vacuum/test_condition.py +++ b/tests/components/vacuum/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, other_states, parametrize_condition_states_all, parametrize_condition_states_any, @@ -43,6 +44,34 @@ async def test_vacuum_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("vacuum.is_cleaning", {}, True, True), + ("vacuum.is_docked", {}, True, True), + ("vacuum.is_encountering_an_error", {}, True, True), + ("vacuum.is_paused", {}, True, True), + ("vacuum.is_returning", {}, True, True), + ], +) +async def test_vacuum_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that vacuum conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 59212654a49b00..cd95abaf2dc08d 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -23,7 +23,7 @@ VacuumActivity, VacuumEntityFeature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er, issue_registry as ir @@ -314,10 +314,11 @@ async def test_clean_area_not_configured(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("config_flow_fixture") @pytest.mark.parametrize( - ("area_mapping", "targeted_areas"), + ("area_mapping", "targeted_areas", "cleaned_segments"), [ - ({}, ["area_1"]), - ({"area_1": ["seg_1"]}, ["area_2"]), + ({}, ["area_2"], None), + ({"area_1": ["seg_1"]}, ["area_2"], None), + ({"area_1": ["seg_1", "seg_2"]}, ["area_1", "area_2"], ["seg_1", "seg_2"]), ], ) async def test_clean_area_no_segments( @@ -325,9 +326,15 @@ async def test_clean_area_no_segments( entity_registry: er.EntityRegistry, area_mapping: dict[str, list[str]], targeted_areas: list[str], + cleaned_segments: list[str] | None, ) -> None: - """Test clean_area does nothing when no segments to clean.""" + """Test clean_area raises error when areas are not mapped to vacuum segments.""" mock_vacuum = MockVacuumWithCleanArea(name="Testing", entity_id="vacuum.testing") + mock_vacuum_2 = MockVacuumWithCleanArea( + name="Testing 2", + entity_id="vacuum.testing_2", + unique_id="mock_vacuum_2_unique_id", + ) config_entry = MockConfigEntry(domain="test") config_entry.add_to_hass(hass) @@ -340,7 +347,9 @@ async def test_clean_area_no_segments( async_unload_entry=help_async_unload_entry, ), ) - setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True) + setup_test_component_platform( + hass, DOMAIN, [mock_vacuum, mock_vacuum_2], from_config_entry=True + ) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -352,15 +361,38 @@ async def test_clean_area_no_segments( "last_seen_segments": [asdict(segment) for segment in mock_vacuum.segments], }, ) - - await hass.services.async_call( + entity_registry.async_update_entity_options( + mock_vacuum_2.entity_id, DOMAIN, - SERVICE_CLEAN_AREA, - {"entity_id": mock_vacuum.entity_id, "cleaning_area_id": targeted_areas}, - blocking=True, + { + "area_mapping": {"area_3": ["seg_3"]}, + "last_seen_segments": [ + asdict(segment) for segment in mock_vacuum_2.segments + ], + }, ) - assert len(mock_vacuum.clean_segments_calls) == 0 + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAN_AREA, + { + "entity_id": [mock_vacuum.entity_id, mock_vacuum_2.entity_id], + "cleaning_area_id": [*targeted_areas, "area_3"], + }, + blocking=True, + ) + assert exc_info.value.translation_key == "areas_not_mapped" + assert exc_info.value.translation_placeholders == {"areas": "area_2"} + + if cleaned_segments is None: + assert len(mock_vacuum.clean_segments_calls) == 0 + else: + assert len(mock_vacuum.clean_segments_calls) == 1 + assert mock_vacuum.clean_segments_calls[0][0] == cleaned_segments + + assert len(mock_vacuum_2.clean_segments_calls) == 1 + assert mock_vacuum_2.clean_segments_calls[0][0] == ["seg_3"] @pytest.mark.usefixtures("config_flow_fixture") @@ -399,7 +431,7 @@ class MockVacuumNoImpl(MockEntity, StateVacuumEntity): await mock_vacuum.async_clean_segments(["seg_1"]) -async def test_clean_area_no_registry_entry() -> None: +async def test_clean_area_no_registry_entry(hass: HomeAssistant) -> None: """Test error handling when registry entry is not set.""" mock_vacuum = MockVacuumWithCleanArea(name="Testing", entity_id="vacuum.testing") @@ -409,11 +441,19 @@ async def test_clean_area_no_registry_entry() -> None: ): mock_vacuum.last_seen_segments # noqa: B018 + call = ServiceCall( + hass, + DOMAIN, + SERVICE_CLEAN_AREA, + {"cleaning_area_id": ["area_1"]}, + context=Context(), + ) + with pytest.raises( RuntimeError, match="Cannot perform area clean, registry entry is not set", ): - await mock_vacuum.async_internal_clean_area(["area_1"]) + await StateVacuumEntity.async_internal_clean_area([mock_vacuum], call) with pytest.raises( RuntimeError, diff --git a/tests/components/valve/test_condition.py b/tests/components/valve/test_condition.py index 5ec78a90229636..20b52236f5f37b 100644 --- a/tests/components/valve/test_condition.py +++ b/tests/components/valve/test_condition.py @@ -13,6 +13,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -40,6 +41,31 @@ async def test_valve_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("valve.is_open", {}, True, False), + ("valve.is_closed", {}, True, False), + ], +) +async def test_valve_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that valve conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index 2c4cd51a97b77a..fb7018bb6ef5bd 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -74,7 +74,7 @@ def mock_window() -> AsyncMock: window.device_updated_cbs = [] window.is_opening = False window.is_closing = False - window.position = MagicMock(position_percent=30, closed=False) + window.position = MagicMock(position_percent=30, closed=False, known=True) window.wink = AsyncMock() window.pyvlx = MagicMock() return window @@ -89,9 +89,13 @@ def mock_dual_roller_shutter() -> AsyncMock: cover.serial_number = "987654321" cover.is_opening = False cover.is_closing = False - cover.position_upper_curtain = MagicMock(position_percent=30, closed=False) - cover.position_lower_curtain = MagicMock(position_percent=30, closed=False) - cover.position = MagicMock(position_percent=30, closed=False) + cover.position_upper_curtain = MagicMock( + position_percent=30, closed=False, known=True + ) + cover.position_lower_curtain = MagicMock( + position_percent=30, closed=False, known=True + ) + cover.position = MagicMock(position_percent=30, closed=False, known=True) cover.pyvlx = MagicMock() return cover @@ -104,11 +108,11 @@ def mock_blind() -> AsyncMock: blind.name = "Test Blind" blind.serial_number = "4711" # Standard cover position (used by current_cover_position) - blind.position = MagicMock(position_percent=40, closed=False) + blind.position = MagicMock(position_percent=40, closed=False, known=True) blind.is_opening = False blind.is_closing = False # Orientation/tilt-related attributes and methods - blind.orientation = MagicMock(position_percent=25) + blind.orientation = MagicMock(position_percent=25, known=True) blind.open_orientation = AsyncMock() blind.close_orientation = AsyncMock() blind.stop_orientation = AsyncMock() @@ -175,9 +179,13 @@ def mock_cover_type(request: pytest.FixtureRequest) -> AsyncMock: cover.serial_number = f"serial_{request.param.__name__}" cover.is_opening = False cover.is_closing = False - cover.position = MagicMock(position_percent=30, closed=False) - cover.position_upper_curtain = MagicMock(position_percent=30, closed=False) - cover.position_lower_curtain = MagicMock(position_percent=30, closed=False) + cover.position = MagicMock(position_percent=30, closed=False, known=True) + cover.position_upper_curtain = MagicMock( + position_percent=30, closed=False, known=True + ) + cover.position_lower_curtain = MagicMock( + position_percent=30, closed=False, known=True + ) cover.pyvlx = MagicMock() return cover diff --git a/tests/components/velux/test_cover.py b/tests/components/velux/test_cover.py index a2620aac31dc41..483fbca5593e3e 100644 --- a/tests/components/velux/test_cover.py +++ b/tests/components/velux/test_cover.py @@ -33,6 +33,7 @@ STATE_CLOSING, STATE_OPEN, STATE_OPENING, + STATE_UNKNOWN, Platform, ) from homeassistant.core import HomeAssistant @@ -475,6 +476,77 @@ async def test_non_blind_has_no_tilt_position( assert "current_tilt_position" not in state.attributes +# Unknown position tests + + +async def test_window_unknown_position( + hass: HomeAssistant, mock_window: AsyncMock +) -> None: + """When the device position is not known, state and position must be unknown.""" + + entity_id = "cover.test_window" + + mock_window.position.known = False + await update_callback_entity(hass, mock_window) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes.get("current_position") is None + + +@pytest.mark.parametrize( + ("unknown_attr", "unknown_entity_id"), + [ + ("position", "cover.test_dual_roller_shutter"), + ("position_upper_curtain", "cover.test_dual_roller_shutter_upper_shutter"), + ("position_lower_curtain", "cover.test_dual_roller_shutter_lower_shutter"), + ], +) +async def test_dual_roller_shutter_unknown_position( + hass: HomeAssistant, + mock_dual_roller_shutter: AsyncMock, + unknown_attr: str, + unknown_entity_id: str, +) -> None: + """Each part falls back to unknown independently when only its position is unknown.""" + + all_entity_ids = { + "cover.test_dual_roller_shutter", + "cover.test_dual_roller_shutter_upper_shutter", + "cover.test_dual_roller_shutter_lower_shutter", + } + + getattr(mock_dual_roller_shutter, unknown_attr).known = False + await update_callback_entity(hass, mock_dual_roller_shutter) + + state = hass.states.get(unknown_entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes.get("current_position") is None + + for entity_id in all_entity_ids - {unknown_entity_id}: + state = hass.states.get(entity_id) + assert state is not None + assert state.state != STATE_UNKNOWN + assert state.attributes.get("current_position") == 70 + + +async def test_blind_unknown_tilt_position( + hass: HomeAssistant, mock_blind: AsyncMock +) -> None: + """Tilt position must be None when the orientation is not known.""" + + entity_id = "cover.test_blind" + + mock_blind.orientation.known = False + await update_callback_entity(hass, mock_blind) + + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes.get("current_tilt_position") is None + + # Exception handling tests diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index 3df28b482a291d..1f96d165312153 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -7146,7 +7146,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '53', + 'state': '53.0', }) # --- # name: test_all_entities[sensor.model7_temperature-entry] @@ -7259,7 +7259,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '52', + 'state': '52.0', }) # --- # name: test_all_entities[sensor.model8_temperature-entry] diff --git a/tests/components/vodafone_station/snapshots/test_sensor.ambr b/tests/components/vodafone_station/snapshots/test_sensor.ambr index 4515ccc78561a0..eb9953213bfa70 100644 --- a/tests/components/vodafone_station/snapshots/test_sensor.ambr +++ b/tests/components/vodafone_station/snapshots/test_sensor.ambr @@ -238,14 +238,14 @@ 'object_id_base': 'Uptime', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Uptime', 'platform': 'vodafone_station', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'sys_uptime', + 'translation_key': None, 'unique_id': 'm123456789_sys_uptime', 'unit_of_measurement': None, }) @@ -253,7 +253,7 @@ # name: test_all_entities[sensor.vodafone_station_m123456789_uptime-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Vodafone Station (m123456789) Uptime', }), 'context': , diff --git a/tests/components/wake_on_lan/test_switch.py b/tests/components/wake_on_lan/test_switch.py index 2711ef6942cbfb..663e6189124beb 100644 --- a/tests/components/wake_on_lan/test_switch.py +++ b/tests/components/wake_on_lan/test_switch.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch from homeassistant.components import switch +from homeassistant.components.wake_on_lan.switch import WolSwitch from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -256,3 +257,44 @@ async def test_no_hostname_state( state = hass.states.get("switch.wake_on_lan") assert state.state == STATE_OFF + + +async def test_remove_unloads_off_script( + hass: HomeAssistant, mock_send_magic_packet: AsyncMock +) -> None: + """Test that removing the WOL switch unloads the off script.""" + assert await async_setup_component( + hass, + switch.DOMAIN, + { + "switch": { + "platform": "wake_on_lan", + "mac": "00-01-02-03-04-05", + "host": "validhostname", + "turn_off": {"service": "shell_command.turn_off_target"}, + } + }, + ) + await hass.async_block_till_done() + + entity = hass.data[switch.DOMAIN].get_entity("switch.wake_on_lan") + assert isinstance(entity, WolSwitch) + assert entity._off_script is not None + + with ( + patch.object( + entity._off_script, + "async_stop", + wraps=entity._off_script.async_stop, + ) as stop_mock, + patch.object( + entity._off_script, + "async_unload", + wraps=entity._off_script.async_unload, + ) as unload_mock, + ): + await entity.async_remove() + await hass.async_block_till_done() + + stop_mock.assert_called_once() + unload_mock.assert_called_once() diff --git a/tests/components/water_heater/test_condition.py b/tests/components/water_heater/test_condition.py index f4965ec70b3ea2..0ddf476a58cc19 100644 --- a/tests/components/water_heater/test_condition.py +++ b/tests/components/water_heater/test_condition.py @@ -26,6 +26,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, assert_numerical_condition_unit_conversion, parametrize_condition_states_all, parametrize_condition_states_any, @@ -71,6 +72,31 @@ async def test_water_heater_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("water_heater.is_off", {}, True, True), + ("water_heater.is_on", {}, True, False), + ], +) +async def test_water_heater_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that water_heater conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/waterfurnace/conftest.py b/tests/components/waterfurnace/conftest.py index fac64eb836d51f..a174872082c1e4 100644 --- a/tests/components/waterfurnace/conftest.py +++ b/tests/components/waterfurnace/conftest.py @@ -15,7 +15,7 @@ ) from homeassistant.components.recorder.statistics import async_add_external_statistics from homeassistant.components.waterfurnace.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform, UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import EnergyConverter @@ -151,16 +151,24 @@ async def seed_statistics( await async_wait_recording_done(hass) +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.CLIMATE, Platform.SENSOR] + + @pytest.fixture async def init_integration( recorder_mock: Recorder, hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_waterfurnace_client: Mock, + platforms: list[Platform], ) -> MockConfigEntry: """Set up the WaterFurnace integration for testing.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + with patch("homeassistant.components.waterfurnace.PLATFORMS", platforms): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() return mock_config_entry diff --git a/tests/components/waterfurnace/fixtures/device_data.json b/tests/components/waterfurnace/fixtures/device_data.json index 41fd4a6ec086bd..ebd94eb7740a5b 100644 --- a/tests/components/waterfurnace/fixtures/device_data.json +++ b/tests/components/waterfurnace/fixtures/device_data.json @@ -5,12 +5,30 @@ "leavingairtemp": 110.5, "tstatroomtemp": 70.2, "enteringwatertemp": 42.8, + "tstatdehumidsetpoint": 55, "tstathumidsetpoint": 45, "tstatrelativehumidity": 43, + "humidity_offset_settings": { "fan_mode": 0, "accessory_type": 2 }, "compressorpower": 800, "fanpower": 150, "auxpower": 0, "looppumppower": 50, "actualcompressorspeed": 1200, - "airflowcurrentspeed": 850 + "airflowcurrentspeed": 850, + "tstatheatingsetpoint": 68, + "tstatcoolingsetpoint": 74, + "activesettings": { + "activemode": 3, + "heatingsp_read": 68, + "coolingsp_read": 74, + "fanmode_read": 0, + "temporaryoverride": 0, + "permanenthold": 0, + "vacationhold": 0, + "onpeakhold": 0, + "superboost": 0, + "tstatmode": 3, + "intertimeon_read": 0, + "intertimeoff_read": 0 + } } diff --git a/tests/components/waterfurnace/snapshots/test_climate.ambr b/tests/components/waterfurnace/snapshots/test_climate.ambr new file mode 100644 index 00000000000000..68b674932e79fa --- /dev/null +++ b/tests/components/waterfurnace/snapshots/test_climate.ambr @@ -0,0 +1,80 @@ +# serializer version: 1 +# name: test_climate_snapshot[climate.test_abc_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 95, + 'max_temp': 26.7, + 'min_humidity': 15, + 'min_temp': 4.4, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_abc_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'waterfurnace', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'TEST_GWID_12345', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_abc_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 43, + 'current_temperature': 21.2, + 'friendly_name': 'Test ABC Type', + 'humidity': 45, + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 95, + 'max_temp': 26.7, + 'min_humidity': 15, + 'min_temp': 4.4, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.test_abc_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/waterfurnace/snapshots/test_sensor.ambr b/tests/components/waterfurnace/snapshots/test_sensor.ambr index 1a9b0d2f76de13..dece10773f7ee8 100644 --- a/tests/components/waterfurnace/snapshots/test_sensor.ambr +++ b/tests/components/waterfurnace/snapshots/test_sensor.ambr @@ -278,7 +278,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '23.3333333333333', }) # --- # name: test_sensors[sensor.test_abc_type_dehumidification_setpoint-entry] @@ -333,7 +333,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '55', }) # --- # name: test_sensors[sensor.test_abc_type_fan_power-entry] @@ -549,7 +549,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '20.0', }) # --- # name: test_sensors[sensor.test_abc_type_humidity-entry] diff --git a/tests/components/waterfurnace/test_climate.py b/tests/components/waterfurnace/test_climate.py new file mode 100644 index 00000000000000..c845e13ec59066 --- /dev/null +++ b/tests/components/waterfurnace/test_climate.py @@ -0,0 +1,357 @@ +"""Test climate of WaterFurnace integration.""" + +from unittest.mock import Mock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion +from waterfurnace.waterfurnace import WFException + +from homeassistant.components.climate import ( + ATTR_HUMIDITY, + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACAction, + HVACMode, +) +from homeassistant.components.waterfurnace.const import UPDATE_INTERVAL +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "climate.test_abc_type" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.CLIMATE] + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_climate_snapshot( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test climate entity against snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +@pytest.mark.parametrize( + ("active_mode_index", "expected_hvac_mode"), + [ + (0, HVACMode.OFF), + (1, HVACMode.HEAT_COOL), + (2, HVACMode.COOL), + (3, HVACMode.HEAT), + (4, HVACMode.HEAT), + ], + ids=["Off", "Auto", "Cool", "Heat", "E-Heat"], +) +async def test_hvac_mode_mapping( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, + freezer: FrozenDateTimeFactory, + active_mode_index: int, + expected_hvac_mode: HVACMode, +) -> None: + """Test that ActiveSettings.mode maps to the correct HVACMode.""" + mock_waterfurnace_client.read_with_retry.return_value.activesettings.activemode = ( + active_mode_index + ) + + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == expected_hvac_mode.value + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +@pytest.mark.parametrize( + ("mode_index", "expected_action"), + [ + (0, HVACAction.IDLE), + (1, HVACAction.FAN), + (2, HVACAction.COOLING), + (3, HVACAction.COOLING), + (4, HVACAction.HEATING), + (5, HVACAction.HEATING), + (6, HVACAction.HEATING), + (7, HVACAction.HEATING), + (8, HVACAction.HEATING), + (9, HVACAction.OFF), + ], + ids=[ + "Standby", + "Fan Only", + "Cooling 1", + "Cooling 2", + "Reheat", + "Heating 1", + "Heating 2", + "E-Heat", + "Aux Heat", + "Lockout", + ], +) +async def test_hvac_action_mapping( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, + freezer: FrozenDateTimeFactory, + mode_index: int, + expected_action: HVACAction, +) -> None: + """Test that WFReading.mode maps to the correct HVACAction.""" + mock_waterfurnace_client.read_with_retry.return_value.modeofoperation = mode_index + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state + assert state.attributes["hvac_action"] == expected_action + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +@pytest.mark.parametrize( + ("hvac_mode", "expected_wf_mode"), + [ + (HVACMode.OFF, 0), + (HVACMode.HEAT_COOL, 1), + (HVACMode.COOL, 2), + (HVACMode.HEAT, 3), + ], + ids=["Off", "Auto", "Cool", "Heat"], +) +async def test_set_hvac_mode( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, + hvac_mode: HVACMode, + expected_wf_mode: int, +) -> None: + """Test setting HVAC mode calls the library with the correct integer.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + mock_waterfurnace_client.set_mode.assert_called_once_with(expected_wf_mode) + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_set_temperature_single_heat( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, +) -> None: + """Test setting temperature in heat mode sets heating setpoint.""" + # Fixture default is activemode=3 (Heat) + # Send 22°C (HA test default unit); entity converts to ~71.6°F + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 22}, + blocking=True, + ) + mock_waterfurnace_client.set_heating_setpoint.assert_called_once_with( + pytest.approx(71.6, abs=0.1) + ) + mock_waterfurnace_client.set_cooling_setpoint.assert_not_called() + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_set_temperature_single_cool( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setting temperature in cool mode sets cooling setpoint.""" + # Switch to Cool mode + mock_waterfurnace_client.read_with_retry.return_value.activesettings.activemode = 2 + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Send 24°C; entity converts to ~75.2°F + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 24}, + blocking=True, + ) + mock_waterfurnace_client.set_cooling_setpoint.assert_called_once_with( + pytest.approx(75.2, abs=0.1) + ) + mock_waterfurnace_client.set_heating_setpoint.assert_not_called() + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_set_temperature_range( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setting temperature range sets both setpoints.""" + # Switch to Auto mode + mock_waterfurnace_client.read_with_retry.return_value.activesettings.activemode = 1 + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Send 18°C low / 26°C high; entity converts to ~64.4°F / ~78.8°F + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TARGET_TEMP_LOW: 18, + ATTR_TARGET_TEMP_HIGH: 26, + }, + blocking=True, + ) + mock_waterfurnace_client.set_heating_setpoint.assert_called_once_with( + pytest.approx(64.4, abs=0.1) + ) + mock_waterfurnace_client.set_cooling_setpoint.assert_called_once_with( + pytest.approx(78.8, abs=0.1) + ) + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_set_humidity( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, +) -> None: + """Test setting target humidity.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HUMIDITY: 50}, + blocking=True, + ) + mock_waterfurnace_client.set_humidity.assert_called_once_with(50) + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_target_temperature_cool_mode( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test target_temperature returns cooling setpoint in cool mode.""" + mock_waterfurnace_client.read_with_retry.return_value.activesettings.activemode = 2 + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state + # Fixture: tstatcoolingsetpoint=74°F → 23.3°C + assert state.attributes["temperature"] == pytest.approx(23.3, abs=0.1) + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_target_temperature_range_auto_mode( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test target_temperature_high/low in auto mode.""" + mock_waterfurnace_client.read_with_retry.return_value.activesettings.activemode = 1 + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state + # Fixture: tstatheatingsetpoint=68°F → 20°C, tstatcoolingsetpoint=74°F → 23.3°C + assert state.attributes["target_temp_low"] == 20.0 + assert state.attributes["target_temp_high"] == pytest.approx(23.3, abs=0.1) + assert state.attributes["temperature"] is None + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_set_hvac_mode_error( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, +) -> None: + """Test that a library error raises HomeAssistantError.""" + mock_waterfurnace_client.set_mode.side_effect = WFException("connection lost") + with pytest.raises(HomeAssistantError, match="Failed to set HVAC mode"): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_set_temperature_error( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, +) -> None: + """Test that a library error raises HomeAssistantError.""" + mock_waterfurnace_client.set_heating_setpoint.side_effect = WFException("timeout") + with pytest.raises(HomeAssistantError, match="Failed to set temperature"): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 22}, + blocking=True, + ) + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_set_humidity_error( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, +) -> None: + """Test that a library error raises HomeAssistantError.""" + mock_waterfurnace_client.set_humidity.side_effect = WFException("timeout") + with pytest.raises(HomeAssistantError, match="Failed to set humidity"): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HUMIDITY: 50}, + blocking=True, + ) + + +@pytest.mark.usefixtures("seed_statistics", "init_integration") +async def test_set_temperature_with_hvac_mode( + hass: HomeAssistant, + mock_waterfurnace_client: Mock, +) -> None: + """Test that ATTR_HVAC_MODE in set_temperature switches mode first.""" + # Fixture default is activemode=3 (Heat); send cool mode + temperature + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 24, + ATTR_HVAC_MODE: HVACMode.COOL, + }, + blocking=True, + ) + mock_waterfurnace_client.set_mode.assert_called_once_with(2) + mock_waterfurnace_client.set_cooling_setpoint.assert_called_once_with( + pytest.approx(75.2, abs=0.1) + ) + mock_waterfurnace_client.set_heating_setpoint.assert_not_called() diff --git a/tests/components/waterfurnace/test_sensor.py b/tests/components/waterfurnace/test_sensor.py index c28bbaa7680492..389c295d1f170a 100644 --- a/tests/components/waterfurnace/test_sensor.py +++ b/tests/components/waterfurnace/test_sensor.py @@ -9,23 +9,27 @@ from waterfurnace.waterfurnace import WFException from homeassistant.components.waterfurnace.const import UPDATE_INTERVAL -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR] + + @pytest.mark.usefixtures("seed_statistics", "init_integration") async def test_sensors( hass: HomeAssistant, - mock_waterfurnace_client: Mock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, ) -> None: """Test that we create the expected sensors.""" - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/webhook/test_init.py b/tests/components/webhook/test_init.py index 20fe50249625e8..6300d640157080 100644 --- a/tests/components/webhook/test_init.py +++ b/tests/components/webhook/test_init.py @@ -9,10 +9,13 @@ import pytest from homeassistant.components import webhook +from homeassistant.components.websocket_api import auth, http from homeassistant.core import HomeAssistant from homeassistant.core_config import async_process_ha_core_config from homeassistant.setup import async_setup_component +from homeassistant.util.aiohttp import MockRequest +from tests.test_util import mock_real_ip from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -267,6 +270,45 @@ async def handle(*args): assert len(hooks) == 1 +@pytest.mark.parametrize( + ("remote", "expected_calls"), + [ + (None, 0), + ("123.123.123.123", 0), + ("not-an-ip", 0), + ("192.168.1.50", 1), + ], +) +async def test_webhook_local_only_mock_request( + hass: HomeAssistant, remote: str | None, expected_calls: int +) -> None: + """Test local_only webhooks for MockRequests with various remote values.""" + await async_setup_component(hass, "webhook", {}) + + hooks = [] + webhook_id = webhook.async_generate_id() + + async def handle(hass: HomeAssistant, webhook_id: str, request: web.Request): + """Handle webhook.""" + hooks.append((hass, webhook_id, await request.text())) + + webhook.async_register( + hass, "test", "Test hook", webhook_id, handle, local_only=True + ) + + request = MockRequest( + content=b'{"data": true}', + headers={"Content-Type": "application/json"}, + method="POST", + query_string="", + mock_source="test", + remote=remote, + ) + resp = await webhook.async_handle_webhook(hass, webhook_id, request) + assert resp.status == HTTPStatus.OK + assert len(hooks) == expected_calls + + @pytest.mark.usefixtures("enable_custom_integrations") async def test_listing_webhook( hass: HomeAssistant, @@ -356,6 +398,8 @@ async def handler( assert received[0].headers["content-type"] == "application/json" assert received[0].query == {"a": "2"} assert await received[0].json() == {"hello": "world"} + # The MockRequest is created with the websocket connection's remote IP + assert received[0].remote is not None # Non existing webhook caplog.clear() @@ -383,3 +427,68 @@ async def handler( in caplog.text ) assert '{"nonexisting": "payload"}' in caplog.text + + +@pytest.mark.parametrize( + ("remote_ip", "expected_calls"), + [ + ("192.168.1.50", 1), + ("123.123.123.123", 0), + ], +) +async def test_ws_webhook_local_only( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + hass_access_token: str, + remote_ip: str, + expected_calls: int, +) -> None: + """Test a local_only webhook over the websocket connection.""" + assert await async_setup_component(hass, "webhook", {}) + assert await async_setup_component(hass, "websocket_api", {}) + await hass.async_block_till_done() + + received = [] + + async def handler( + hass: HomeAssistant, webhook_id: str, request: web.Request + ) -> web.Response: + """Handle a webhook.""" + received.append(request) + return web.json_response({"from": "handler"}) + + webhook.async_register( + hass, "test", "Test", "mock-webhook-id", handler, local_only=True + ) + + set_mock_ip = mock_real_ip(hass.http.app) + set_mock_ip(remote_ip) + + client = await hass_client_no_auth() + + async with client.ws_connect(http.URL) as ws: + auth_msg = await ws.receive_json() + assert auth_msg["type"] == auth.TYPE_AUTH_REQUIRED + + await ws.send_json({"type": auth.TYPE_AUTH, "access_token": hass_access_token}) + auth_msg = await ws.receive_json() + assert auth_msg["type"] == auth.TYPE_AUTH_OK + + await ws.send_json( + { + "id": 5, + "type": "webhook/handle", + "webhook_id": "mock-webhook-id", + "method": "POST", + "headers": {"Content-Type": "application/json"}, + "body": '{"hello": "world"}', + "query": "", + } + ) + result = await ws.receive_json() + + assert result["success"], result + assert result["result"]["status"] == HTTPStatus.OK + assert len(received) == expected_calls + if expected_calls: + assert received[0].remote == remote_ip diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 10cc6fc7d6bf85..cdd38e1a0f9326 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -177,6 +177,24 @@ async def target_entities( switch_platform.config_entry = config_entry await switch_platform.async_add_entities([device1_switch, area_device_switch]) + area_device_diagnostic_sensor = MockEntity( + entity_id="sensor.test7", + unique_id="test7", + device_info=dr.DeviceInfo(identifiers=area_device.identifiers), + entity_category=EntityCategory.DIAGNOSTIC, + ) + label2_device_config_sensor = MockEntity( + entity_id="sensor.potato", + unique_id="potato", + device_info=dr.DeviceInfo(identifiers=label2_device.identifiers), + entity_category=EntityCategory.CONFIG, + ) + sensor_platform = MockEntityPlatform(hass, domain="sensor", platform_name="test") + sensor_platform.config_entry = config_entry + await sensor_platform.async_add_entities( + [area_device_diagnostic_sensor, label2_device_config_sensor] + ) + component1_light = MockEntity( entity_id="light.component1_light", unique_id="component1_light" ) @@ -246,6 +264,8 @@ async def target_entities( "light.test6", "switch.test2", "switch.test5", + "sensor.test7", + "sensor.potato", "light.component1_light", "light.component1_flash_light", "light.component1_effect_flash_light", @@ -3795,7 +3815,11 @@ async def async_get_triggers_conditions(hass: HomeAssistant) -> dict[str, type]: Mock( **{ f"async_get_{automation_component}s": AsyncMock( - return_value={"match_all": Mock, "other_integration_lights": Mock} + return_value={ + "match_all": Mock, + "other_integration_lights": Mock, + "non_primary_sensor": Mock, + } ) } ), @@ -3873,6 +3897,12 @@ def get_common_descriptions(domain: str): - light.LightEntityFeature.EFFECT - integration: test domain: light + + non_primary_sensor: + target: + entity: + domain: sensor + primary_entities_only: false """ def _load_yaml(fname, secrets=None): @@ -3978,6 +4008,7 @@ async def assert_command( "component1", "component1.light_message", "component2.match_all", + "component2.non_primary_sensor", "component2.other_integration_lights", "light.turned_on", "sensor.turned_on", @@ -3990,6 +4021,7 @@ async def assert_command( {"area_id": ["kitchen", "living_room"]}, [ "component2.match_all", + "component2.non_primary_sensor", "component2.other_integration_lights", "light.turned_on", "switch.turned_on", @@ -4003,10 +4035,23 @@ async def assert_command( "light.turned_on", "component1", "component2.match_all", + "component2.non_primary_sensor", "component2.other_integration_lights", "switch.turned_on", ], ) + + # Test direct targeting of a non-primary entity - even + # primary_entities_only=True components match + await assert_command( + {"entity_id": ["sensor.test7"]}, + [ + "component2.match_all", + "component2.non_primary_sensor", + "sensor.turned_on", + ], + ) + # Test mixed target types await assert_command( { @@ -4019,6 +4064,7 @@ async def assert_command( "component1", "component1.light_message", "component2.match_all", + "component2.non_primary_sensor", "component2.other_integration_lights", "light.turned_on", "sensor.turned_on", @@ -4107,6 +4153,12 @@ def get_common_service_descriptions(domain: str): - light.LightEntityFeature.EFFECT - integration: test domain: light + + non_primary_sensor: + target: + entity: + domain: sensor + primary_entities_only: false """ def _load_yaml(fname, secrets=None): @@ -4145,6 +4197,7 @@ def _load_yaml(fname, secrets=None): hass.services.async_register( "component2", "other_integration_lights", lambda call: None ) + hass.services.async_register("component2", "non_primary_sensor", lambda call: None) await hass.async_block_till_done() async def assert_services( @@ -4226,6 +4279,7 @@ async def assert_services( [ "component1.light_message", "component2.match_all", + "component2.non_primary_sensor", "component2.other_integration_lights", "light.turn_on", "sensor.turn_on", @@ -4238,6 +4292,7 @@ async def assert_services( {"area_id": ["kitchen", "living_room"]}, [ "component2.match_all", + "component2.non_primary_sensor", "component2.other_integration_lights", "light.turn_on", "switch.turn_on", @@ -4250,10 +4305,23 @@ async def assert_services( [ "light.turn_on", "component2.match_all", + "component2.non_primary_sensor", "component2.other_integration_lights", "switch.turn_on", ], ) + + # Test direct targeting of a non-primary entity - even + # primary_entities_only=True components match + await assert_services( + {"entity_id": ["sensor.test7"]}, + [ + "component2.match_all", + "component2.non_primary_sensor", + "sensor.turn_on", + ], + ) + # Test mixed target types await assert_services( { @@ -4265,6 +4333,7 @@ async def assert_services( [ "component1.light_message", "component2.match_all", + "component2.non_primary_sensor", "component2.other_integration_lights", "light.turn_on", "sensor.turn_on", @@ -4432,3 +4501,29 @@ async def test_get_automation_component_lookup_table_cache( _get_automation_component_lookup_table(hass, "services", services) is service_result1 ) + + +@pytest.mark.parametrize( + ("side_effect", "expect_success"), + [(Exception("error"), False), (None, True)], +) +async def test_execute_script_unloads_script( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + side_effect: Exception | None, + expect_success: bool, +) -> None: + """Test that execute_script unloads the script after execution.""" + with patch("homeassistant.helpers.script.Script", autospec=True) as script_mock: + script_mock.return_value.async_run.return_value = None + script_mock.return_value.async_run.side_effect = side_effect + await websocket_client.send_json_auto_id( + { + "type": "execute_script", + "sequence": [{"service": "domain_test.test_service"}], + } + ) + msg = await websocket_client.receive_json() + assert msg["success"] == expect_success + + script_mock.return_value.async_unload.assert_called_once() diff --git a/tests/components/websocket_api/test_connection.py b/tests/components/websocket_api/test_connection.py index 343575e5b4a145..bbaf87907d0001 100644 --- a/tests/components/websocket_api/test_connection.py +++ b/tests/components/websocket_api/test_connection.py @@ -12,6 +12,7 @@ from homeassistant.components import websocket_api from homeassistant.components.websocket_api.const import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers.redact import REDACTED from tests.common import MockUser @@ -100,7 +101,12 @@ def get_extra_info(key: str) -> Any | None: ) as current_request: current_request.get.return_value = mocked_request conn = websocket_api.ActiveConnection( - logging.getLogger(__name__), hass, send_messages.append, user, refresh_token + logging.getLogger(__name__), + hass, + send_messages.append, + user, + refresh_token, + remote="127.0.0.42", ) conn.async_handle_exception({"id": 5}, exc) @@ -113,7 +119,7 @@ def get_extra_info(key: str) -> Any | None: async def test_binary_handler_registration() -> None: """Test binary handler registration.""" connection = websocket_api.ActiveConnection( - None, Mock(data={websocket_api.DOMAIN: None}), None, None, Mock() + None, Mock(data={websocket_api.DOMAIN: None}), None, None, Mock(), remote=None ) # One filler to align indexes with prefix numbers @@ -132,3 +138,46 @@ async def test_binary_handler_registration() -> None: # Verify we reuse an unsubscribed prefix prefix, unsub = connection.async_register_binary_handler(None) assert prefix == 15 + + +async def test_credential_redaction( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test credential redaction.""" + send_messages = [] + user = MockUser() + refresh_token = Mock() + hass.data[DOMAIN] = {} + test_input = ["valid detail information", "secretpassword", "api-token-12345"] + + connection = websocket_api.ActiveConnection( + logging.getLogger(__name__), + hass, + send_messages.append, + user, + refresh_token, + remote=None, + ) + + msg = { + "id": 5, + "detail": test_input[0], + "password": test_input[1], + "token": test_input[2], + } + connection.async_handle_exception(msg, vol.Invalid("bad input")) + + assert len(send_messages) == 1 + error_message = send_messages[0]["error"]["message"] + assert test_input[0] in error_message + assert test_input[1] not in error_message + assert test_input[2] not in error_message + assert REDACTED in error_message + + msg = {"type": "auth", "access_token": test_input[2]} + connection.async_handle(msg) + + assert len(send_messages) == 2 + assert send_messages[1]["error"]["message"] == "Message incorrectly formatted." + assert test_input[2] not in caplog.text + assert REDACTED in caplog.text diff --git a/tests/components/window/test_condition.py b/tests/components/window/test_condition.py index 5e64d10b0e632f..9e786b721d67a3 100644 --- a/tests/components/window/test_condition.py +++ b/tests/components/window/test_condition.py @@ -13,6 +13,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -47,6 +48,31 @@ async def test_window_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("window.is_closed", {}, True, True), + ("window.is_open", {}, True, True), + ], +) +async def test_window_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that window conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + # --- binary_sensor tests --- @@ -346,7 +372,7 @@ async def test_window_condition_excludes_non_window_device_class( ) # Matching entities in matching state - condition should be True - assert condition_any(hass) is True + assert condition_any.async_check() is True # Set matching entities to non-matching state hass.states.async_set( @@ -360,4 +386,4 @@ async def test_window_condition_excludes_non_window_device_class( await hass.async_block_till_done() # Wrong device class entities still in matching state, but should be excluded - assert condition_any(hass) is False + assert condition_any.async_check() is False diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 0d6bae972803e0..71f0923e009074 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -31,7 +31,6 @@ import zigpy.types from homeassistant import config_entries -from homeassistant.components.hassio import AddonError, AddonState from homeassistant.components.usb import SerialDevice, USBDevice from homeassistant.components.zha import config_flow, radio_manager from homeassistant.components.zha.const import ( @@ -347,7 +346,7 @@ async def test_zeroconf_discovery( await hass.async_block_till_done() assert result_form["type"] is FlowResultType.CREATE_ENTRY - assert result_form["title"] == entry_name + assert result_form["title"] == "" assert result_form["context"]["unique_id"] == unique_id assert result_form["data"] == { CONF_DEVICE: { @@ -407,7 +406,7 @@ async def test_legacy_zeroconf_discovery_zigate( await hass.async_block_till_done() assert result_form["type"] is FlowResultType.CREATE_ENTRY - assert result_form["title"] == "some name" + assert result_form["title"] == "" assert result_form["data"] == { CONF_DEVICE: { CONF_DEVICE_PATH: "socket://192.168.1.200:1234", @@ -548,7 +547,7 @@ async def test_discovery_via_usb(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "zigbee radio" + assert result3["title"] == "" assert result3["data"] == { "device": { "baudrate": 115200, @@ -1068,21 +1067,16 @@ async def test_zeroconf_not_onboarded(hass: HomeAssistant) -> None: "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", mock_detect_radio_type(radio_type=RadioType.deconz), ) -@patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock(return_value=[usb_port()]), -) async def test_user_flow(hass: HomeAssistant) -> None: """Test user flow -- radio detected.""" port = usb_port() - port_select = f"{port.device} - {port.description}, s/n: {port.serial_number} - {port.manufacturer}" result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data={ - zigpy.config.CONF_DEVICE_PATH: port_select, + zigpy.config.CONF_DEVICE_PATH: port.device, }, ) assert result["type"] is FlowResultType.MENU @@ -1102,7 +1096,7 @@ async def test_user_flow(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"].startswith(port.description) + assert result2["title"] == "" assert result2["data"] == { "device": { "path": port.device, @@ -1117,21 +1111,16 @@ async def test_user_flow(hass: HomeAssistant) -> None: "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", AsyncMock(return_value=ProbeResult.PROBING_FAILED), ) -@patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock(return_value=[usb_port()]), -) async def test_user_flow_not_detected(hass: HomeAssistant) -> None: """Test user flow, radio not detected.""" port = com_port() - port_select = f"{port.device} - {port.description}, s/n: {port.serial_number} - {port.manufacturer}" result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data={ - zigpy.config.CONF_DEVICE_PATH: port_select, + zigpy.config.CONF_DEVICE_PATH: port.device, CONF_BAUDRATE: 115200, CONF_FLOW_CONTROL: None, }, @@ -1141,10 +1130,6 @@ async def test_user_flow_not_detected(hass: HomeAssistant) -> None: assert result["step_id"] == "manual_pick_radio_type" -@patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock(return_value=[usb_port()]), -) async def test_user_flow_show_form(hass: HomeAssistant) -> None: """Test user step form.""" result = await hass.config_entries.flow.async_init( @@ -1156,34 +1141,6 @@ async def test_user_flow_show_form(hass: HomeAssistant) -> None: assert result["step_id"] == "choose_serial_port" -@pytest.mark.usefixtures("addon_not_installed") -@patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock(return_value=[]), -) -async def test_user_flow_show_manual(hass: HomeAssistant) -> None: - """Test user flow manual entry when no comport detected.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "manual_pick_radio_type" - - -async def test_user_flow_manual(hass: HomeAssistant) -> None: - """Test user flow manual entry.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - data={zigpy.config.CONF_DEVICE_PATH: config_flow.CONF_MANUAL_PATH}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "manual_pick_radio_type" - - @pytest.mark.parametrize("radio_type", RadioType.list()) async def test_pick_radio_flow(hass: HomeAssistant, radio_type) -> None: """Test radio picker.""" @@ -1342,7 +1299,7 @@ async def test_hardware_not_onboarded(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result_create["title"] == "Yellow" + assert result_create["title"] == "" assert result_create["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, @@ -1396,7 +1353,7 @@ async def test_hardware_no_flow_strategy(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result_create["title"] == "Yellow" + assert result_create["title"] == "" assert result_create["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, @@ -1451,7 +1408,7 @@ async def test_hardware_flow_strategy_advanced(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result_create["type"] is FlowResultType.CREATE_ENTRY - assert result_create["title"] == "Yellow" + assert result_create["title"] == "" assert result_create["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, @@ -1498,7 +1455,7 @@ async def test_hardware_flow_strategy_recommended(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result_create["type"] is FlowResultType.CREATE_ENTRY - assert result_create["title"] == "Yellow" + assert result_create["title"] == "" assert result_create["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, @@ -1701,7 +1658,6 @@ def advanced_pick_radio(hass: HomeAssistant) -> Generator[RadioPicker]: async def wrapper(radio_type: RadioType) -> ConfigFlowResult: port = com_port() - port_select = f"{port.device} - {port.description}, s/n: {port.serial_number} - {port.manufacturer}" with patch( "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", @@ -1711,7 +1667,7 @@ async def wrapper(radio_type: RadioType) -> ConfigFlowResult: DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data={ - zigpy.config.CONF_DEVICE_PATH: port_select, + zigpy.config.CONF_DEVICE_PATH: port.device, }, ) @@ -1728,13 +1684,7 @@ async def wrapper(radio_type: RadioType) -> ConfigFlowResult: return advanced_strategy_result - p1 = patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock(return_value=[usb_port()]), - ) - p2 = patch("homeassistant.components.zha.async_setup_entry") - - with p1, p2: + with patch("homeassistant.components.zha.async_setup_entry"): yield wrapper @@ -1877,7 +1827,7 @@ async def form_network_side_effect(*args, **kwargs): await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "zigbee radio" + assert result["title"] == "" assert result["data"] == { "device": { "baudrate": 115200, @@ -2317,16 +2267,6 @@ async def test_options_flow_creates_backup( ("none", None), ], ) -@patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock( - return_value=[ - usb_port("/dev/SomePort"), - usb_port("/dev/ttyUSB0"), - usb_port("/dev/SomeOtherPort"), - ] - ), -) @patch("homeassistant.components.zha.async_setup_entry", return_value=True) async def test_options_flow_defaults( async_setup_entry, @@ -2472,15 +2412,6 @@ async def test_options_flow_defaults( assert async_setup_entry.call_count == 1 -@patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock( - return_value=[ - usb_port("/dev/SomePort"), - usb_port("/dev/SomeOtherPort"), - ] - ), -) @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_options_flow_defaults_socket(hass: HomeAssistant) -> None: """Test options flow defaults work even for serial ports that can't be listed.""" @@ -2518,13 +2449,17 @@ async def test_options_flow_defaults_socket(hass: HomeAssistant) -> None: user_input={"next_step_id": config_flow.OptionsMigrationIntent.RECONFIGURE}, ) - # Radio path must be manually entered + # The existing device path is the default assert result2["step_id"] == "choose_serial_port" - assert result2["data_schema"]({})[CONF_DEVICE_PATH] == config_flow.CONF_MANUAL_PATH + assert result2["data_schema"]({})[CONF_DEVICE_PATH] == "socket://localhost:5678" - result3 = await hass.config_entries.options.async_configure( - flow["flow_id"], user_input={} - ) + with patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", + AsyncMock(return_value=ProbeResult.PROBING_FAILED), + ): + result3 = await hass.config_entries.options.async_configure( + flow["flow_id"], user_input={} + ) # Current radio type is the default assert result3["step_id"] == "manual_pick_radio_type" @@ -2556,10 +2491,6 @@ async def test_options_flow_defaults_socket(hass: HomeAssistant) -> None: assert result5["step_id"] == "choose_migration_strategy" -@patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock(return_value=[usb_port()]), -) @patch("homeassistant.components.zha.async_setup_entry", return_value=True) async def test_options_flow_restarts_running_zha_if_cancelled( async_setup_entry, hass: HomeAssistant @@ -2656,10 +2587,6 @@ async def test_options_flow_migration_reset_old_adapter( "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", return_value=ProbeResult.RADIO_TYPE_DETECTED, ), - patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock(return_value=[usb_port("/dev/ttyUSB_new")]), - ), patch( "homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database", return_value=[backup], @@ -2675,9 +2602,7 @@ async def test_options_flow_migration_reset_old_adapter( result_port = await hass.config_entries.options.async_configure( flow["flow_id"], - user_input={ - CONF_DEVICE_PATH: "/dev/ttyUSB_new - Some serial port, s/n: 1234 - Virtual serial port" - }, + user_input={CONF_DEVICE_PATH: "/dev/ttyUSB_new"}, ) assert result_port["step_id"] == "choose_migration_strategy" @@ -2715,10 +2640,6 @@ async def test_options_flow_migration_reset_old_adapter( assert entry.data["device"]["path"] == "/dev/ttyUSB_new" -@patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock(return_value=[usb_port()]), -) @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_options_flow_reconfigure_no_reset( hass: HomeAssistant, backup, mock_app @@ -2728,6 +2649,7 @@ async def test_options_flow_reconfigure_no_reset( entry = MockConfigEntry( version=config_flow.ZhaConfigFlowHandler.VERSION, domain=DOMAIN, + title="My custom Zigbee name", data={ CONF_DEVICE: { CONF_DEVICE_PATH: "/dev/ttyUSB_old", @@ -2761,10 +2683,6 @@ async def test_options_flow_reconfigure_no_reset( "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", return_value=ProbeResult.RADIO_TYPE_DETECTED, ), - patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock(return_value=[usb_port("/dev/ttyUSB_new")]), - ), patch( "homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database", return_value=[backup], @@ -2780,9 +2698,7 @@ async def test_options_flow_reconfigure_no_reset( result_port = await hass.config_entries.options.async_configure( flow["flow_id"], - user_input={ - CONF_DEVICE_PATH: "/dev/ttyUSB_new - Some serial port, s/n: 1234 - Virtual serial port" - }, + user_input={CONF_DEVICE_PATH: "/dev/ttyUSB_new"}, ) assert result_port["step_id"] == "choose_migration_strategy" @@ -2812,196 +2728,10 @@ async def test_options_flow_reconfigure_no_reset( # The entry is updated assert entry.data["device"]["path"] == "/dev/ttyUSB_new" + # The user-customized title is preserved across reconfigure + assert entry.title == "My custom Zigbee name" -@pytest.mark.parametrize( - "device", - [ - "/dev/ttyAMA1", # CM4 - "/dev/ttyAMA10", # CM5, erroneously detected by pyserial - ], -) -async def test_config_flow_port_yellow_port_name( - hass: HomeAssistant, device: str -) -> None: - """Test config flow serial port name for Yellow Zigbee radio.""" - # Create a USB device with the parametrized device path - port = USBDevice( - device=device, - vid="10C4", - pid="EA60", - serial_number=None, - manufacturer=None, - description=None, - ) - - with ( - patch("homeassistant.components.zha.config_flow.yellow_hardware.async_info"), - patch( - "homeassistant.components.zha.config_flow.async_scan_serial_ports", - return_value=[port], - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - ) - - # list_serial_ports replaces all /dev/ttyAMA* with the Yellow port at /dev/ttyAMA1 - assert ( - result["data_schema"].schema["path"].container[0] - == "/dev/ttyAMA1 - Yellow Zigbee module - Nabu Casa" - ) - - -async def test_config_flow_ports_no_hassio(hass: HomeAssistant) -> None: - """Test config flow serial port name when this is not a hassio install.""" - - with ( - patch("homeassistant.components.zha.config_flow.is_hassio", return_value=False), - patch( - "homeassistant.components.zha.config_flow.async_scan_serial_ports", - return_value=[], - ), - ): - ports = await config_flow.list_serial_ports(hass) - - assert ports == [] - - -async def test_config_flow_port_multiprotocol_port_name(hass: HomeAssistant) -> None: - """Test config flow serial port name for multiprotocol add-on.""" - - with ( - patch("homeassistant.components.zha.config_flow.is_hassio", return_value=True), - patch( - "homeassistant.components.hassio.addon_manager.AddonManager.async_get_addon_info" - ) as async_get_addon_info, - patch( - "homeassistant.components.zha.config_flow.async_scan_serial_ports", - return_value=[], - ), - ): - async_get_addon_info.return_value.state = AddonState.RUNNING - async_get_addon_info.return_value.hostname = "core-silabs-multiprotocol" - ports = await config_flow.list_serial_ports(hass) - - assert len(ports) == 1 - assert ports[0].description == "Silicon Labs Multiprotocol add-on" - assert ports[0].manufacturer == "Nabu Casa" - assert ports[0].device == "socket://core-silabs-multiprotocol:9999" - - -async def test_config_flow_port_no_multiprotocol(hass: HomeAssistant) -> None: - """Test config flow serial port listing when addon info fails to load.""" - - with ( - patch("homeassistant.components.zha.config_flow.is_hassio", return_value=True), - patch( - "homeassistant.components.hassio.addon_manager.AddonManager.async_get_addon_info", - new_callable=DelayedAsyncMock, - side_effect=AddonError, - ), - patch( - "homeassistant.components.zha.config_flow.async_scan_serial_ports", - return_value=[], - ), - ): - ports = await config_flow.list_serial_ports(hass) - - assert ports == [] - - -async def test_list_serial_ports_ignored_devices(hass: HomeAssistant) -> None: - """Test that list_serial_ports filters out ignored non-Zigbee devices.""" - mock_ports = [ - USBDevice( - device="/dev/ttyUSB0", - vid="303A", - pid="4001", - serial_number="1234", - manufacturer="Nabu Casa", - description="ZWA-2", - ), - USBDevice( - device="/dev/ttyUSB1", - vid="303A", - pid="4001", - serial_number="1235", - manufacturer="Nabu Casa", - description="ZBT-2", - ), - USBDevice( - device="/dev/ttyUSB2", - vid="10C4", - pid="EA60", - serial_number="1236", - manufacturer="Nabu Casa", - description="Home Assistant Connect ZBT-1", - ), - USBDevice( - device="/dev/ttyUSB3", - vid="10C4", - pid="EA60", - serial_number="1237", - manufacturer="Nabu Casa", - description="SkyConnect v1.0", - ), - USBDevice( - device="/dev/ttyUSB4", - vid="1234", - pid="5678", - serial_number="1238", - manufacturer="Another Manufacturer", - description="Zigbee USB Adapter", - ), - USBDevice( - device="/dev/ttyUSB5", - vid="1234", - pid="5678", - serial_number=None, - manufacturer=None, - description=None, - ), - ] - - with ( - patch("homeassistant.components.zha.config_flow.is_hassio", return_value=False), - patch( - "homeassistant.components.zha.config_flow.async_scan_serial_ports", - return_value=mock_ports, - ), - ): - ports = await config_flow.list_serial_ports(hass) - - # ZWA-2 should be filtered out, others should remain - assert len(ports) == 5 - - assert ports[0].device == "/dev/ttyUSB1" - assert ports[0].manufacturer == "Nabu Casa" - assert ports[0].description == "ZBT-2" - - assert ports[1].device == "/dev/ttyUSB2" - assert ports[1].manufacturer == "Nabu Casa" - assert ports[1].description == "Home Assistant Connect ZBT-1" - - assert ports[2].device == "/dev/ttyUSB3" - assert ports[2].manufacturer == "Nabu Casa" - assert ports[2].description == "SkyConnect v1.0" - - assert ports[3].device == "/dev/ttyUSB4" - assert ports[3].manufacturer == "Another Manufacturer" - assert ports[3].description == "Zigbee USB Adapter" - - assert ports[4].device == "/dev/ttyUSB5" - assert ports[4].manufacturer is None - assert ports[4].description is None - - -@patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock(return_value=[usb_port()]), -) async def test_probe_wrong_firmware_installed(hass: HomeAssistant) -> None: """Test auto-probing failing because the wrong firmware is installed.""" @@ -3607,7 +3337,7 @@ async def remove_entry_during_reset(): # Since config entry was removed, flow skipped to maybe_confirm_ezsp_restore # and restored backup, creating a new entry in the end assert result_recommended["type"] is FlowResultType.CREATE_ENTRY - assert result_recommended["title"] == "zigbee radio" + assert result_recommended["title"] == "" assert result_recommended["data"] == { "device": { "baudrate": 115200, diff --git a/tests/components/zha/test_dynamic_entities.py b/tests/components/zha/test_dynamic_entities.py new file mode 100644 index 00000000000000..e04b39208241e7 --- /dev/null +++ b/tests/components/zha/test_dynamic_entities.py @@ -0,0 +1,259 @@ +"""Test ZHA dynamic entity lifecycle (runtime add/remove and reference cleanup).""" + +from collections.abc import Callable, Coroutine, Generator +from unittest.mock import patch + +import pytest +from zha.application import Platform as ZhaPlatform +from zha.application.platforms import PlatformEntity +from zha.zigbee.device import DeviceEntityAddedEvent, DeviceEntityRemovedEvent +from zigpy.device import Device +from zigpy.profiles import zha +from zigpy.zcl.clusters import general + +from homeassistant.components.zha.helpers import ( + ZHADeviceProxy, + get_zha_data, + get_zha_gateway, + get_zha_gateway_proxy, +) +from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import find_entity_id +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + + +@pytest.fixture(autouse=True) +def platforms_only() -> Generator[None]: + """Only set up the switch + binary_sensor platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + (Platform.SWITCH, Platform.BINARY_SENSOR), + ): + yield + + +async def _create_device( + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], +) -> ZHADeviceProxy: + """Create a device exposing a switch and a binary_sensor that share a unique_id. + + OnOff input on an occupancy-sensor device type yields a switch entity + and a binary_sensor entity (Opening) keyed on the same `(ieee, ep, 6)` + base unique_id but on different platforms - a useful collision shape + for verifying that runtime add/remove events are scoped per platform. + """ + await setup_zha() + gateway = get_zha_gateway(hass) + gateway_proxy = get_zha_gateway_proxy(hass) + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + general.OnOff.cluster_id, + general.Groups.cluster_id, + ], + SIG_EP_OUTPUT: [general.OnOff.cluster_id], + SIG_EP_TYPE: zha.DeviceType.OCCUPANCY_SENSOR, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ieee="01:2d:6f:00:0a:90:69:e8", + node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00", + ) + + gateway.get_or_create_device(zigpy_device) + await gateway.async_device_initialized(zigpy_device) + await hass.async_block_till_done(wait_background_tasks=True) + + zha_device_proxy = gateway_proxy.get_device_proxy(zigpy_device.ieee) + assert zha_device_proxy is not None + return zha_device_proxy + + +def _get_platform_entity( + zha_device_proxy: ZHADeviceProxy, platform: Platform +) -> PlatformEntity: + """Return the underlying ZHA platform entity for the given platform.""" + return next( + entity + for entity in zha_device_proxy.device.platform_entities.values() + if platform == entity.PLATFORM + ) + + +async def test_dynamic_entity_lifecycle( + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], +) -> None: + """Test the full hard-remove -> re-add -> soft-remove cycle. + + Also confirms that the binary_sensor sharing the same unique_id but on a + different platform is never disturbed - the (platform, unique_id) tuple + is the ZHA entity identity. + """ + zha_device_proxy = await _create_device(hass, setup_zha, zigpy_device_mock) + platform_entity = _get_platform_entity(zha_device_proxy, Platform.SWITCH) + binary_sensor_entity = _get_platform_entity( + zha_device_proxy, Platform.BINARY_SENSOR + ) + entity_id = find_entity_id(Platform.SWITCH, zha_device_proxy, hass) + binary_sensor_id = find_entity_id(Platform.BINARY_SENSOR, zha_device_proxy, hass) + assert entity_id is not None + assert binary_sensor_id is not None + + # Same unique_id, different platforms - the collision shape we care about. + assert platform_entity.unique_id == binary_sensor_entity.unique_id + assert platform_entity.PLATFORM != binary_sensor_entity.PLATFORM + + switch_state_before = hass.states.get(entity_id) + binary_sensor_state_before = hass.states.get(binary_sensor_id) + assert switch_state_before is not None + assert switch_state_before.state == STATE_OFF + assert binary_sensor_state_before is not None + assert binary_sensor_state_before.state == STATE_OFF + + # Hard remove: state and registry entry both go away. + zha_device_proxy.device.emit( + DeviceEntityRemovedEvent.event_type, + DeviceEntityRemovedEvent( + platform=ZhaPlatform.SWITCH, + unique_id=platform_entity.unique_id, + remove=True, + ), + ) + await hass.async_block_till_done() + registry = er.async_get(hass) + assert registry.async_get(entity_id) is None + assert hass.states.get(entity_id) is None + # The binary_sensor with the same unique_id is untouched. + assert registry.async_get(binary_sensor_id) is not None + assert hass.states.get(binary_sensor_id) == binary_sensor_state_before + + ha_zha_data = get_zha_data(hass) + assert len(ha_zha_data.platforms[Platform.SWITCH]) == 0 + + # Re-add: emitter exercises the on_all_events -> _handle_event_protocol + # -> handler wiring; the entity reappears as a HA state. + zha_device_proxy.device.emit( + DeviceEntityAddedEvent.event_type, + DeviceEntityAddedEvent( + platform=ZhaPlatform.SWITCH, + unique_id=platform_entity.unique_id, + ), + ) + await hass.async_block_till_done() + # The platform listener drained the queue when SIGNAL_ADD_ENTITIES fired. + assert len(ha_zha_data.platforms[Platform.SWITCH]) == 0 + switch_state_after = hass.states.get(entity_id) + assert switch_state_after is not None + assert switch_state_after.state == switch_state_before.state + assert hass.states.get(binary_sensor_id) == binary_sensor_state_before + + # Exactly one entity reference is tracked; the remove + re-add cycle did + # not leave a stale entry behind. + gateway_proxy = get_zha_gateway_proxy(hass) + matching_refs = [ + ref + for ref in gateway_proxy.ha_entity_refs[zha_device_proxy.device.ieee] + if ref.ha_entity_id == entity_id + ] + assert len(matching_refs) == 1 + + # Soft remove: registry entry is kept, state goes unavailable. + entry = registry.async_get(entity_id) + assert entry is not None + zha_device_proxy.device.emit( + DeviceEntityRemovedEvent.event_type, + DeviceEntityRemovedEvent( + platform=ZhaPlatform.SWITCH, + unique_id=entry.unique_id, + remove=False, + ), + ) + await hass.async_block_till_done() + assert registry.async_get(entity_id) is not None + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE + # The binary_sensor with the same unique_id is still untouched. + assert hass.states.get(binary_sensor_id) == binary_sensor_state_before + + +async def test_unknown_unique_id_is_noop( + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], +) -> None: + """Test that add/remove events with an unknown unique_id are no-ops.""" + zha_device_proxy = await _create_device(hass, setup_zha, zigpy_device_mock) + + entity_id = find_entity_id(Platform.SWITCH, zha_device_proxy, hass) + assert entity_id is not None + + ha_zha_data = get_zha_data(hass) + registry = er.async_get(hass) + + # Added: nothing queued, no dispatcher signal fired. + with patch( + "homeassistant.components.zha.helpers.async_dispatcher_send" + ) as mock_dispatch: + zha_device_proxy.device.emit( + DeviceEntityAddedEvent.event_type, + DeviceEntityAddedEvent( + platform=ZhaPlatform.SWITCH, + unique_id="nonexistent_unique_id", + ), + ) + await hass.async_block_till_done() + + assert len(ha_zha_data.platforms[Platform.SWITCH]) == 0 + mock_dispatch.assert_not_called() + + # Removed (both flag values): the existing entity is untouched. + for remove in (False, True): + zha_device_proxy.device.emit( + DeviceEntityRemovedEvent.event_type, + DeviceEntityRemovedEvent( + platform=ZhaPlatform.SWITCH, + unique_id="nonexistent_unique_id", + remove=remove, + ), + ) + await hass.async_block_till_done() + + assert registry.async_get(entity_id) is not None + assert hass.states.get(entity_id) is not None + + +async def test_remove_entity_reference_when_ieee_already_cleared( + hass: HomeAssistant, + setup_zha: Callable[..., Coroutine[None]], + zigpy_device_mock: Callable[..., Device], +) -> None: + """Test entity teardown when _ha_entity_refs already lost the ieee. + + Simulates the race where ``handle_device_removed`` pops the ieee entry + before the entity finishes tearing down. The early-return guard must + keep the popped key out of the dict. + """ + zha_device_proxy = await _create_device(hass, setup_zha, zigpy_device_mock) + + entity_id = find_entity_id(Platform.SWITCH, zha_device_proxy, hass) + assert entity_id is not None + + gateway_proxy = get_zha_gateway_proxy(hass) + ieee = zha_device_proxy.device.ieee + gateway_proxy._ha_entity_refs.pop(ieee, None) + + er.async_get(hass).async_remove(entity_id) + await hass.async_block_till_done() + + assert ieee not in gateway_proxy._ha_entity_refs diff --git a/tests/components/zinvolt/snapshots/test_select.ambr b/tests/components/zinvolt/snapshots/test_select.ambr new file mode 100644 index 00000000000000..941a346b4f7e61 --- /dev/null +++ b/tests/components/zinvolt/snapshots/test_select.ambr @@ -0,0 +1,68 @@ +# serializer version: 1 +# name: test_all_entities[select.zinvolt_batterij_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'dynamic', + 'self_use', + 'fast_discharge', + 'charged', + 'idle', + 'fast_charge', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.zinvolt_batterij_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mode', + 'platform': 'zinvolt', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_mode', + 'unique_id': 'ZVG011025120088.mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.zinvolt_batterij_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Zinvolt Batterij Mode', + 'options': list([ + 'dynamic', + 'self_use', + 'fast_discharge', + 'charged', + 'idle', + 'fast_charge', + ]), + }), + 'context': , + 'entity_id': 'select.zinvolt_batterij_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charged', + }) +# --- diff --git a/tests/components/zinvolt/test_select.py b/tests/components/zinvolt/test_select.py new file mode 100644 index 00000000000000..6d7d095d27cfec --- /dev/null +++ b/tests/components/zinvolt/test_select.py @@ -0,0 +1,61 @@ +"""Tests for the Zinvolt select.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion +from zinvolt.models import SmartMode + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_zinvolt_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.zinvolt._PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_option( + hass: HomeAssistant, + mock_zinvolt_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set option.""" + await setup_integration(hass, mock_config_entry) + + mock_zinvolt_client.get_battery_status.return_value.smart_mode = ( + SmartMode.PERFORMANCE + ) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.zinvolt_batterij_mode", + ATTR_OPTION: "fast_discharge", + }, + blocking=True, + ) + + mock_zinvolt_client.set_smart_mode.assert_called_once_with( + "a125ef17-6bdf-45ad-b106-ce54e95e4634", SmartMode.PERFORMANCE + ) + assert hass.states.get("select.zinvolt_batterij_mode").state == "fast_discharge" diff --git a/tests/components/zone/test_condition.py b/tests/components/zone/test_condition.py index dae76186702ed3..391593019b33d1 100644 --- a/tests/components/zone/test_condition.py +++ b/tests/components/zone/test_condition.py @@ -22,7 +22,7 @@ async def test_zone_raises(hass: HomeAssistant) -> None: zone_condition.zone(hass, zone_ent=None, entity="sensor.any") with pytest.raises(ConditionError, match="unknown zone"): - test(hass) + test.async_check() hass.states.async_set( "zone.home", @@ -34,7 +34,7 @@ async def test_zone_raises(hass: HomeAssistant) -> None: zone_condition.zone(hass, zone_ent="zone.home", entity=None) with pytest.raises(ConditionError, match="unknown entity"): - test(hass) + test.async_check() hass.states.async_set( "device_tracker.cat", @@ -43,7 +43,7 @@ async def test_zone_raises(hass: HomeAssistant) -> None: ) with pytest.raises(ConditionError, match="latitude"): - test(hass) + test.async_check() hass.states.async_set( "device_tracker.cat", @@ -52,7 +52,7 @@ async def test_zone_raises(hass: HomeAssistant) -> None: ) with pytest.raises(ConditionError, match="longitude"): - test(hass) + test.async_check() hass.states.async_set( "device_tracker.cat", @@ -61,7 +61,7 @@ async def test_zone_raises(hass: HomeAssistant) -> None: ) # All okay, now test multiple failed conditions - assert test(hass) + assert test.async_check() config = { "condition": "zone", @@ -75,10 +75,10 @@ async def test_zone_raises(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) with pytest.raises(ConditionError, match="dog"): - test(hass) + test.async_check() with pytest.raises(ConditionError, match="work"): - test(hass) + test.async_check() hass.states.async_set( "zone.work", @@ -92,7 +92,7 @@ async def test_zone_raises(hass: HomeAssistant) -> None: {"friendly_name": "dog", "latitude": 20.1, "longitude": 10.1}, ) - assert test(hass) + assert test.async_check() async def test_zone_multiple_entities(hass: HomeAssistant) -> None: @@ -130,7 +130,7 @@ async def test_zone_multiple_entities(hass: HomeAssistant) -> None: "home", {"friendly_name": "person_2", "latitude": 2.1, "longitude": 1.1}, ) - assert test(hass) + assert test.async_check() hass.states.async_set( "device_tracker.person_1", @@ -142,7 +142,7 @@ async def test_zone_multiple_entities(hass: HomeAssistant) -> None: "home", {"friendly_name": "person_2", "latitude": 2.1, "longitude": 1.1}, ) - assert not test(hass) + assert not test.async_check() hass.states.async_set( "device_tracker.person_1", @@ -154,7 +154,7 @@ async def test_zone_multiple_entities(hass: HomeAssistant) -> None: "home", {"friendly_name": "person_2", "latitude": 20.1, "longitude": 10.1}, ) - assert not test(hass) + assert not test.async_check() async def test_multiple_zones(hass: HomeAssistant) -> None: @@ -191,18 +191,18 @@ async def test_multiple_zones(hass: HomeAssistant) -> None: "home", {"friendly_name": "person", "latitude": 2.1, "longitude": 1.1}, ) - assert test(hass) + assert test.async_check() hass.states.async_set( "device_tracker.person", "home", {"friendly_name": "person", "latitude": 20.1, "longitude": 10.1}, ) - assert test(hass) + assert test.async_check() hass.states.async_set( "device_tracker.person", "home", {"friendly_name": "person", "latitude": 50.1, "longitude": 20.1}, ) - assert not test(hass) + assert not test.async_check() diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index d2b6cbeaeeb322..084c965e38ad6d 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -598,6 +598,17 @@ def hoppe_ehandle_connectsense_state_fixture() -> NodeDataType: ) +@pytest.fixture(name="fibaro_fgms001_v2_8_state") +def fibaro_fgms001_v2_8_state_fixture() -> NodeDataType: + """Load node state fixture data for Fibaro FGMS001 on firmware 2.8.""" + return cast( + NodeDataType, + # Note: this fixture was created from a simulated device. + # If necessary, replace it with one created from a real FGMS001 + load_json_object_fixture("fibaro_fgms001_v2_8_state.json", DOMAIN), + ) + + # model fixtures @@ -1492,3 +1503,13 @@ def hoppe_ehandle_connectsense_fixture( node = Node(client, hoppe_ehandle_connectsense_state) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="fibaro_fgms001_v2_8") +def fibaro_fgms001_v2_8_fixture( + client: MagicMock, fibaro_fgms001_v2_8_state: NodeDataType +) -> Node: + """Load node for Fibaro FGMS001 on firmware 2.8.""" + node = Node(client, fibaro_fgms001_v2_8_state) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/fibaro_fgms001_v2_8_state.json b/tests/components/zwave_js/fixtures/fibaro_fgms001_v2_8_state.json new file mode 100644 index 00000000000000..e2b98d2eb7d934 --- /dev/null +++ b/tests/components/zwave_js/fixtures/fibaro_fgms001_v2_8_state.json @@ -0,0 +1,198 @@ +{ + "nodeId": 2, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 271, + "productId": 4097, + "productType": 2048, + "firmwareVersion": "2.8", + "deviceConfig": { + "filename": "/data/db/devices/0x010f/fgms001.json", + "manufacturerId": 271, + "manufacturer": "Fibargroup", + "label": "FGMS001", + "description": "Motion Sensor", + "devices": [ + { + "productType": 2048, + "productId": 4097 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "paramInformation": { + "_map": {} + } + }, + "label": "FGMS001", + "interviewAttempts": 1, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 9600, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x010f:0x0800:0x1001:2.8", + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false, + "protocol": 0, + "values": [ + { + "endpoint": 0, + "commandClass": 48, + "commandClassName": "Binary Sensor", + "property": "Any", + "propertyName": "Any", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Sensor state (Any)", + "ccSpecific": { + "sensorType": 255 + }, + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 4097 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 2048 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 271 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 1, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["2.8"] + } + ], + "endpoints": [ + { + "nodeId": 2, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + } + }, + "commandClasses": [ + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 48, + "name": "Binary Sensor", + "version": 1, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 97f94341b6470c..f5ec08d569401e 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -7,6 +7,10 @@ from zwave_js_server.event import Event from zwave_js_server.model.node import Node +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode from homeassistant.components.number import ( @@ -28,8 +32,10 @@ from homeassistant.components.zwave_js.discovery_data_template import ( DynamicCurrentTempClimateDataTemplate, ) +from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, STATE_OFF, STATE_UNKNOWN, @@ -658,3 +664,47 @@ async def test_nabu_casa_zwa2_legacy( assert state.attributes["friendly_name"] == "Home Assistant Connect ZWA-2 LED", ( "The LED should have the correct friendly name" ) + + +@pytest.mark.parametrize("platforms", [[Platform.BINARY_SENSOR, Platform.LIGHT]]) +async def test_fibaro_fgms001_v2_8_motion_discovery( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, + fibaro_fgms001_v2_8: Node, + integration: MockConfigEntry, +) -> None: + """Test the Fibaro FGMS001 on firmware 2.8 is discovered as a motion sensor. + + The device exposes its motion state via the Sensor Binary CC under the + "Any" sensor type. Without the device-specific discovery override the + value would either fall through to the disabled legacy boolean schema + or be misclassified, so we assert that exactly one binary_sensor entity + with device_class=motion is created and no light entity exists. + """ + device = device_registry.async_get_device( + identifiers={get_device_id(client.driver, fibaro_fgms001_v2_8)} + ) + assert device is not None + + entries = er.async_entries_for_device( + entity_registry, device.id, include_disabled_entities=True + ) + + motion_entries = [ + entry + for entry in entries + if entry.domain == BINARY_SENSOR_DOMAIN + and entry.original_device_class == BinarySensorDeviceClass.MOTION + ] + assert len(motion_entries) == 1 + motion_entry = motion_entries[0] + assert motion_entry.disabled_by is None + + state = hass.states.get(motion_entry.entity_id) + assert state is not None + assert state.state == STATE_OFF + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.MOTION + + assert not [entry for entry in entries if entry.domain == Platform.LIGHT] diff --git a/tests/hassfest/test_dependencies.py b/tests/hassfest/test_dependencies.py index 26ed8a01ba87cf..46a1443a301127 100644 --- a/tests/hassfest/test_dependencies.py +++ b/tests/hassfest/test_dependencies.py @@ -4,7 +4,14 @@ import pytest -from script.hassfest.dependencies import ImportCollector +from script.hassfest.dependencies import ( + CORE_INTEGRATIONS, + ImportCollector, + _validate_dependencies, +) +from script.hassfest.model import Config + +from . import get_integration @pytest.fixture @@ -90,3 +97,48 @@ def test_all_imports(mock_collector) -> None: "child_import_field", "renamed_absolute", } + + +def test_dependency_on_core_integration_rejected(config: Config) -> None: + """Test that depending on a core integration is rejected.""" + consumer = get_integration("consumer", config) + consumer.manifest["dependencies"] = ["persistent_notification"] + + integrations = { + "consumer": consumer, + "persistent_notification": get_integration("persistent_notification", config), + } + + _validate_dependencies(integrations) + + assert len(consumer.errors) == 1 + assert ( + "Dependency persistent_notification is a core integration" + in consumer.errors[0].error + ) + + +def test_dependency_on_non_core_integration_allowed(config: Config) -> None: + """Test that depending on a non-core integration is not rejected.""" + consumer = get_integration("consumer", config) + consumer.manifest["dependencies"] = ["other"] + + integrations = { + "consumer": consumer, + "other": get_integration("other", config), + } + + _validate_dependencies(integrations) + + assert consumer.errors == [] + + +def test_core_integrations_in_sync_with_bootstrap() -> None: + """Test the duplicated CORE_INTEGRATIONS stays aligned with bootstrap.""" + # Imported here so the rest of the hassfest tests are not slowed down + # by bootstrap's eager component pre-imports. + from homeassistant.bootstrap import ( # noqa: PLC0415 + CORE_INTEGRATIONS as bootstrap_core_integrations, + ) + + assert bootstrap_core_integrations == CORE_INTEGRATIONS diff --git a/tests/hassfest/test_requirements.py b/tests/hassfest/test_requirements.py index ac95ce1e4d123b..c91dddfbd94aff 100644 --- a/tests/hassfest/test_requirements.py +++ b/tests/hassfest/test_requirements.py @@ -276,6 +276,7 @@ def test_check_dependency_file_names(integration: Integration) -> None: PackagePath("py.typed"), PackagePath("my_package.py"), PackagePath("some_script.Pth"), + PackagePath("entry_point.start"), PackagePath("my_package-1.0.0.dist-info/METADATA"), ] with ( @@ -289,20 +290,25 @@ def test_check_dependency_file_names(integration: Integration) -> None: assert _packages_checked_files_cache[pkg]["file_names"] == { "py.typed", "some_script.Pth", + "entry_point.start", } - assert len(integration.errors) == 2 + assert len(integration.errors) == 3 assert f"Package {pkg} has a forbidden file 'py.typed' in {package}" in [ x.error for x in integration.errors ] assert f"Package {pkg} has a forbidden file 'some_script.Pth' in {package}" in [ x.error for x in integration.errors ] + assert ( + f"Package {pkg} has a forbidden file 'entry_point.start' in {package}" + in [x.error for x in integration.errors] + ) integration.errors.clear() # Repeated call should use cache assert check_dependency_files(integration, package, pkg, ()) is False assert mock_files.call_count == 1 - assert len(integration.errors) == 2 + assert len(integration.errors) == 3 integration.errors.clear() # All good diff --git a/tests/helpers/template/extensions/test_state.py b/tests/helpers/template/extensions/test_state.py new file mode 100644 index 00000000000000..4ea707a5a145df --- /dev/null +++ b/tests/helpers/template/extensions/test_state.py @@ -0,0 +1,1291 @@ +"""Test state functions for Home Assistant templates.""" + +from __future__ import annotations + +from collections.abc import Iterable +from unittest.mock import patch + +import pytest + +from homeassistant.components import group +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, + UnitOfArea, + UnitOfLength, + UnitOfMass, + UnitOfPrecipitationDepth, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import entity_registry as er, template, translation +from homeassistant.helpers.template.render_info import ( + ALL_STATES_RATE_LIMIT, + DOMAIN_STATES_RATE_LIMIT, +) +from homeassistant.setup import async_setup_component +from homeassistant.util.unit_system import UnitSystem + +from tests.common import MockConfigEntry +from tests.helpers.template.helpers import assert_result_info, render, render_to_info + + +def _set_up_units(hass: HomeAssistant) -> None: + """Set up the tests.""" + hass.config.units = UnitSystem( + "custom", + accumulated_precipitation=UnitOfPrecipitationDepth.MILLIMETERS, + area=UnitOfArea.SQUARE_METERS, + conversions={}, + length=UnitOfLength.METERS, + mass=UnitOfMass.GRAMS, + pressure=UnitOfPressure.PA, + temperature=UnitOfTemperature.CELSIUS, + volume=UnitOfVolume.LITERS, + wind_speed=UnitOfSpeed.KILOMETERS_PER_HOUR, + ) + + +def test_referring_states_by_entity_id(hass: HomeAssistant) -> None: + """Test referring states by entity id.""" + hass.states.async_set("test.object", "happy") + assert render(hass, "{{ states.test.object.state }}") == "happy" + + assert render(hass, '{{ states["test.object"].state }}') == "happy" + + assert render(hass, '{{ states("test.object") }}') == "happy" + + +def test_iterating_all_states(hass: HomeAssistant) -> None: + """Test iterating all states.""" + tmpl_str = "{% for state in states | sort(attribute='entity_id') %}{{ state.state }}{% endfor %}" + + info = render_to_info(hass, tmpl_str) + assert_result_info(info, "", all_states=True) + assert info.rate_limit == ALL_STATES_RATE_LIMIT + + hass.states.async_set("test.object", "happy") + hass.states.async_set("sensor.temperature", 10) + + info = render_to_info(hass, tmpl_str) + assert_result_info(info, "10happy", entities=[], all_states=True) + + +def test_iterating_all_states_unavailable(hass: HomeAssistant) -> None: + """Test iterating all states unavailable.""" + hass.states.async_set("test.object", "on") + + tmpl_str = ( + "{{" + " states" + " | selectattr('state', 'in', ['unavailable', 'unknown', 'none'])" + " | list" + " | count" + "}}" + ) + + info = render_to_info(hass, tmpl_str) + + assert info.all_states is True + assert info.rate_limit == ALL_STATES_RATE_LIMIT + + hass.states.async_set("test.object", "unknown") + hass.states.async_set("sensor.temperature", 10) + + info = render_to_info(hass, tmpl_str) + assert_result_info(info, 1, entities=[], all_states=True) + + +def test_iterating_domain_states(hass: HomeAssistant) -> None: + """Test iterating domain states.""" + tmpl_str = "{% for state in states.sensor %}{{ state.state }}{% endfor %}" + + info = render_to_info(hass, tmpl_str) + assert_result_info(info, "", domains=["sensor"]) + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT + + hass.states.async_set("test.object", "happy") + hass.states.async_set("sensor.back_door", "open") + hass.states.async_set("sensor.temperature", 10) + + info = render_to_info(hass, tmpl_str) + assert_result_info( + info, + "open10", + entities=[], + domains=["sensor"], + ) + + +def test_if_state_exists(hass: HomeAssistant) -> None: + """Test if state exists works.""" + hass.states.async_set("test.object", "available") + + result = render( + hass, "{% if states.test.object %}exists{% else %}not exists{% endif %}" + ) + assert result == "exists" + + +def test_is_state(hass: HomeAssistant) -> None: + """Test is_state method.""" + hass.states.async_set("test.object", "available") + + result = render( + hass, '{% if is_state("test.object", "available") %}yes{% else %}no{% endif %}' + ) + assert result == "yes" + + result = render(hass, """{{ is_state("test.noobject", "available") }}""") + assert result is False + + result = render( + hass, + '{% if "test.object" is is_state("available") %}yes{% else %}no{% endif %}', + ) + assert result == "yes" + + result = render( + hass, + """{{ ['test.object'] | select("is_state", "available") | first | default }}""", + ) + assert result == "test.object" + + result = render(hass, '{{ is_state("test.object", ["on", "off", "available"]) }}') + assert result is True + + +def test_is_state_attr(hass: HomeAssistant) -> None: + """Test is_state_attr method.""" + hass.states.async_set("test.object", "available", {"mode": "on", "exists": None}) + + result = render( + hass, + """{% if is_state_attr("test.object", "mode", "on") %}yes{% else %}no{% endif %}""", + ) + assert result == "yes" + + result = render(hass, """{{ is_state_attr("test.noobject", "mode", "on") }}""") + assert result is False + + result = render( + hass, + """{% if "test.object" is is_state_attr("mode", "on") %}yes{% else %}no{% endif %}""", + ) + assert result == "yes" + + result = render( + hass, + """{{ ['test.object'] | select("is_state_attr", "mode", "on") | first | default }}""", + ) + assert result == "test.object" + + result = render( + hass, + """{% if is_state_attr("test.object", "exists", None) %}yes{% else %}no{% endif %}""", + ) + assert result == "yes" + + result = render( + hass, + """{% if is_state_attr("test.object", "noexist", None) %}yes{% else %}no{% endif %}""", + ) + assert result == "no" + + +def test_state_attr(hass: HomeAssistant) -> None: + """Test state_attr method.""" + hass.states.async_set( + "test.object", "available", {"effect": "action", "mode": "on"} + ) + + result = render( + hass, + """{% if state_attr("test.object", "mode") == "on" %}yes{% else %}no{% endif %}""", + ) + assert result == "yes" + + result = render(hass, """{{ state_attr("test.noobject", "mode") == None }}""") + assert result is True + + result = render( + hass, + """{% if "test.object" | state_attr("mode") == "on" %}yes{% else %}no{% endif %}""", + ) + assert result == "yes" + + result = render( + hass, + """{{ ['test.object'] | map("state_attr", "effect") | first | default }}""", + ) + assert result == "action" + + +def test_states_function(hass: HomeAssistant) -> None: + """Test using states as a function.""" + hass.states.async_set("test.object", "available") + + result = render(hass, '{{ states("test.object") }}') + assert result == "available" + + result = render(hass, '{{ states("test.object2") }}') + assert result == "unknown" + + result = render( + hass, + """{% if "test.object" | states == "available" %}yes{% else %}no{% endif %}""", + ) + assert result == "yes" + + result = render(hass, """{{ ['test.object'] | map("states") | first | default }}""") + assert result == "available" + + +async def test_state_translated( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test state_translated method.""" + assert await async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "group", + "name": "Grouped", + "entities": ["binary_sensor.first", "binary_sensor.second"], + } + }, + ) + await hass.async_block_till_done() + await translation._async_get_translations_cache(hass).async_load("en", set()) + + hass.states.async_set("switch.without_translations", "on", attributes={}) + hass.states.async_set("binary_sensor.without_device_class", "on", attributes={}) + hass.states.async_set( + "binary_sensor.with_device_class", "on", attributes={"device_class": "motion"} + ) + hass.states.async_set( + "binary_sensor.with_unknown_device_class", + "on", + attributes={"device_class": "unknown_class"}, + ) + hass.states.async_set( + "some_domain.with_device_class_1", + "off", + attributes={"device_class": "some_device_class"}, + ) + hass.states.async_set( + "some_domain.with_device_class_2", + "foo", + attributes={"device_class": "some_device_class"}, + ) + hass.states.async_set("domain.is_unavailable", "unavailable", attributes={}) + hass.states.async_set("domain.is_unknown", "unknown", attributes={}) + + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + translation_key="translation_key", + ) + hass.states.async_set("light.hue_5678", "on", attributes={}) + + result = render(hass, '{{ state_translated("switch.without_translations") }}') + assert result == "on" + + result = render( + hass, '{{ state_translated("binary_sensor.without_device_class") }}' + ) + assert result == "On" + + result = render(hass, '{{ state_translated("binary_sensor.with_device_class") }}') + assert result == "Detected" + + result = render( + hass, '{{ state_translated("binary_sensor.with_unknown_device_class") }}' + ) + assert result == "On" + + with pytest.raises(TemplateError): + render(hass, '{{ state_translated("contextfunction") }}') + + result = render(hass, '{{ state_translated("switch.invalid") }}') + assert result == "unknown" + + with pytest.raises(TemplateError): + render(hass, '{{ state_translated("-invalid") }}') + + def mock_get_cached_translations( + _hass: HomeAssistant, + _language: str, + category: str, + _integrations: Iterable[str] | None = None, + ): + if category == "entity": + return { + "component.hue.entity.light.translation_key.state.on": "state_is_on", + } + return {} + + with patch( + "homeassistant.helpers.translation.async_get_cached_translations", + side_effect=mock_get_cached_translations, + ): + result = render(hass, '{{ state_translated("light.hue_5678") }}') + assert result == "state_is_on" + + result = render(hass, '{{ state_translated("domain.is_unavailable") }}') + assert result == "unavailable" + + result = render(hass, '{{ state_translated("domain.is_unknown") }}') + assert result == "unknown" + + +async def test_state_attr_translated( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test state_attr_translated method.""" + await translation._async_get_translations_cache(hass).async_load("en", set()) + + hass.states.async_set( + "climate.living_room", + "heat", + attributes={"fan_mode": "auto", "hvac_action": "heating"}, + ) + hass.states.async_set( + "switch.test", + "on", + attributes={"some_attr": "some_value", "numeric_attr": 42, "bool_attr": True}, + ) + + result = render( + hass, + '{{ state_attr_translated("switch.test", "some_attr") }}', + ) + assert result == "some_value" + + # Non-string attributes should be returned as-is without type conversion + result = render( + hass, + '{{ state_attr_translated("switch.test", "numeric_attr") }}', + ) + assert result == 42 + assert isinstance(result, int) + + result = render( + hass, + '{{ state_attr_translated("switch.test", "bool_attr") }}', + ) + assert result is True + + result = render( + hass, + '{{ state_attr_translated("climate.non_existent", "fan_mode") }}', + ) + assert result is None + + with pytest.raises(TemplateError): + render(hass, '{{ state_attr_translated("-invalid", "fan_mode") }}') + + result = render( + hass, + '{{ state_attr_translated("climate.living_room", "non_existent") }}', + ) + assert result is None + + +@pytest.mark.parametrize( + ( + "entity_id", + "attribute", + "translations", + "expected_result", + ), + [ + ( + "climate.test_platform_5678", + "fan_mode", + { + "component.test_platform.entity.climate.my_climate.state_attributes.fan_mode.state.auto": "Platform Automatic", + }, + "Platform Automatic", + ), + ( + "climate.living_room", + "fan_mode", + { + "component.climate.entity_component._.state_attributes.fan_mode.state.auto": "Automatic", + }, + "Automatic", + ), + ( + "climate.living_room", + "hvac_action", + { + "component.climate.entity_component._.state_attributes.hvac_action.state.heating": "Heating", + }, + "Heating", + ), + ], +) +async def test_state_attr_translated_translation_lookups( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entity_id: str, + attribute: str, + translations: dict[str, str], + expected_result: str, +) -> None: + """Test state_attr_translated translation lookups.""" + await translation._async_get_translations_cache(hass).async_load("en", set()) + + hass.states.async_set( + "climate.living_room", + "heat", + attributes={"fan_mode": "auto", "hvac_action": "heating"}, + ) + + config_entry = MockConfigEntry(domain="climate") + config_entry.add_to_hass(hass) + entity_registry.async_get_or_create( + "climate", + "test_platform", + "5678", + config_entry=config_entry, + translation_key="my_climate", + ) + hass.states.async_set( + "climate.test_platform_5678", + "heat", + attributes={"fan_mode": "auto"}, + ) + + with patch( + "homeassistant.helpers.translation.async_get_cached_translations", + return_value=translations, + ): + result = render( + hass, + f'{{{{ state_attr_translated("{entity_id}", "{attribute}") }}}}', + ) + assert result == expected_result + + +def test_has_value(hass: HomeAssistant) -> None: + """Test has_value method.""" + hass.states.async_set("test.value1", 1) + hass.states.async_set("test.unavailable", STATE_UNAVAILABLE) + + result = render(hass, """{{ has_value("test.value1") }}""") + assert result is True + + result = render(hass, """{{ has_value("test.unavailable") }}""") + assert result is False + + result = render(hass, """{{ has_value("test.unknown") }}""") + assert result is False + + result = render( + hass, """{% if "test.value1" is has_value %}yes{% else %}no{% endif %}""" + ) + assert result == "yes" + + +def test_distance_function_with_1_state(hass: HomeAssistant) -> None: + """Test distance function with 1 state.""" + _set_up_units(hass) + hass.states.async_set( + "test.object", "happy", {"latitude": 32.87336, "longitude": -117.22943} + ) + + result = render(hass, "{{ distance(states.test.object) | round }}") + assert result == 187 + + +def test_distance_function_with_2_states(hass: HomeAssistant) -> None: + """Test distance function with 2 states.""" + _set_up_units(hass) + hass.states.async_set( + "test.object", "happy", {"latitude": 32.87336, "longitude": -117.22943} + ) + hass.states.async_set( + "test.object_2", + "happy", + {"latitude": hass.config.latitude, "longitude": hass.config.longitude}, + ) + + result = render( + hass, "{{ distance(states.test.object, states.test.object_2) | round }}" + ) + assert result == 187 + + +def test_distance_function_with_1_coord(hass: HomeAssistant) -> None: + """Test distance function with 1 coord.""" + _set_up_units(hass) + + result = render(hass, '{{ distance("32.87336", "-117.22943") | round }}') + assert result == 187 + + +def test_distance_function_with_2_coords(hass: HomeAssistant) -> None: + """Test distance function with 2 coords.""" + _set_up_units(hass) + tpl = f'{{{{ distance("32.87336", "-117.22943", {hass.config.latitude}, {hass.config.longitude}) | round }}}}' + assert render(hass, tpl) == 187 + + +def test_distance_function_with_1_state_1_coord(hass: HomeAssistant) -> None: + """Test distance function with 1 state 1 coord.""" + _set_up_units(hass) + hass.states.async_set( + "test.object_2", + "happy", + {"latitude": hass.config.latitude, "longitude": hass.config.longitude}, + ) + + result = render( + hass, '{{ distance("32.87336", "-117.22943", states.test.object_2) | round }}' + ) + assert result == 187 + + result = render( + hass, '{{ distance(states.test.object_2, "32.87336", "-117.22943") | round }}' + ) + assert result == 187 + + +def test_distance_function_return_none_if_invalid_state(hass: HomeAssistant) -> None: + """Test distance function return None if invalid state.""" + hass.states.async_set("test.object_2", "happy", {"latitude": 10}) + with pytest.raises(TemplateError): + render(hass, "{{ distance(states.test.object_2) | round }}") + + +def test_distance_function_return_none_if_invalid_coord(hass: HomeAssistant) -> None: + """Test distance function return None if invalid coord.""" + assert render(hass, '{{ distance("123", "abc") }}') is None + + assert render(hass, '{{ distance("123") }}') is None + + hass.states.async_set( + "test.object_2", + "happy", + {"latitude": hass.config.latitude, "longitude": hass.config.longitude}, + ) + + result = render(hass, '{{ distance("123", states.test_object_2) }}') + assert result is None + + +def test_distance_function_with_2_entity_ids(hass: HomeAssistant) -> None: + """Test distance function with 2 entity ids.""" + _set_up_units(hass) + hass.states.async_set( + "test.object", "happy", {"latitude": 32.87336, "longitude": -117.22943} + ) + hass.states.async_set( + "test.object_2", + "happy", + {"latitude": hass.config.latitude, "longitude": hass.config.longitude}, + ) + + result = render(hass, '{{ distance("test.object", "test.object_2") | round }}') + assert result == 187 + + +def test_distance_function_with_1_entity_1_coord(hass: HomeAssistant) -> None: + """Test distance function with 1 entity_id and 1 coord.""" + _set_up_units(hass) + hass.states.async_set( + "test.object", + "happy", + {"latitude": hass.config.latitude, "longitude": hass.config.longitude}, + ) + + result = render( + hass, '{{ distance("test.object", "32.87336", "-117.22943") | round }}' + ) + assert result == 187 + + +def test_closest_function_home_vs_domain(hass: HomeAssistant) -> None: + """Test closest function home vs domain.""" + hass.states.async_set( + "test_domain.object", + "happy", + { + "latitude": hass.config.latitude + 0.1, + "longitude": hass.config.longitude + 0.1, + }, + ) + + hass.states.async_set( + "not_test_domain.but_closer", + "happy", + {"latitude": hass.config.latitude, "longitude": hass.config.longitude}, + ) + + assert ( + render(hass, "{{ closest(states.test_domain).entity_id }}") + == "test_domain.object" + ) + + assert ( + render(hass, "{{ (states.test_domain | closest).entity_id }}") + == "test_domain.object" + ) + + +def test_closest_function_home_vs_all_states(hass: HomeAssistant) -> None: + """Test closest function home vs all states.""" + hass.states.async_set( + "test_domain.object", + "happy", + { + "latitude": hass.config.latitude + 0.1, + "longitude": hass.config.longitude + 0.1, + }, + ) + + hass.states.async_set( + "test_domain_2.and_closer", + "happy", + {"latitude": hass.config.latitude, "longitude": hass.config.longitude}, + ) + + assert render(hass, "{{ closest(states).entity_id }}") == "test_domain_2.and_closer" + + assert ( + render(hass, "{{ (states | closest).entity_id }}") == "test_domain_2.and_closer" + ) + + +async def test_expand(hass: HomeAssistant) -> None: + """Test expand function.""" + info = render_to_info(hass, "{{ expand('test.object') }}") + assert_result_info(info, [], ["test.object"]) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ expand(56) }}") + assert_result_info(info, []) + assert info.rate_limit is None + + hass.states.async_set("test.object", "happy") + + info = render_to_info( + hass, + "{{ expand('test.object') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", + ) + assert_result_info(info, "test.object", ["test.object"]) + assert info.rate_limit is None + + info = render_to_info( + hass, + "{{ expand('group.new_group') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", + ) + assert_result_info(info, "", ["group.new_group"]) + assert info.rate_limit is None + + info = render_to_info( + hass, + "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", + ) + assert_result_info(info, "", [], ["group"]) + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT + + assert await async_setup_component(hass, "group", {}) + await hass.async_block_till_done() + await group.Group.async_create_group( + hass, + "new group", + created_by_service=False, + entity_ids=["test.object"], + icon=None, + mode=None, + object_id=None, + order=None, + ) + + info = render_to_info( + hass, + "{{ expand('group.new_group') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", + ) + assert_result_info(info, "test.object", {"group.new_group", "test.object"}) + assert info.rate_limit is None + + info = render_to_info( + hass, + "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", + ) + assert_result_info(info, "test.object", {"test.object"}, ["group"]) + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT + + info = render_to_info( + hass, + ( + "{{ expand('group.new_group', 'test.object')" + " | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" + ), + ) + assert_result_info(info, "test.object", {"test.object", "group.new_group"}) + + info = render_to_info( + hass, + ( + "{{ ['group.new_group', 'test.object'] | expand" + " | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" + ), + ) + assert_result_info(info, "test.object", {"test.object", "group.new_group"}) + assert info.rate_limit is None + + hass.states.async_set("sensor.power_1", 0) + hass.states.async_set("sensor.power_2", 200.2) + hass.states.async_set("sensor.power_3", 400.4) + + assert await async_setup_component(hass, "group", {}) + await hass.async_block_till_done() + await group.Group.async_create_group( + hass, + "power sensors", + created_by_service=False, + entity_ids=["sensor.power_1", "sensor.power_2", "sensor.power_3"], + icon=None, + mode=None, + object_id=None, + order=None, + ) + + info = render_to_info( + hass, + ( + "{{ states.group.power_sensors.attributes.entity_id | expand " + "| sort(attribute='entity_id') | map(attribute='state')|map('float')|sum }}" + ), + ) + assert_result_info( + info, + 200.2 + 400.4, + {"group.power_sensors", "sensor.power_1", "sensor.power_2", "sensor.power_3"}, + ) + assert info.rate_limit is None + + # With group entities + hass.states.async_set("light.first", "on") + hass.states.async_set("light.second", "off") + + assert await async_setup_component( + hass, + "light", + { + "light": { + "platform": "group", + "name": "Grouped", + "entities": ["light.first", "light.second"], + } + }, + ) + await hass.async_block_till_done() + + info = render_to_info( + hass, + "{{ expand('light.grouped') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", + ) + assert_result_info( + info, + "light.first, light.second", + ["light.grouped", "light.first", "light.second"], + ) + + assert await async_setup_component( + hass, + "zone", + { + "zone": { + "name": "Test", + "latitude": 32.880837, + "longitude": -117.237561, + "radius": 250, + "passive": False, + } + }, + ) + info = render_to_info( + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", + ) + assert_result_info( + info, + "", + ["zone.test"], + ) + + hass.states.async_set( + "person.person1", + "test", + ) + await hass.async_block_till_done() + + info = render_to_info( + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", + ) + assert_result_info( + info, + "person.person1", + ["zone.test", "person.person1"], + ) + + hass.states.async_set( + "person.person2", + "test", + ) + await hass.async_block_till_done() + + info = render_to_info( + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", + ) + assert_result_info( + info, + "person.person1, person.person2", + ["zone.test", "person.person1", "person.person2"], + ) + + +def test_closest_function_to_coord(hass: HomeAssistant) -> None: + """Test closest function to coord.""" + hass.states.async_set( + "test_domain.closest_home", + "happy", + { + "latitude": hass.config.latitude + 0.1, + "longitude": hass.config.longitude + 0.1, + }, + ) + + hass.states.async_set( + "test_domain.closest_zone", + "happy", + { + "latitude": hass.config.latitude + 0.2, + "longitude": hass.config.longitude + 0.2, + }, + ) + + hass.states.async_set( + "zone.far_away", + "zoning", + { + "latitude": hass.config.latitude + 0.3, + "longitude": hass.config.longitude + 0.3, + }, + ) + + result = render( + hass, + f'{{{{ closest("{hass.config.latitude + 0.3}", {hass.config.longitude + 0.3}, states.test_domain).entity_id }}}}', + ) + assert result == "test_domain.closest_zone" + + result = render( + hass, + f'{{{{ (states.test_domain | closest("{hass.config.latitude + 0.3}", {hass.config.longitude + 0.3})).entity_id }}}}', + ) + assert result == "test_domain.closest_zone" + + +def test_closest_function_to_entity_id(hass: HomeAssistant) -> None: + """Test closest function to entity id.""" + hass.states.async_set( + "test_domain.closest_home", + "happy", + { + "latitude": hass.config.latitude + 0.1, + "longitude": hass.config.longitude + 0.1, + }, + ) + + hass.states.async_set( + "test_domain.closest_zone", + "happy", + { + "latitude": hass.config.latitude + 0.2, + "longitude": hass.config.longitude + 0.2, + }, + ) + + hass.states.async_set( + "zone.far_away", + "zoning", + { + "latitude": hass.config.latitude + 0.3, + "longitude": hass.config.longitude + 0.3, + }, + ) + + info = render_to_info( + hass, + "{{ closest(zone, states.test_domain).entity_id }}", + {"zone": "zone.far_away"}, + ) + + assert_result_info( + info, + "test_domain.closest_zone", + ["test_domain.closest_home", "test_domain.closest_zone", "zone.far_away"], + ["test_domain"], + ) + + info = render_to_info( + hass, + ( + "{{ ([states.test_domain, 'test_domain.closest_zone'] " + "| closest(zone)).entity_id }}" + ), + {"zone": "zone.far_away"}, + ) + + assert_result_info( + info, + "test_domain.closest_zone", + ["test_domain.closest_home", "test_domain.closest_zone", "zone.far_away"], + ["test_domain"], + ) + + +def test_closest_function_to_state(hass: HomeAssistant) -> None: + """Test closest function to state.""" + hass.states.async_set( + "test_domain.closest_home", + "happy", + { + "latitude": hass.config.latitude + 0.1, + "longitude": hass.config.longitude + 0.1, + }, + ) + + hass.states.async_set( + "test_domain.closest_zone", + "happy", + { + "latitude": hass.config.latitude + 0.2, + "longitude": hass.config.longitude + 0.2, + }, + ) + + hass.states.async_set( + "zone.far_away", + "zoning", + { + "latitude": hass.config.latitude + 0.3, + "longitude": hass.config.longitude + 0.3, + }, + ) + + assert ( + render( + hass, "{{ closest(states.zone.far_away, states.test_domain).entity_id }}" + ) + == "test_domain.closest_zone" + ) + + +def test_closest_function_invalid_state(hass: HomeAssistant) -> None: + """Test closest function invalid state.""" + hass.states.async_set( + "test_domain.closest_home", + "happy", + { + "latitude": hass.config.latitude + 0.1, + "longitude": hass.config.longitude + 0.1, + }, + ) + + for state in ("states.zone.non_existing", '"zone.non_existing"'): + assert render(hass, f"{{{{ closest({state}, states) }}}}") is None + + +def test_closest_function_state_with_invalid_location(hass: HomeAssistant) -> None: + """Test closest function state with invalid location.""" + hass.states.async_set( + "test_domain.closest_home", + "happy", + {"latitude": "invalid latitude", "longitude": hass.config.longitude + 0.1}, + ) + + assert ( + render(hass, "{{ closest(states.test_domain.closest_home, states) }}") is None + ) + + +def test_closest_function_invalid_coordinates(hass: HomeAssistant) -> None: + """Test closest function invalid coordinates.""" + hass.states.async_set( + "test_domain.closest_home", + "happy", + { + "latitude": hass.config.latitude + 0.1, + "longitude": hass.config.longitude + 0.1, + }, + ) + + assert render(hass, '{{ closest("invalid", "coord", states) }}') is None + assert render(hass, '{{ states | closest("invalid", "coord") }}') is None + + +def test_closest_function_no_location_states(hass: HomeAssistant) -> None: + """Test closest function without location states.""" + assert render(hass, "{{ closest(states).entity_id }}") == "" + + +def test_generate_filter_iterators(hass: HomeAssistant) -> None: + """Test extract entities function with none entities stuff.""" + info = render_to_info( + hass, + """ + {% for state in states %} + {{ state.entity_id }} + {% endfor %} + """, + ) + assert_result_info(info, "", all_states=True) + + info = render_to_info( + hass, + """ + {% for state in states.sensor %} + {{ state.entity_id }} + {% endfor %} + """, + ) + assert_result_info(info, "", domains=["sensor"]) + + hass.states.async_set("sensor.test_sensor", "off", {"attr": "value"}) + + # Don't need the entity because the state is not accessed + info = render_to_info( + hass, + """ + {% for state in states.sensor %} + {{ state.entity_id }} + {% endfor %} + """, + ) + assert_result_info(info, "sensor.test_sensor", domains=["sensor"]) + + # But we do here because the state gets accessed + info = render_to_info( + hass, + """ + {% for state in states.sensor %} + {{ state.entity_id }}={{ state.state }}, + {% endfor %} + """, + ) + assert_result_info(info, "sensor.test_sensor=off,", [], ["sensor"]) + + info = render_to_info( + hass, + """ + {% for state in states.sensor %} + {{ state.entity_id }}={{ state.attributes.attr }}, + {% endfor %} + """, + ) + assert_result_info(info, "sensor.test_sensor=value,", [], ["sensor"]) + + +def test_generate_select(hass: HomeAssistant) -> None: + """Test extract entities function with none entities stuff.""" + template_str = """ +{{ states.sensor|selectattr("state","equalto","off") +|join(",", attribute="entity_id") }} + """ + + info = render_to_info(hass, template_str) + assert_result_info(info, "", [], []) + assert info.domains_lifecycle == {"sensor"} + + hass.states.async_set("sensor.test_sensor", "off", {"attr": "value"}) + hass.states.async_set("sensor.test_sensor_on", "on") + + info = render_to_info(hass, template_str) + assert_result_info( + info, + "sensor.test_sensor", + [], + ["sensor"], + ) + assert info.domains_lifecycle == {"sensor"} + + +def test_state_with_unit(hass: HomeAssistant) -> None: + """Test the state_with_unit property helper.""" + hass.states.async_set("sensor.test", "23", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) + hass.states.async_set("sensor.test2", "wow") + + result = render(hass, "{{ states.sensor.test.state_with_unit }}") + assert result == "23 beers" + + result = render(hass, "{{ states.sensor.test2.state_with_unit }}") + assert result == "wow" + + result = render( + hass, "{% for state in states %}{{ state.state_with_unit }} {% endfor %}" + ) + assert result == "23 beers wow" + + result = render(hass, "{{ states.sensor.non_existing.state_with_unit }}") + assert result == "" + + +def test_state_with_unit_and_rounding( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test formatting the state rounded and with unit.""" + entry = entity_registry.async_get_or_create( + "sensor", "test", "very_unique", suggested_object_id="test" + ) + entity_registry.async_update_entity_options( + entry.entity_id, + "sensor", + { + "suggested_display_precision": 2, + }, + ) + assert entry.entity_id == "sensor.test" + + hass.states.async_set("sensor.test", "23", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) + hass.states.async_set("sensor.test2", "23", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) + hass.states.async_set("sensor.test3", "-0.0", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) + hass.states.async_set("sensor.test4", "-0", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) + + # state_with_unit property + tpl = template.Template("{{ states.sensor.test.state_with_unit }}", hass) + tpl2 = template.Template("{{ states.sensor.test2.state_with_unit }}", hass) + + # AllStates.__call__ defaults + tpl3 = template.Template("{{ states('sensor.test') }}", hass) + tpl4 = template.Template("{{ states('sensor.test2') }}", hass) + + # AllStates.__call__ and with_unit=True + tpl5 = template.Template("{{ states('sensor.test', with_unit=True) }}", hass) + tpl6 = template.Template("{{ states('sensor.test2', with_unit=True) }}", hass) + + # AllStates.__call__ and rounded=True + tpl7 = template.Template("{{ states('sensor.test', rounded=True) }}", hass) + tpl8 = template.Template("{{ states('sensor.test2', rounded=True) }}", hass) + tpl9 = template.Template("{{ states('sensor.test3', rounded=True) }}", hass) + tpl10 = template.Template("{{ states('sensor.test4', rounded=True) }}", hass) + + assert tpl.async_render() == "23.00 beers" + assert tpl2.async_render() == "23 beers" + assert tpl3.async_render() == 23 + assert tpl4.async_render() == 23 + assert tpl5.async_render() == "23.00 beers" + assert tpl6.async_render() == "23 beers" + assert tpl7.async_render() == 23.0 + assert tpl8.async_render() == 23 + assert tpl9.async_render() == 0.0 + assert tpl10.async_render() == 0 + + hass.states.async_set("sensor.test", "23.015", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) + hass.states.async_set("sensor.test2", "23.015", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) + + assert tpl.async_render() == "23.02 beers" + assert tpl2.async_render() == "23.015 beers" + assert tpl3.async_render() == 23.015 + assert tpl4.async_render() == 23.015 + assert tpl5.async_render() == "23.02 beers" + assert tpl6.async_render() == "23.015 beers" + assert tpl7.async_render() == 23.02 + assert tpl8.async_render() == 23.015 + + +async def test_closest_function_home_vs_group_entity_id(hass: HomeAssistant) -> None: + """Test closest function home vs group entity id.""" + hass.states.async_set( + "test_domain.object", + "happy", + { + "latitude": hass.config.latitude + 0.1, + "longitude": hass.config.longitude + 0.1, + }, + ) + + hass.states.async_set( + "not_in_group.but_closer", + "happy", + {"latitude": hass.config.latitude, "longitude": hass.config.longitude}, + ) + + assert await async_setup_component(hass, "group", {}) + await hass.async_block_till_done() + await group.Group.async_create_group( + hass, + "location group", + created_by_service=False, + entity_ids=["test_domain.object"], + icon=None, + mode=None, + object_id=None, + order=None, + ) + + info = render_to_info(hass, '{{ closest("group.location_group").entity_id }}') + assert_result_info( + info, "test_domain.object", {"group.location_group", "test_domain.object"} + ) + assert info.rate_limit is None + + +async def test_closest_function_home_vs_group_state(hass: HomeAssistant) -> None: + """Test closest function home vs group state.""" + hass.states.async_set( + "test_domain.object", + "happy", + { + "latitude": hass.config.latitude + 0.1, + "longitude": hass.config.longitude + 0.1, + }, + ) + + hass.states.async_set( + "not_in_group.but_closer", + "happy", + {"latitude": hass.config.latitude, "longitude": hass.config.longitude}, + ) + + assert await async_setup_component(hass, "group", {}) + await hass.async_block_till_done() + await group.Group.async_create_group( + hass, + "location group", + created_by_service=False, + entity_ids=["test_domain.object"], + icon=None, + mode=None, + object_id=None, + order=None, + ) + + info = render_to_info(hass, '{{ closest("group.location_group").entity_id }}') + assert_result_info( + info, "test_domain.object", {"group.location_group", "test_domain.object"} + ) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ closest(states.group.location_group).entity_id }}") + assert_result_info( + info, "test_domain.object", {"test_domain.object", "group.location_group"} + ) + assert info.rate_limit is None diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index 9b61f2a4d76c06..204c04dcc91fe9 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Iterable from datetime import datetime from unittest.mock import patch @@ -10,50 +9,18 @@ import pytest import voluptuous as vol -from homeassistant.components import group -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, - STATE_UNAVAILABLE, - UnitOfArea, - UnitOfLength, - UnitOfMass, - UnitOfPrecipitationDepth, - UnitOfPressure, - UnitOfSpeed, - UnitOfTemperature, - UnitOfVolume, -) +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError -from homeassistant.helpers import entity_registry as er, template, translation +from homeassistant.helpers import entity_registry as er, template from homeassistant.helpers.template.render_info import ( ALL_STATES_RATE_LIMIT, DOMAIN_STATES_RATE_LIMIT, ) -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from homeassistant.util.unit_system import UnitSystem from .helpers import assert_result_info, render, render_to_info -from tests.common import MockConfigEntry - - -def _set_up_units(hass: HomeAssistant) -> None: - """Set up the tests.""" - hass.config.units = UnitSystem( - "custom", - accumulated_precipitation=UnitOfPrecipitationDepth.MILLIMETERS, - area=UnitOfArea.SQUARE_METERS, - conversions={}, - length=UnitOfLength.METERS, - mass=UnitOfMass.GRAMS, - pressure=UnitOfPressure.PA, - temperature=UnitOfTemperature.CELSIUS, - volume=UnitOfVolume.LITERS, - wind_speed=UnitOfSpeed.KILOMETERS_PER_HOUR, - ) - async def test_template_render_missing_hass(hass: HomeAssistant) -> None: """Test template render when hass is not set.""" @@ -80,12 +47,11 @@ async def test_template_render_info_collision(hass: HomeAssistant) -> None: template_obj.async_render_to_info() -@pytest.mark.usefixtures("hass") -def test_template_equality() -> None: +def test_template_equality(hass: HomeAssistant) -> None: """Test template comparison and hashing.""" - template_one = template.Template("{{ template_one }}") - template_one_1 = template.Template("{{ template_one }}") - template_two = template.Template("{{ template_two }}") + template_one = template.Template("{{ template_one }}", hass) + template_one_1 = template.Template("{{ template_one }}", hass) + template_two = template.Template("{{ template_two }}", hass) assert template_one == template_one_1 assert template_one != template_two @@ -95,7 +61,7 @@ def test_template_equality() -> None: assert str(template_one_1) == "Template" with pytest.raises(TypeError): - template.Template(["{{ template_one }}"]) + template.Template(["{{ template_one }}"], hass) def test_invalid_template(hass: HomeAssistant) -> None: @@ -120,16 +86,6 @@ def test_invalid_template(hass: HomeAssistant) -> None: tmpl.async_render() -def test_referring_states_by_entity_id(hass: HomeAssistant) -> None: - """Test referring states by entity id.""" - hass.states.async_set("test.object", "happy") - assert render(hass, "{{ states.test.object.state }}") == "happy" - - assert render(hass, '{{ states["test.object"].state }}') == "happy" - - assert render(hass, '{{ states("test.object") }}') == "happy" - - def test_invalid_entity_id(hass: HomeAssistant) -> None: """Test referring states by entity id.""" with pytest.raises(TemplateError): @@ -143,68 +99,7 @@ def test_invalid_entity_id(hass: HomeAssistant) -> None: def test_raise_exception_on_error(hass: HomeAssistant) -> None: """Test raising an exception on error.""" with pytest.raises(TemplateError): - template.Template("{{ invalid_syntax").ensure_valid() - - -def test_iterating_all_states(hass: HomeAssistant) -> None: - """Test iterating all states.""" - tmpl_str = "{% for state in states | sort(attribute='entity_id') %}{{ state.state }}{% endfor %}" - - info = render_to_info(hass, tmpl_str) - assert_result_info(info, "", all_states=True) - assert info.rate_limit == ALL_STATES_RATE_LIMIT - - hass.states.async_set("test.object", "happy") - hass.states.async_set("sensor.temperature", 10) - - info = render_to_info(hass, tmpl_str) - assert_result_info(info, "10happy", entities=[], all_states=True) - - -def test_iterating_all_states_unavailable(hass: HomeAssistant) -> None: - """Test iterating all states unavailable.""" - hass.states.async_set("test.object", "on") - - tmpl_str = ( - "{{" - " states" - " | selectattr('state', 'in', ['unavailable', 'unknown', 'none'])" - " | list" - " | count" - "}}" - ) - - info = render_to_info(hass, tmpl_str) - - assert info.all_states is True - assert info.rate_limit == ALL_STATES_RATE_LIMIT - - hass.states.async_set("test.object", "unknown") - hass.states.async_set("sensor.temperature", 10) - - info = render_to_info(hass, tmpl_str) - assert_result_info(info, 1, entities=[], all_states=True) - - -def test_iterating_domain_states(hass: HomeAssistant) -> None: - """Test iterating domain states.""" - tmpl_str = "{% for state in states.sensor %}{{ state.state }}{% endfor %}" - - info = render_to_info(hass, tmpl_str) - assert_result_info(info, "", domains=["sensor"]) - assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT - - hass.states.async_set("test.object", "happy") - hass.states.async_set("sensor.back_door", "open") - hass.states.async_set("sensor.temperature", 10) - - info = render_to_info(hass, tmpl_str) - assert_result_info( - info, - "open10", - entities=[], - domains=["sensor"], - ) + template.Template("{{ invalid_syntax", hass).ensure_valid() async def test_import(hass: HomeAssistant) -> None: @@ -344,938 +239,76 @@ def test_render_with_possible_json_value_with_template_error_value( ) -> None: """Render with possible JSON value with template error value.""" tpl = template.Template("{{ non_existing.variable }}", hass) - assert tpl.async_render_with_possible_json_value("hello", "-") == "-" - - -def test_render_with_possible_json_value_with_missing_json_value( - hass: HomeAssistant, -) -> None: - """Render with possible JSON value with unknown JSON object.""" - tpl = template.Template("{{ value_json.goodbye }}", hass) - assert tpl.async_render_with_possible_json_value('{"hello": "world"}') == "" - - -def test_render_with_possible_json_value_non_string_value(hass: HomeAssistant) -> None: - """Render with possible JSON value with non-string value.""" - tpl = template.Template( - """{{ strptime(value~'+0000', '%Y-%m-%d %H:%M:%S%z') }}""", - hass, - ) - value = datetime(2019, 1, 18, 12, 13, 14) - expected = str(value.replace(tzinfo=dt_util.UTC)) - assert tpl.async_render_with_possible_json_value(value) == expected - - -def test_render_with_possible_json_value_and_parse_result(hass: HomeAssistant) -> None: - """Render with possible JSON value with valid JSON.""" - tpl = template.Template("{{ value_json.hello }}", hass) - result = tpl.async_render_with_possible_json_value( - """{"hello": {"world": "value1"}}""", parse_result=True - ) - assert isinstance(result, dict) - - -def test_render_with_possible_json_value_and_dont_parse_result( - hass: HomeAssistant, -) -> None: - """Render with possible JSON value with valid JSON.""" - tpl = template.Template("{{ value_json.hello }}", hass) - result = tpl.async_render_with_possible_json_value( - """{"hello": {"world": "value1"}}""", parse_result=False - ) - assert isinstance(result, str) - - -def test_if_state_exists(hass: HomeAssistant) -> None: - """Test if state exists works.""" - hass.states.async_set("test.object", "available") - - result = render( - hass, "{% if states.test.object %}exists{% else %}not exists{% endif %}" - ) - assert result == "exists" - - -def test_is_state(hass: HomeAssistant) -> None: - """Test is_state method.""" - hass.states.async_set("test.object", "available") - - result = render( - hass, '{% if is_state("test.object", "available") %}yes{% else %}no{% endif %}' - ) - assert result == "yes" - - result = render(hass, """{{ is_state("test.noobject", "available") }}""") - assert result is False - - result = render( - hass, - '{% if "test.object" is is_state("available") %}yes{% else %}no{% endif %}', - ) - assert result == "yes" - - result = render( - hass, - """{{ ['test.object'] | select("is_state", "available") | first | default }}""", - ) - assert result == "test.object" - - result = render(hass, '{{ is_state("test.object", ["on", "off", "available"]) }}') - assert result is True - - -def test_is_state_attr(hass: HomeAssistant) -> None: - """Test is_state_attr method.""" - hass.states.async_set("test.object", "available", {"mode": "on", "exists": None}) - - result = render( - hass, - """{% if is_state_attr("test.object", "mode", "on") %}yes{% else %}no{% endif %}""", - ) - assert result == "yes" - - result = render(hass, """{{ is_state_attr("test.noobject", "mode", "on") }}""") - assert result is False - - result = render( - hass, - """{% if "test.object" is is_state_attr("mode", "on") %}yes{% else %}no{% endif %}""", - ) - assert result == "yes" - - result = render( - hass, - """{{ ['test.object'] | select("is_state_attr", "mode", "on") | first | default }}""", - ) - assert result == "test.object" - - result = render( - hass, - """{% if is_state_attr("test.object", "exists", None) %}yes{% else %}no{% endif %}""", - ) - assert result == "yes" - - result = render( - hass, - """{% if is_state_attr("test.object", "noexist", None) %}yes{% else %}no{% endif %}""", - ) - assert result == "no" - - -def test_state_attr(hass: HomeAssistant) -> None: - """Test state_attr method.""" - hass.states.async_set( - "test.object", "available", {"effect": "action", "mode": "on"} - ) - - result = render( - hass, - """{% if state_attr("test.object", "mode") == "on" %}yes{% else %}no{% endif %}""", - ) - assert result == "yes" - - result = render(hass, """{{ state_attr("test.noobject", "mode") == None }}""") - assert result is True - - result = render( - hass, - """{% if "test.object" | state_attr("mode") == "on" %}yes{% else %}no{% endif %}""", - ) - assert result == "yes" - - result = render( - hass, - """{{ ['test.object'] | map("state_attr", "effect") | first | default }}""", - ) - assert result == "action" - - -def test_states_function(hass: HomeAssistant) -> None: - """Test using states as a function.""" - hass.states.async_set("test.object", "available") - - result = render(hass, '{{ states("test.object") }}') - assert result == "available" - - result = render(hass, '{{ states("test.object2") }}') - assert result == "unknown" - - result = render( - hass, - """{% if "test.object" | states == "available" %}yes{% else %}no{% endif %}""", - ) - assert result == "yes" - - result = render(hass, """{{ ['test.object'] | map("states") | first | default }}""") - assert result == "available" - - -async def test_state_translated( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test state_translated method.""" - assert await async_setup_component( - hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "group", - "name": "Grouped", - "entities": ["binary_sensor.first", "binary_sensor.second"], - } - }, - ) - await hass.async_block_till_done() - await translation._async_get_translations_cache(hass).async_load("en", set()) - - hass.states.async_set("switch.without_translations", "on", attributes={}) - hass.states.async_set("binary_sensor.without_device_class", "on", attributes={}) - hass.states.async_set( - "binary_sensor.with_device_class", "on", attributes={"device_class": "motion"} - ) - hass.states.async_set( - "binary_sensor.with_unknown_device_class", - "on", - attributes={"device_class": "unknown_class"}, - ) - hass.states.async_set( - "some_domain.with_device_class_1", - "off", - attributes={"device_class": "some_device_class"}, - ) - hass.states.async_set( - "some_domain.with_device_class_2", - "foo", - attributes={"device_class": "some_device_class"}, - ) - hass.states.async_set("domain.is_unavailable", "unavailable", attributes={}) - hass.states.async_set("domain.is_unknown", "unknown", attributes={}) - - config_entry = MockConfigEntry(domain="light") - config_entry.add_to_hass(hass) - entity_registry.async_get_or_create( - "light", - "hue", - "5678", - config_entry=config_entry, - translation_key="translation_key", - ) - hass.states.async_set("light.hue_5678", "on", attributes={}) - - result = render(hass, '{{ state_translated("switch.without_translations") }}') - assert result == "on" - - result = render( - hass, '{{ state_translated("binary_sensor.without_device_class") }}' - ) - assert result == "On" - - result = render(hass, '{{ state_translated("binary_sensor.with_device_class") }}') - assert result == "Detected" - - result = render( - hass, '{{ state_translated("binary_sensor.with_unknown_device_class") }}' - ) - assert result == "On" - - with pytest.raises(TemplateError): - render(hass, '{{ state_translated("contextfunction") }}') - - result = render(hass, '{{ state_translated("switch.invalid") }}') - assert result == "unknown" - - with pytest.raises(TemplateError): - render(hass, '{{ state_translated("-invalid") }}') - - def mock_get_cached_translations( - _hass: HomeAssistant, - _language: str, - category: str, - _integrations: Iterable[str] | None = None, - ): - if category == "entity": - return { - "component.hue.entity.light.translation_key.state.on": "state_is_on", - } - return {} - - with patch( - "homeassistant.helpers.translation.async_get_cached_translations", - side_effect=mock_get_cached_translations, - ): - result = render(hass, '{{ state_translated("light.hue_5678") }}') - assert result == "state_is_on" - - result = render(hass, '{{ state_translated("domain.is_unavailable") }}') - assert result == "unavailable" - - result = render(hass, '{{ state_translated("domain.is_unknown") }}') - assert result == "unknown" - - -async def test_state_attr_translated( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test state_attr_translated method.""" - await translation._async_get_translations_cache(hass).async_load("en", set()) - - hass.states.async_set( - "climate.living_room", - "heat", - attributes={"fan_mode": "auto", "hvac_action": "heating"}, - ) - hass.states.async_set( - "switch.test", - "on", - attributes={"some_attr": "some_value", "numeric_attr": 42, "bool_attr": True}, - ) - - result = render( - hass, - '{{ state_attr_translated("switch.test", "some_attr") }}', - ) - assert result == "some_value" - - # Non-string attributes should be returned as-is without type conversion - result = render( - hass, - '{{ state_attr_translated("switch.test", "numeric_attr") }}', - ) - assert result == 42 - assert isinstance(result, int) - - result = render( - hass, - '{{ state_attr_translated("switch.test", "bool_attr") }}', - ) - assert result is True - - result = render( - hass, - '{{ state_attr_translated("climate.non_existent", "fan_mode") }}', - ) - assert result is None - - with pytest.raises(TemplateError): - render(hass, '{{ state_attr_translated("-invalid", "fan_mode") }}') - - result = render( - hass, - '{{ state_attr_translated("climate.living_room", "non_existent") }}', - ) - assert result is None - - -@pytest.mark.parametrize( - ( - "entity_id", - "attribute", - "translations", - "expected_result", - ), - [ - ( - "climate.test_platform_5678", - "fan_mode", - { - "component.test_platform.entity.climate.my_climate.state_attributes.fan_mode.state.auto": "Platform Automatic", - }, - "Platform Automatic", - ), - ( - "climate.living_room", - "fan_mode", - { - "component.climate.entity_component._.state_attributes.fan_mode.state.auto": "Automatic", - }, - "Automatic", - ), - ( - "climate.living_room", - "hvac_action", - { - "component.climate.entity_component._.state_attributes.hvac_action.state.heating": "Heating", - }, - "Heating", - ), - ], -) -async def test_state_attr_translated_translation_lookups( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - entity_id: str, - attribute: str, - translations: dict[str, str], - expected_result: str, -) -> None: - """Test state_attr_translated translation lookups.""" - await translation._async_get_translations_cache(hass).async_load("en", set()) - - hass.states.async_set( - "climate.living_room", - "heat", - attributes={"fan_mode": "auto", "hvac_action": "heating"}, - ) - - config_entry = MockConfigEntry(domain="climate") - config_entry.add_to_hass(hass) - entity_registry.async_get_or_create( - "climate", - "test_platform", - "5678", - config_entry=config_entry, - translation_key="my_climate", - ) - hass.states.async_set( - "climate.test_platform_5678", - "heat", - attributes={"fan_mode": "auto"}, - ) - - with patch( - "homeassistant.helpers.translation.async_get_cached_translations", - return_value=translations, - ): - result = render( - hass, - f'{{{{ state_attr_translated("{entity_id}", "{attribute}") }}}}', - ) - assert result == expected_result - - -def test_has_value(hass: HomeAssistant) -> None: - """Test has_value method.""" - hass.states.async_set("test.value1", 1) - hass.states.async_set("test.unavailable", STATE_UNAVAILABLE) - - result = render(hass, """{{ has_value("test.value1") }}""") - assert result is True - - result = render(hass, """{{ has_value("test.unavailable") }}""") - assert result is False - - result = render(hass, """{{ has_value("test.unknown") }}""") - assert result is False - - result = render( - hass, """{% if "test.value1" is has_value %}yes{% else %}no{% endif %}""" - ) - assert result == "yes" - - -@patch( - "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", - return_value=True, -) -def test_timedelta(mock_is_safe, hass: HomeAssistant) -> None: - """Test relative_time method.""" - now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") - with freeze_time(now): - result = render(hass, "{{timedelta(seconds=120)}}") - assert result == "0:02:00" - - result = render(hass, "{{timedelta(seconds=86400)}}") - assert result == "1 day, 0:00:00" - - result = render(hass, "{{timedelta(days=1, hours=4)}}") - assert result == "1 day, 4:00:00" - - result = render(hass, "{{relative_time(now() - timedelta(seconds=3600))}}") - assert result == "1 hour" - - result = render(hass, "{{relative_time(now() - timedelta(seconds=86400))}}") - assert result == "1 day" - - result = render(hass, "{{relative_time(now() - timedelta(seconds=86401))}}") - assert result == "1 day" - - result = render(hass, "{{relative_time(now() - timedelta(weeks=2, days=1))}}") - assert result == "15 days" - - -def test_distance_function_with_1_state(hass: HomeAssistant) -> None: - """Test distance function with 1 state.""" - _set_up_units(hass) - hass.states.async_set( - "test.object", "happy", {"latitude": 32.87336, "longitude": -117.22943} - ) - - result = render(hass, "{{ distance(states.test.object) | round }}") - assert result == 187 - - -def test_distance_function_with_2_states(hass: HomeAssistant) -> None: - """Test distance function with 2 states.""" - _set_up_units(hass) - hass.states.async_set( - "test.object", "happy", {"latitude": 32.87336, "longitude": -117.22943} - ) - hass.states.async_set( - "test.object_2", - "happy", - {"latitude": hass.config.latitude, "longitude": hass.config.longitude}, - ) - - result = render( - hass, "{{ distance(states.test.object, states.test.object_2) | round }}" - ) - assert result == 187 - - -def test_distance_function_with_1_coord(hass: HomeAssistant) -> None: - """Test distance function with 1 coord.""" - _set_up_units(hass) - - result = render(hass, '{{ distance("32.87336", "-117.22943") | round }}') - assert result == 187 - - -def test_distance_function_with_2_coords(hass: HomeAssistant) -> None: - """Test distance function with 2 coords.""" - _set_up_units(hass) - tpl = f'{{{{ distance("32.87336", "-117.22943", {hass.config.latitude}, {hass.config.longitude}) | round }}}}' - assert render(hass, tpl) == 187 - - -def test_distance_function_with_1_state_1_coord(hass: HomeAssistant) -> None: - """Test distance function with 1 state 1 coord.""" - _set_up_units(hass) - hass.states.async_set( - "test.object_2", - "happy", - {"latitude": hass.config.latitude, "longitude": hass.config.longitude}, - ) - - result = render( - hass, '{{ distance("32.87336", "-117.22943", states.test.object_2) | round }}' - ) - assert result == 187 - - result = render( - hass, '{{ distance(states.test.object_2, "32.87336", "-117.22943") | round }}' - ) - assert result == 187 - - -def test_distance_function_return_none_if_invalid_state(hass: HomeAssistant) -> None: - """Test distance function return None if invalid state.""" - hass.states.async_set("test.object_2", "happy", {"latitude": 10}) - with pytest.raises(TemplateError): - render(hass, "{{ distance(states.test.object_2) | round }}") - - -def test_distance_function_return_none_if_invalid_coord(hass: HomeAssistant) -> None: - """Test distance function return None if invalid coord.""" - assert render(hass, '{{ distance("123", "abc") }}') is None - - assert render(hass, '{{ distance("123") }}') is None - - hass.states.async_set( - "test.object_2", - "happy", - {"latitude": hass.config.latitude, "longitude": hass.config.longitude}, - ) - - result = render(hass, '{{ distance("123", states.test_object_2) }}') - assert result is None - - -def test_distance_function_with_2_entity_ids(hass: HomeAssistant) -> None: - """Test distance function with 2 entity ids.""" - _set_up_units(hass) - hass.states.async_set( - "test.object", "happy", {"latitude": 32.87336, "longitude": -117.22943} - ) - hass.states.async_set( - "test.object_2", - "happy", - {"latitude": hass.config.latitude, "longitude": hass.config.longitude}, - ) - - result = render(hass, '{{ distance("test.object", "test.object_2") | round }}') - assert result == 187 - - -def test_distance_function_with_1_entity_1_coord(hass: HomeAssistant) -> None: - """Test distance function with 1 entity_id and 1 coord.""" - _set_up_units(hass) - hass.states.async_set( - "test.object", - "happy", - {"latitude": hass.config.latitude, "longitude": hass.config.longitude}, - ) - - result = render( - hass, '{{ distance("test.object", "32.87336", "-117.22943") | round }}' - ) - assert result == 187 - - -def test_closest_function_home_vs_domain(hass: HomeAssistant) -> None: - """Test closest function home vs domain.""" - hass.states.async_set( - "test_domain.object", - "happy", - { - "latitude": hass.config.latitude + 0.1, - "longitude": hass.config.longitude + 0.1, - }, - ) - - hass.states.async_set( - "not_test_domain.but_closer", - "happy", - {"latitude": hass.config.latitude, "longitude": hass.config.longitude}, - ) - - assert ( - render(hass, "{{ closest(states.test_domain).entity_id }}") - == "test_domain.object" - ) - - assert ( - render(hass, "{{ (states.test_domain | closest).entity_id }}") - == "test_domain.object" - ) - - -def test_closest_function_home_vs_all_states(hass: HomeAssistant) -> None: - """Test closest function home vs all states.""" - hass.states.async_set( - "test_domain.object", - "happy", - { - "latitude": hass.config.latitude + 0.1, - "longitude": hass.config.longitude + 0.1, - }, - ) - - hass.states.async_set( - "test_domain_2.and_closer", - "happy", - {"latitude": hass.config.latitude, "longitude": hass.config.longitude}, - ) - - assert render(hass, "{{ closest(states).entity_id }}") == "test_domain_2.and_closer" - - assert ( - render(hass, "{{ (states | closest).entity_id }}") == "test_domain_2.and_closer" - ) - - -async def test_closest_function_home_vs_group_entity_id(hass: HomeAssistant) -> None: - """Test closest function home vs group entity id.""" - hass.states.async_set( - "test_domain.object", - "happy", - { - "latitude": hass.config.latitude + 0.1, - "longitude": hass.config.longitude + 0.1, - }, - ) - - hass.states.async_set( - "not_in_group.but_closer", - "happy", - {"latitude": hass.config.latitude, "longitude": hass.config.longitude}, - ) - - assert await async_setup_component(hass, "group", {}) - await hass.async_block_till_done() - await group.Group.async_create_group( - hass, - "location group", - created_by_service=False, - entity_ids=["test_domain.object"], - icon=None, - mode=None, - object_id=None, - order=None, - ) - - info = render_to_info(hass, '{{ closest("group.location_group").entity_id }}') - assert_result_info( - info, "test_domain.object", {"group.location_group", "test_domain.object"} - ) - assert info.rate_limit is None - - -async def test_closest_function_home_vs_group_state(hass: HomeAssistant) -> None: - """Test closest function home vs group state.""" - hass.states.async_set( - "test_domain.object", - "happy", - { - "latitude": hass.config.latitude + 0.1, - "longitude": hass.config.longitude + 0.1, - }, - ) - - hass.states.async_set( - "not_in_group.but_closer", - "happy", - {"latitude": hass.config.latitude, "longitude": hass.config.longitude}, - ) - - assert await async_setup_component(hass, "group", {}) - await hass.async_block_till_done() - await group.Group.async_create_group( - hass, - "location group", - created_by_service=False, - entity_ids=["test_domain.object"], - icon=None, - mode=None, - object_id=None, - order=None, - ) - - info = render_to_info(hass, '{{ closest("group.location_group").entity_id }}') - assert_result_info( - info, "test_domain.object", {"group.location_group", "test_domain.object"} - ) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ closest(states.group.location_group).entity_id }}") - assert_result_info( - info, "test_domain.object", {"test_domain.object", "group.location_group"} - ) - assert info.rate_limit is None - - -async def test_expand(hass: HomeAssistant) -> None: - """Test expand function.""" - info = render_to_info(hass, "{{ expand('test.object') }}") - assert_result_info(info, [], ["test.object"]) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ expand(56) }}") - assert_result_info(info, []) - assert info.rate_limit is None - - hass.states.async_set("test.object", "happy") - - info = render_to_info( - hass, - "{{ expand('test.object') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", - ) - assert_result_info(info, "test.object", ["test.object"]) - assert info.rate_limit is None - - info = render_to_info( - hass, - "{{ expand('group.new_group') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", - ) - assert_result_info(info, "", ["group.new_group"]) - assert info.rate_limit is None - - info = render_to_info( - hass, - "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", - ) - assert_result_info(info, "", [], ["group"]) - assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT - - assert await async_setup_component(hass, "group", {}) - await hass.async_block_till_done() - await group.Group.async_create_group( - hass, - "new group", - created_by_service=False, - entity_ids=["test.object"], - icon=None, - mode=None, - object_id=None, - order=None, - ) - - info = render_to_info( - hass, - "{{ expand('group.new_group') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", - ) - assert_result_info(info, "test.object", {"group.new_group", "test.object"}) - assert info.rate_limit is None - - info = render_to_info( - hass, - "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", - ) - assert_result_info(info, "test.object", {"test.object"}, ["group"]) - assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT - - info = render_to_info( - hass, - ( - "{{ expand('group.new_group', 'test.object')" - " | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" - ), - ) - assert_result_info(info, "test.object", {"test.object", "group.new_group"}) - - info = render_to_info( - hass, - ( - "{{ ['group.new_group', 'test.object'] | expand" - " | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" - ), - ) - assert_result_info(info, "test.object", {"test.object", "group.new_group"}) - assert info.rate_limit is None - - hass.states.async_set("sensor.power_1", 0) - hass.states.async_set("sensor.power_2", 200.2) - hass.states.async_set("sensor.power_3", 400.4) - - assert await async_setup_component(hass, "group", {}) - await hass.async_block_till_done() - await group.Group.async_create_group( - hass, - "power sensors", - created_by_service=False, - entity_ids=["sensor.power_1", "sensor.power_2", "sensor.power_3"], - icon=None, - mode=None, - object_id=None, - order=None, - ) - - info = render_to_info( - hass, - ( - "{{ states.group.power_sensors.attributes.entity_id | expand " - "| sort(attribute='entity_id') | map(attribute='state')|map('float')|sum }}" - ), - ) - assert_result_info( - info, - 200.2 + 400.4, - {"group.power_sensors", "sensor.power_1", "sensor.power_2", "sensor.power_3"}, - ) - assert info.rate_limit is None + assert tpl.async_render_with_possible_json_value("hello", "-") == "-" - # With group entities - hass.states.async_set("light.first", "on") - hass.states.async_set("light.second", "off") - assert await async_setup_component( - hass, - "light", - { - "light": { - "platform": "group", - "name": "Grouped", - "entities": ["light.first", "light.second"], - } - }, - ) - await hass.async_block_till_done() +def test_render_with_possible_json_value_with_missing_json_value( + hass: HomeAssistant, +) -> None: + """Render with possible JSON value with unknown JSON object.""" + tpl = template.Template("{{ value_json.goodbye }}", hass) + assert tpl.async_render_with_possible_json_value('{"hello": "world"}') == "" - info = render_to_info( - hass, - "{{ expand('light.grouped') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", - ) - assert_result_info( - info, - "light.first, light.second", - ["light.grouped", "light.first", "light.second"], - ) - assert await async_setup_component( - hass, - "zone", - { - "zone": { - "name": "Test", - "latitude": 32.880837, - "longitude": -117.237561, - "radius": 250, - "passive": False, - } - }, - ) - info = render_to_info( +def test_render_with_possible_json_value_non_string_value(hass: HomeAssistant) -> None: + """Render with possible JSON value with non-string value.""" + tpl = template.Template( + """{{ strptime(value~'+0000', '%Y-%m-%d %H:%M:%S%z') }}""", hass, - "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", - ) - assert_result_info( - info, - "", - ["zone.test"], ) + value = datetime(2019, 1, 18, 12, 13, 14) + expected = str(value.replace(tzinfo=dt_util.UTC)) + assert tpl.async_render_with_possible_json_value(value) == expected - hass.states.async_set( - "person.person1", - "test", - ) - await hass.async_block_till_done() - info = render_to_info( - hass, - "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", - ) - assert_result_info( - info, - "person.person1", - ["zone.test", "person.person1"], +def test_render_with_possible_json_value_and_parse_result(hass: HomeAssistant) -> None: + """Render with possible JSON value with valid JSON.""" + tpl = template.Template("{{ value_json.hello }}", hass) + result = tpl.async_render_with_possible_json_value( + """{"hello": {"world": "value1"}}""", parse_result=True ) + assert isinstance(result, dict) - hass.states.async_set( - "person.person2", - "test", - ) - await hass.async_block_till_done() - info = render_to_info( - hass, - "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", - ) - assert_result_info( - info, - "person.person1, person.person2", - ["zone.test", "person.person1", "person.person2"], +def test_render_with_possible_json_value_and_dont_parse_result( + hass: HomeAssistant, +) -> None: + """Render with possible JSON value with valid JSON.""" + tpl = template.Template("{{ value_json.hello }}", hass) + result = tpl.async_render_with_possible_json_value( + """{"hello": {"world": "value1"}}""", parse_result=False ) + assert isinstance(result, str) -def test_closest_function_to_coord(hass: HomeAssistant) -> None: - """Test closest function to coord.""" - hass.states.async_set( - "test_domain.closest_home", - "happy", - { - "latitude": hass.config.latitude + 0.1, - "longitude": hass.config.longitude + 0.1, - }, - ) +@patch( + "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", + return_value=True, +) +def test_timedelta(mock_is_safe, hass: HomeAssistant) -> None: + """Test relative_time method.""" + now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") + with freeze_time(now): + result = render(hass, "{{timedelta(seconds=120)}}") + assert result == "0:02:00" - hass.states.async_set( - "test_domain.closest_zone", - "happy", - { - "latitude": hass.config.latitude + 0.2, - "longitude": hass.config.longitude + 0.2, - }, - ) + result = render(hass, "{{timedelta(seconds=86400)}}") + assert result == "1 day, 0:00:00" - hass.states.async_set( - "zone.far_away", - "zoning", - { - "latitude": hass.config.latitude + 0.3, - "longitude": hass.config.longitude + 0.3, - }, - ) + result = render(hass, "{{timedelta(days=1, hours=4)}}") + assert result == "1 day, 4:00:00" - result = render( - hass, - f'{{{{ closest("{hass.config.latitude + 0.3}", {hass.config.longitude + 0.3}, states.test_domain).entity_id }}}}', - ) - assert result == "test_domain.closest_zone" + result = render(hass, "{{relative_time(now() - timedelta(seconds=3600))}}") + assert result == "1 hour" - result = render( - hass, - f'{{{{ (states.test_domain | closest("{hass.config.latitude + 0.3}", {hass.config.longitude + 0.3})).entity_id }}}}', - ) - assert result == "test_domain.closest_zone" + result = render(hass, "{{relative_time(now() - timedelta(seconds=86400))}}") + assert result == "1 day" + + result = render(hass, "{{relative_time(now() - timedelta(seconds=86401))}}") + assert result == "1 day" + + result = render(hass, "{{relative_time(now() - timedelta(weeks=2, days=1))}}") + assert result == "15 days" def test_async_render_to_info_with_branching(hass: HomeAssistant) -> None: @@ -1467,231 +500,6 @@ def test_result_as_boolean(hass: HomeAssistant) -> None: assert template.result_as_boolean(None) is False -def test_closest_function_to_entity_id(hass: HomeAssistant) -> None: - """Test closest function to entity id.""" - hass.states.async_set( - "test_domain.closest_home", - "happy", - { - "latitude": hass.config.latitude + 0.1, - "longitude": hass.config.longitude + 0.1, - }, - ) - - hass.states.async_set( - "test_domain.closest_zone", - "happy", - { - "latitude": hass.config.latitude + 0.2, - "longitude": hass.config.longitude + 0.2, - }, - ) - - hass.states.async_set( - "zone.far_away", - "zoning", - { - "latitude": hass.config.latitude + 0.3, - "longitude": hass.config.longitude + 0.3, - }, - ) - - info = render_to_info( - hass, - "{{ closest(zone, states.test_domain).entity_id }}", - {"zone": "zone.far_away"}, - ) - - assert_result_info( - info, - "test_domain.closest_zone", - ["test_domain.closest_home", "test_domain.closest_zone", "zone.far_away"], - ["test_domain"], - ) - - info = render_to_info( - hass, - ( - "{{ ([states.test_domain, 'test_domain.closest_zone'] " - "| closest(zone)).entity_id }}" - ), - {"zone": "zone.far_away"}, - ) - - assert_result_info( - info, - "test_domain.closest_zone", - ["test_domain.closest_home", "test_domain.closest_zone", "zone.far_away"], - ["test_domain"], - ) - - -def test_closest_function_to_state(hass: HomeAssistant) -> None: - """Test closest function to state.""" - hass.states.async_set( - "test_domain.closest_home", - "happy", - { - "latitude": hass.config.latitude + 0.1, - "longitude": hass.config.longitude + 0.1, - }, - ) - - hass.states.async_set( - "test_domain.closest_zone", - "happy", - { - "latitude": hass.config.latitude + 0.2, - "longitude": hass.config.longitude + 0.2, - }, - ) - - hass.states.async_set( - "zone.far_away", - "zoning", - { - "latitude": hass.config.latitude + 0.3, - "longitude": hass.config.longitude + 0.3, - }, - ) - - assert ( - render( - hass, "{{ closest(states.zone.far_away, states.test_domain).entity_id }}" - ) - == "test_domain.closest_zone" - ) - - -def test_closest_function_invalid_state(hass: HomeAssistant) -> None: - """Test closest function invalid state.""" - hass.states.async_set( - "test_domain.closest_home", - "happy", - { - "latitude": hass.config.latitude + 0.1, - "longitude": hass.config.longitude + 0.1, - }, - ) - - for state in ("states.zone.non_existing", '"zone.non_existing"'): - assert render(hass, f"{{{{ closest({state}, states) }}}}") is None - - -def test_closest_function_state_with_invalid_location(hass: HomeAssistant) -> None: - """Test closest function state with invalid location.""" - hass.states.async_set( - "test_domain.closest_home", - "happy", - {"latitude": "invalid latitude", "longitude": hass.config.longitude + 0.1}, - ) - - assert ( - render(hass, "{{ closest(states.test_domain.closest_home, states) }}") is None - ) - - -def test_closest_function_invalid_coordinates(hass: HomeAssistant) -> None: - """Test closest function invalid coordinates.""" - hass.states.async_set( - "test_domain.closest_home", - "happy", - { - "latitude": hass.config.latitude + 0.1, - "longitude": hass.config.longitude + 0.1, - }, - ) - - assert render(hass, '{{ closest("invalid", "coord", states) }}') is None - assert render(hass, '{{ states | closest("invalid", "coord") }}') is None - - -def test_closest_function_no_location_states(hass: HomeAssistant) -> None: - """Test closest function without location states.""" - assert render(hass, "{{ closest(states).entity_id }}") == "" - - -def test_generate_filter_iterators(hass: HomeAssistant) -> None: - """Test extract entities function with none entities stuff.""" - info = render_to_info( - hass, - """ - {% for state in states %} - {{ state.entity_id }} - {% endfor %} - """, - ) - assert_result_info(info, "", all_states=True) - - info = render_to_info( - hass, - """ - {% for state in states.sensor %} - {{ state.entity_id }} - {% endfor %} - """, - ) - assert_result_info(info, "", domains=["sensor"]) - - hass.states.async_set("sensor.test_sensor", "off", {"attr": "value"}) - - # Don't need the entity because the state is not accessed - info = render_to_info( - hass, - """ - {% for state in states.sensor %} - {{ state.entity_id }} - {% endfor %} - """, - ) - assert_result_info(info, "sensor.test_sensor", domains=["sensor"]) - - # But we do here because the state gets accessed - info = render_to_info( - hass, - """ - {% for state in states.sensor %} - {{ state.entity_id }}={{ state.state }}, - {% endfor %} - """, - ) - assert_result_info(info, "sensor.test_sensor=off,", [], ["sensor"]) - - info = render_to_info( - hass, - """ - {% for state in states.sensor %} - {{ state.entity_id }}={{ state.attributes.attr }}, - {% endfor %} - """, - ) - assert_result_info(info, "sensor.test_sensor=value,", [], ["sensor"]) - - -def test_generate_select(hass: HomeAssistant) -> None: - """Test extract entities function with none entities stuff.""" - template_str = """ -{{ states.sensor|selectattr("state","equalto","off") -|join(",", attribute="entity_id") }} - """ - - info = render_to_info(hass, template_str) - assert_result_info(info, "", [], []) - assert info.domains_lifecycle == {"sensor"} - - hass.states.async_set("sensor.test_sensor", "off", {"attr": "value"}) - hass.states.async_set("sensor.test_sensor_on", "on") - - info = render_to_info(hass, template_str) - assert_result_info( - info, - "sensor.test_sensor", - [], - ["sensor"], - ) - assert info.domains_lifecycle == {"sensor"} - - async def test_async_render_to_info_in_conditional(hass: HomeAssistant) -> None: """Test extract entities function with none entities stuff.""" info = render_to_info(hass, '{{ states("sensor.xyz") == "dog" }}') @@ -1739,89 +547,6 @@ def test_jinja_namespace(hass: HomeAssistant) -> None: assert test_template.async_render() == "another value" -def test_state_with_unit(hass: HomeAssistant) -> None: - """Test the state_with_unit property helper.""" - hass.states.async_set("sensor.test", "23", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) - hass.states.async_set("sensor.test2", "wow") - - result = render(hass, "{{ states.sensor.test.state_with_unit }}") - assert result == "23 beers" - - result = render(hass, "{{ states.sensor.test2.state_with_unit }}") - assert result == "wow" - - result = render( - hass, "{% for state in states %}{{ state.state_with_unit }} {% endfor %}" - ) - assert result == "23 beers wow" - - result = render(hass, "{{ states.sensor.non_existing.state_with_unit }}") - assert result == "" - - -def test_state_with_unit_and_rounding( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test formatting the state rounded and with unit.""" - entry = entity_registry.async_get_or_create( - "sensor", "test", "very_unique", suggested_object_id="test" - ) - entity_registry.async_update_entity_options( - entry.entity_id, - "sensor", - { - "suggested_display_precision": 2, - }, - ) - assert entry.entity_id == "sensor.test" - - hass.states.async_set("sensor.test", "23", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) - hass.states.async_set("sensor.test2", "23", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) - hass.states.async_set("sensor.test3", "-0.0", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) - hass.states.async_set("sensor.test4", "-0", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) - - # state_with_unit property - tpl = template.Template("{{ states.sensor.test.state_with_unit }}", hass) - tpl2 = template.Template("{{ states.sensor.test2.state_with_unit }}", hass) - - # AllStates.__call__ defaults - tpl3 = template.Template("{{ states('sensor.test') }}", hass) - tpl4 = template.Template("{{ states('sensor.test2') }}", hass) - - # AllStates.__call__ and with_unit=True - tpl5 = template.Template("{{ states('sensor.test', with_unit=True) }}", hass) - tpl6 = template.Template("{{ states('sensor.test2', with_unit=True) }}", hass) - - # AllStates.__call__ and rounded=True - tpl7 = template.Template("{{ states('sensor.test', rounded=True) }}", hass) - tpl8 = template.Template("{{ states('sensor.test2', rounded=True) }}", hass) - tpl9 = template.Template("{{ states('sensor.test3', rounded=True) }}", hass) - tpl10 = template.Template("{{ states('sensor.test4', rounded=True) }}", hass) - - assert tpl.async_render() == "23.00 beers" - assert tpl2.async_render() == "23 beers" - assert tpl3.async_render() == 23 - assert tpl4.async_render() == 23 - assert tpl5.async_render() == "23.00 beers" - assert tpl6.async_render() == "23 beers" - assert tpl7.async_render() == 23.0 - assert tpl8.async_render() == 23 - assert tpl9.async_render() == 0.0 - assert tpl10.async_render() == 0 - - hass.states.async_set("sensor.test", "23.015", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) - hass.states.async_set("sensor.test2", "23.015", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) - - assert tpl.async_render() == "23.02 beers" - assert tpl2.async_render() == "23.015 beers" - assert tpl3.async_render() == 23.015 - assert tpl4.async_render() == 23.015 - assert tpl5.async_render() == "23.02 beers" - assert tpl6.async_render() == "23.015 beers" - assert tpl7.async_render() == 23.02 - assert tpl8.async_render() == 23.015 - - @pytest.mark.parametrize( ("rounded", "with_unit", "output1_1", "output1_2", "output2_1", "output2_2"), [ @@ -1896,28 +621,30 @@ def test_render_complex_handling_non_template_values(hass: HomeAssistant) -> Non ) == {True: 1, False: 2} -@pytest.mark.usefixtures("hass") -async def test_cache_garbage_collection() -> None: +async def test_cache_garbage_collection(hass: HomeAssistant) -> None: """Test caching a template.""" template_string = ( "{% set dict = {'foo': 'x&y', 'bar': 42} %} {{ dict | urlencode }}" ) tpl = template.Template( (template_string), + hass, ) tpl.ensure_valid() - assert template._NO_HASS_ENV.template_cache.get(template_string) + env = tpl._env + assert env.template_cache.get(template_string) tpl2 = template.Template( (template_string), + hass, ) tpl2.ensure_valid() - assert template._NO_HASS_ENV.template_cache.get(template_string) + assert env.template_cache.get(template_string) del tpl - assert template._NO_HASS_ENV.template_cache.get(template_string) + assert env.template_cache.get(template_string) del tpl2 - assert not template._NO_HASS_ENV.template_cache.get(template_string) + assert not env.template_cache.get(template_string) def test_is_template_string() -> None: @@ -2289,20 +1016,3 @@ def test_template_output_exceeds_maximum_size(hass: HomeAssistant) -> None: """Test template output exceeds maximum size.""" with pytest.raises(TemplateError): render(hass, "{{ 'a' * 1024 * 257 }}") - - -def test_warn_no_hass(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: - """Test deprecation warning when instantiating Template without hass.""" - - message = "Detected code that creates a template object without passing hass" - template.Template("blah") - assert message in caplog.text - caplog.clear() - - template.Template("blah", None) - assert message in caplog.text - caplog.clear() - - template.Template("blah", hass) - assert message not in caplog.text - caplog.clear() diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index be995e35248782..acd1e78a899e2b 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -2,6 +2,7 @@ from collections.abc import Mapping from contextlib import AbstractContextManager, nullcontext as does_not_raise +from dataclasses import dataclass, field from datetime import timedelta import io import logging @@ -23,6 +24,7 @@ from homeassistant.components.system_health import DOMAIN as SYSTEM_HEALTH_DOMAIN from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_LABEL_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_CONDITION, CONF_DEVICE_ID, @@ -43,6 +45,7 @@ condition, config_validation as cv, entity_registry as er, + label_registry as lr, trace, ) from homeassistant.helpers.automation import ( @@ -179,7 +182,7 @@ async def test_and_condition(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) with pytest.raises(ConditionError): - test(hass) + test.async_check() assert_condition_trace( { "": [{"error_type": ConditionError}], @@ -191,7 +194,7 @@ async def test_and_condition(hass: HomeAssistant) -> None: ) hass.states.async_set("sensor.temperature", 120) - assert not test(hass) + assert not test.async_check() assert_condition_trace( { "": [{"result": {"result": False}}], @@ -203,7 +206,7 @@ async def test_and_condition(hass: HomeAssistant) -> None: ) hass.states.async_set("sensor.temperature", 105) - assert not test(hass) + assert not test.async_check() assert_condition_trace( { "": [{"result": {"result": False}}], @@ -215,7 +218,7 @@ async def test_and_condition(hass: HomeAssistant) -> None: ) hass.states.async_set("sensor.temperature", 100) - assert test(hass) + assert test.async_check() assert_condition_trace( { "": [{"result": {"result": True}}], @@ -253,7 +256,7 @@ async def test_and_condition_raises(hass: HomeAssistant) -> None: # All subconditions raise, the AND-condition should raise with pytest.raises(ConditionError): - test(hass) + test.async_check() assert_condition_trace( { "": [{"error_type": ConditionError}], @@ -268,7 +271,7 @@ async def test_and_condition_raises(hass: HomeAssistant) -> None: # should raise hass.states.async_set("sensor.temperature2", 120) with pytest.raises(ConditionError): - test(hass) + test.async_check() assert_condition_trace( { "": [{"error_type": ConditionError}], @@ -282,7 +285,7 @@ async def test_and_condition_raises(hass: HomeAssistant) -> None: # The first subconditions raises, the second returns False, the AND-condition # should return False hass.states.async_set("sensor.temperature2", 90) - assert not test(hass) + assert not test.async_check() assert_condition_trace( { "": [{"result": {"result": False}}], @@ -324,7 +327,7 @@ async def test_and_condition_with_template(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 120) - assert not test(hass) + assert not test.async_check() assert_condition_trace( { "": [{"result": {"result": False}}], @@ -335,10 +338,10 @@ async def test_and_condition_with_template(hass: HomeAssistant) -> None: ) hass.states.async_set("sensor.temperature", 105) - assert not test(hass) + assert not test.async_check() hass.states.async_set("sensor.temperature", 100) - assert test(hass) + assert test.async_check() async def test_and_condition_shorthand(hass: HomeAssistant) -> None: @@ -366,7 +369,7 @@ async def test_and_condition_shorthand(hass: HomeAssistant) -> None: assert "and" not in config hass.states.async_set("sensor.temperature", 120) - assert not test(hass) + assert not test.async_check() assert_condition_trace( { "": [{"result": {"result": False}}], @@ -377,10 +380,10 @@ async def test_and_condition_shorthand(hass: HomeAssistant) -> None: ) hass.states.async_set("sensor.temperature", 105) - assert not test(hass) + assert not test.async_check() hass.states.async_set("sensor.temperature", 100) - assert test(hass) + assert test.async_check() async def test_and_condition_list_shorthand(hass: HomeAssistant) -> None: @@ -408,7 +411,7 @@ async def test_and_condition_list_shorthand(hass: HomeAssistant) -> None: assert "and" not in config hass.states.async_set("sensor.temperature", 120) - assert not test(hass) + assert not test.async_check() assert_condition_trace( { "": [{"result": {"result": False}}], @@ -419,10 +422,68 @@ async def test_and_condition_list_shorthand(hass: HomeAssistant) -> None: ) hass.states.async_set("sensor.temperature", 105) - assert not test(hass) + assert not test.async_check() hass.states.async_set("sensor.temperature", 100) - assert test(hass) + assert test.async_check() + + +async def test_conditions_from_config_has_and_semantics( + hass: HomeAssistant, +) -> None: + """Test that async_conditions_from_config returns a callable with AND semantics.""" + hass.states.async_set("binary_sensor.test_one", STATE_ON) + hass.states.async_set("binary_sensor.test_two", STATE_ON) + configs = await condition.async_validate_conditions_config( + hass, + [ + { + "condition": "state", + "entity_id": "binary_sensor.test_one", + "state": STATE_ON, + }, + { + "condition": "state", + "entity_id": "binary_sensor.test_two", + "state": STATE_ON, + }, + ], + ) + test = await condition.async_conditions_from_config( + hass, configs, logging.getLogger(__name__), "test" + ) + assert test.async_check() is True + hass.states.async_set("binary_sensor.test_two", STATE_OFF) + assert test.async_check() is False + + +async def test_conditions_from_config_forwards_call( + hass: HomeAssistant, +) -> None: + """Test that async_conditions_from_config forwards call.""" + hass.states.async_set("binary_sensor.test_one", STATE_ON) + hass.states.async_set("binary_sensor.test_two", STATE_ON) + configs = await condition.async_validate_conditions_config( + hass, + [ + { + "condition": "state", + "entity_id": "binary_sensor.test_one", + "state": STATE_ON, + }, + { + "condition": "state", + "entity_id": "binary_sensor.test_two", + "state": STATE_ON, + }, + ], + ) + test = await condition.async_conditions_from_config( + hass, configs, logging.getLogger(__name__), "test" + ) + assert test() is True + hass.states.async_set("binary_sensor.test_two", STATE_OFF) + assert test() is False async def test_malformed_and_condition_list_shorthand(hass: HomeAssistant) -> None: @@ -459,7 +520,7 @@ async def test_or_condition(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) with pytest.raises(ConditionError): - test(hass) + test.async_check() assert_condition_trace( { "": [{"error_type": ConditionError}], @@ -471,7 +532,7 @@ async def test_or_condition(hass: HomeAssistant) -> None: ) hass.states.async_set("sensor.temperature", 120) - assert not test(hass) + assert not test.async_check() assert_condition_trace( { "": [{"result": {"result": False}}], @@ -493,7 +554,7 @@ async def test_or_condition(hass: HomeAssistant) -> None: ) hass.states.async_set("sensor.temperature", 105) - assert test(hass) + assert test.async_check() assert_condition_trace( { "": [{"result": {"result": True}}], @@ -507,7 +568,7 @@ async def test_or_condition(hass: HomeAssistant) -> None: ) hass.states.async_set("sensor.temperature", 100) - assert test(hass) + assert test.async_check() assert_condition_trace( { "": [{"result": {"result": True}}], @@ -543,7 +604,7 @@ async def test_or_condition_raises(hass: HomeAssistant) -> None: # All subconditions raise, the OR-condition should raise with pytest.raises(ConditionError): - test(hass) + test.async_check() assert_condition_trace( { "": [{"error_type": ConditionError}], @@ -558,7 +619,7 @@ async def test_or_condition_raises(hass: HomeAssistant) -> None: # should raise hass.states.async_set("sensor.temperature2", 100) with pytest.raises(ConditionError): - test(hass) + test.async_check() assert_condition_trace( { "": [{"error_type": ConditionError}], @@ -580,7 +641,7 @@ async def test_or_condition_raises(hass: HomeAssistant) -> None: # The first subconditions raises, the second returns True, the OR-condition # should return True hass.states.async_set("sensor.temperature2", 120) - assert test(hass) + assert test.async_check() assert_condition_trace( { "": [{"result": {"result": True}}], @@ -610,13 +671,13 @@ async def test_or_condition_with_template(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 120) - assert not test(hass) + assert not test.async_check() hass.states.async_set("sensor.temperature", 105) - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.temperature", 100) - assert test(hass) + assert test.async_check() async def test_or_condition_shorthand(hass: HomeAssistant) -> None: @@ -640,13 +701,13 @@ async def test_or_condition_shorthand(hass: HomeAssistant) -> None: assert "or" not in config hass.states.async_set("sensor.temperature", 120) - assert not test(hass) + assert not test.async_check() hass.states.async_set("sensor.temperature", 105) - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.temperature", 100) - assert test(hass) + assert test.async_check() async def test_not_condition(hass: HomeAssistant) -> None: @@ -672,7 +733,7 @@ async def test_not_condition(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) with pytest.raises(ConditionError): - test(hass) + test.async_check() assert_condition_trace( { "": [{"error_type": ConditionError}], @@ -684,7 +745,7 @@ async def test_not_condition(hass: HomeAssistant) -> None: ) hass.states.async_set("sensor.temperature", 101) - assert test(hass) + assert test.async_check() assert_condition_trace( { "": [{"result": {"result": True}}], @@ -706,7 +767,7 @@ async def test_not_condition(hass: HomeAssistant) -> None: ) hass.states.async_set("sensor.temperature", 50) - assert test(hass) + assert test.async_check() assert_condition_trace( { "": [{"result": {"result": True}}], @@ -722,7 +783,7 @@ async def test_not_condition(hass: HomeAssistant) -> None: ) hass.states.async_set("sensor.temperature", 49) - assert not test(hass) + assert not test.async_check() assert_condition_trace( { "": [{"result": {"result": False}}], @@ -736,7 +797,7 @@ async def test_not_condition(hass: HomeAssistant) -> None: ) hass.states.async_set("sensor.temperature", 100) - assert not test(hass) + assert not test.async_check() assert_condition_trace( { "": [{"result": {"result": False}}], @@ -772,7 +833,7 @@ async def test_not_condition_raises(hass: HomeAssistant) -> None: # All subconditions raise, the NOT-condition should raise with pytest.raises(ConditionError): - test(hass) + test.async_check() assert_condition_trace( { "": [{"error_type": ConditionError}], @@ -787,7 +848,7 @@ async def test_not_condition_raises(hass: HomeAssistant) -> None: # should raise hass.states.async_set("sensor.temperature2", 90) with pytest.raises(ConditionError): - test(hass) + test.async_check() assert_condition_trace( { "": [{"error_type": ConditionError}], @@ -803,7 +864,7 @@ async def test_not_condition_raises(hass: HomeAssistant) -> None: # The first subconditions raises, the second returns True, the NOT-condition # should return False hass.states.async_set("sensor.temperature2", 40) - assert not test(hass) + assert not test.async_check() assert_condition_trace( { "": [{"result": {"result": False}}], @@ -836,16 +897,16 @@ async def test_not_condition_with_template(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 101) - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.temperature", 50) - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.temperature", 49) - assert not test(hass) + assert not test.async_check() hass.states.async_set("sensor.temperature", 100) - assert not test(hass) + assert not test.async_check() async def test_not_condition_shorthand(hass: HomeAssistant) -> None: @@ -872,16 +933,16 @@ async def test_not_condition_shorthand(hass: HomeAssistant) -> None: assert "not" not in config hass.states.async_set("sensor.temperature", 101) - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.temperature", 50) - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.temperature", 49) - assert not test(hass) + assert not test.async_check() hass.states.async_set("sensor.temperature", 100) - assert not test(hass) + assert not test.async_check() async def test_time_window(hass: HomeAssistant) -> None: @@ -912,29 +973,29 @@ async def test_time_window(hass: HomeAssistant) -> None: "homeassistant.helpers.condition.dt_util.now", return_value=dt_util.now().replace(hour=3), ): - assert not test1(hass) - assert test2(hass) + assert not test1.async_check() + assert test2.async_check() with patch( "homeassistant.helpers.condition.dt_util.now", return_value=dt_util.now().replace(hour=9), ): - assert test1(hass) - assert not test2(hass) + assert test1.async_check() + assert not test2.async_check() with patch( "homeassistant.helpers.condition.dt_util.now", return_value=dt_util.now().replace(hour=15), ): - assert test1(hass) - assert not test2(hass) + assert test1.async_check() + assert not test2.async_check() with patch( "homeassistant.helpers.condition.dt_util.now", return_value=dt_util.now().replace(hour=21), ): - assert not test1(hass) - assert test2(hass) + assert not test1.async_check() + assert test2.async_check() async def test_time_using_input_datetime(hass: HomeAssistant) -> None: @@ -1146,6 +1207,11 @@ async def test_time_using_sensor(hass: HomeAssistant) -> None: "2020-06-01 01:00:00.000000+00:00", # 6 pm local time {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, ) + hass.states.async_set( + "sensor.uptime_am", + "2021-06-03 13:00:00.000000+00:00", # 6 am local time + {ATTR_DEVICE_CLASS: SensorDeviceClass.UPTIME}, + ) hass.states.async_set( "sensor.no_device_class", "2020-06-01 01:00:00.000000+00:00", @@ -1168,6 +1234,7 @@ async def test_time_using_sensor(hass: HomeAssistant) -> None: return_value=dt_util.now().replace(hour=9), ): assert condition.time(hass, after="sensor.am", before="sensor.pm") + assert condition.time(hass, after="sensor.uptime_am", before="sensor.pm") assert not condition.time(hass, after="sensor.pm", before="sensor.am") with patch( @@ -1234,9 +1301,9 @@ async def test_state_raises(hass: HomeAssistant) -> None: config = await condition.async_validate_condition_config(hass, config) test = await condition.async_from_config(hass, config) with pytest.raises(ConditionError, match="unknown entity.*door"): - test(hass) + test.async_check() with pytest.raises(ConditionError, match="unknown entity.*window"): - test(hass) + test.async_check() # Unknown state entity @@ -1251,7 +1318,7 @@ async def test_state_raises(hass: HomeAssistant) -> None: hass.states.async_set("sensor.door", "open") with pytest.raises(ConditionError, match="input_text.missing"): - test(hass) + test.async_check() async def test_state_for(hass: HomeAssistant) -> None: @@ -1272,11 +1339,11 @@ async def test_state_for(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 100) - assert not test(hass) + assert not test.async_check() now = dt_util.utcnow() + timedelta(seconds=5) with freeze_time(now): - assert test(hass) + assert test.async_check() async def test_state_for_template(hass: HomeAssistant) -> None: @@ -1298,11 +1365,11 @@ async def test_state_for_template(hass: HomeAssistant) -> None: hass.states.async_set("sensor.temperature", 100) hass.states.async_set("input_number.test", 5) - assert not test(hass) + assert not test.async_check() now = dt_util.utcnow() + timedelta(seconds=5) with freeze_time(now): - assert test(hass) + assert test.async_check() @pytest.mark.parametrize("for_template", [{"{{invalid}}": 5}, {"hours": "{{ 1/0 }}"}]) @@ -1328,7 +1395,7 @@ async def test_state_for_invalid_template( hass.states.async_set("sensor.temperature", 100) hass.states.async_set("input_number.test", 5) with pytest.raises(ConditionError): - assert not test(hass) + assert not test.async_check() async def test_state_unknown_attribute(hass: HomeAssistant) -> None: @@ -1345,7 +1412,7 @@ async def test_state_unknown_attribute(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.door", "open") - assert not test(hass) + assert not test.async_check() assert_condition_trace( { "": [{"result": {"result": False}}], @@ -1381,15 +1448,15 @@ async def test_state_multiple_entities(hass: HomeAssistant) -> None: hass.states.async_set("sensor.temperature_1", 100) hass.states.async_set("sensor.temperature_2", 100) - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.temperature_1", 101) hass.states.async_set("sensor.temperature_2", 100) - assert not test(hass) + assert not test.async_check() hass.states.async_set("sensor.temperature_1", 100) hass.states.async_set("sensor.temperature_2", 101) - assert not test(hass) + assert not test.async_check() async def test_state_multiple_entities_match_any(hass: HomeAssistant) -> None: @@ -1411,19 +1478,19 @@ async def test_state_multiple_entities_match_any(hass: HomeAssistant) -> None: hass.states.async_set("sensor.temperature_1", 100) hass.states.async_set("sensor.temperature_2", 100) - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.temperature_1", 101) hass.states.async_set("sensor.temperature_2", 100) - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.temperature_1", 100) hass.states.async_set("sensor.temperature_2", 101) - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.temperature_1", 101) hass.states.async_set("sensor.temperature_2", 101) - assert not test(hass) + assert not test.async_check() async def test_multiple_states(hass: HomeAssistant) -> None: @@ -1444,13 +1511,13 @@ async def test_multiple_states(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 100) - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.temperature", 200) - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.temperature", 42) - assert not test(hass) + assert not test.async_check() async def test_state_attribute(hass: HomeAssistant) -> None: @@ -1471,19 +1538,19 @@ async def test_state_attribute(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 100, {"unknown_attr": 200}) - assert not test(hass) + assert not test.async_check() hass.states.async_set("sensor.temperature", 100, {"attribute1": 200}) - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.temperature", 100, {"attribute1": "200"}) - assert not test(hass) + assert not test.async_check() hass.states.async_set("sensor.temperature", 100, {"attribute1": 201}) - assert not test(hass) + assert not test.async_check() hass.states.async_set("sensor.temperature", 100, {"attribute1": None}) - assert not test(hass) + assert not test.async_check() async def test_state_attribute_boolean(hass: HomeAssistant) -> None: @@ -1499,16 +1566,16 @@ async def test_state_attribute_boolean(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 100, {"happening": 200}) - assert not test(hass) + assert not test.async_check() hass.states.async_set("sensor.temperature", 100, {"happening": True}) - assert not test(hass) + assert not test.async_check() hass.states.async_set("sensor.temperature", 100, {"no_happening": 201}) - assert not test(hass) + assert not test.async_check() hass.states.async_set("sensor.temperature", 100, {"happening": False}) - assert test(hass) + assert test.async_check() async def test_state_entity_registry_id( @@ -1529,10 +1596,10 @@ async def test_state_entity_registry_id( test = await condition.async_from_config(hass, config) hass.states.async_set("switch.test", "on") - assert test(hass) + assert test.async_check() hass.states.async_set("switch.test", "off") - assert not test(hass) + assert not test.async_check() async def test_state_using_input_entities(hass: HomeAssistant) -> None: @@ -1576,13 +1643,13 @@ async def test_state_using_input_entities(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.salut", "goodbye") - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.salut", "salut") - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.salut", "hello") - assert not test(hass) + assert not test.async_check() await hass.services.async_call( "input_text", @@ -1593,13 +1660,13 @@ async def test_state_using_input_entities(hass: HomeAssistant) -> None: }, blocking=True, ) - assert not test(hass) + assert not test.async_check() hass.states.async_set("sensor.salut", "hi") - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.salut", "cya") - assert test(hass) + assert test.async_check() await hass.services.async_call( "input_select", @@ -1610,10 +1677,10 @@ async def test_state_using_input_entities(hass: HomeAssistant) -> None: }, blocking=True, ) - assert not test(hass) + assert not test.async_check() hass.states.async_set("sensor.salut", "welcome") - assert test(hass) + assert test.async_check() async def test_numeric_state_known_non_matching(hass: HomeAssistant) -> None: @@ -1629,7 +1696,7 @@ async def test_numeric_state_known_non_matching(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) # Unavailable state - assert not test(hass) + assert not test.async_check() assert_condition_trace( { @@ -1649,7 +1716,7 @@ async def test_numeric_state_known_non_matching(hass: HomeAssistant) -> None: # Unknown state hass.states.async_set("sensor.temperature", "unknown") - assert not test(hass) + assert not test.async_check() assert_condition_trace( { @@ -1680,9 +1747,9 @@ async def test_numeric_state_raises(hass: HomeAssistant) -> None: config = await condition.async_validate_condition_config(hass, config) test = await condition.async_from_config(hass, config) with pytest.raises(ConditionError, match="unknown entity.*temperature"): - test(hass) + test.async_check() with pytest.raises(ConditionError, match="unknown entity.*humidity"): - test(hass) + test.async_check() # Template error config = { @@ -1697,7 +1764,7 @@ async def test_numeric_state_raises(hass: HomeAssistant) -> None: hass.states.async_set("sensor.temperature", 50) with pytest.raises(ConditionError, match="ZeroDivisionError"): - test(hass) + test.async_check() # Bad number config = { @@ -1711,7 +1778,7 @@ async def test_numeric_state_raises(hass: HomeAssistant) -> None: hass.states.async_set("sensor.temperature", "fifty") with pytest.raises(ConditionError, match="cannot be processed as a number"): - test(hass) + test.async_check() # Below entity missing config = { @@ -1725,7 +1792,7 @@ async def test_numeric_state_raises(hass: HomeAssistant) -> None: hass.states.async_set("sensor.temperature", 50) with pytest.raises(ConditionError, match="'below' entity"): - test(hass) + test.async_check() # Below entity not a number hass.states.async_set("input_number.missing", "number") @@ -1733,7 +1800,7 @@ async def test_numeric_state_raises(hass: HomeAssistant) -> None: ConditionError, match="'below'.*input_number.missing.*cannot be processed as a number", ): - test(hass) + test.async_check() # Above entity missing config = { @@ -1747,7 +1814,7 @@ async def test_numeric_state_raises(hass: HomeAssistant) -> None: hass.states.async_set("sensor.temperature", 50) with pytest.raises(ConditionError, match="'above' entity"): - test(hass) + test.async_check() # Above entity not a number hass.states.async_set("input_number.missing", "number") @@ -1755,7 +1822,7 @@ async def test_numeric_state_raises(hass: HomeAssistant) -> None: ConditionError, match="'above'.*input_number.missing.*cannot be processed as a number", ): - test(hass) + test.async_check() async def test_numeric_state_unknown_attribute(hass: HomeAssistant) -> None: @@ -1772,7 +1839,7 @@ async def test_numeric_state_unknown_attribute(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 50) - assert not test(hass) + assert not test.async_check() assert_condition_trace( { "": [{"result": {"result": False}}], @@ -1810,15 +1877,15 @@ async def test_numeric_state_multiple_entities(hass: HomeAssistant) -> None: hass.states.async_set("sensor.temperature_1", 49) hass.states.async_set("sensor.temperature_2", 49) - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.temperature_1", 50) hass.states.async_set("sensor.temperature_2", 49) - assert not test(hass) + assert not test.async_check() hass.states.async_set("sensor.temperature_1", 49) hass.states.async_set("sensor.temperature_2", 50) - assert not test(hass) + assert not test.async_check() async def test_numeric_state_attribute(hass: HomeAssistant) -> None: @@ -1839,19 +1906,19 @@ async def test_numeric_state_attribute(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 100, {"unknown_attr": 10}) - assert not test(hass) + assert not test.async_check() hass.states.async_set("sensor.temperature", 100, {"attribute1": 49}) - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.temperature", 100, {"attribute1": "49"}) - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.temperature", 100, {"attribute1": 51}) - assert not test(hass) + assert not test.async_check() hass.states.async_set("sensor.temperature", 100, {"attribute1": None}) - assert not test(hass) + assert not test.async_check() async def test_numeric_state_entity_registry_id( @@ -1872,10 +1939,10 @@ async def test_numeric_state_entity_registry_id( test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.test", "110") - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.test", "90") - assert not test(hass) + assert not test.async_check() async def test_numeric_state_using_input_number(hass: HomeAssistant) -> None: @@ -1907,19 +1974,19 @@ async def test_numeric_state_using_input_number(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 42) - assert test(hass) + assert test.async_check() hass.states.async_set("sensor.temperature", 10) - assert not test(hass) + assert not test.async_check() hass.states.async_set("sensor.temperature", 100) - assert not test(hass) + assert not test.async_check() hass.states.async_set("input_number.high", "unknown") - assert not test(hass) + assert not test.async_check() hass.states.async_set("input_number.high", "unavailable") - assert not test(hass) + assert not test.async_check() await hass.services.async_call( "input_number", @@ -1930,13 +1997,13 @@ async def test_numeric_state_using_input_number(hass: HomeAssistant) -> None: }, blocking=True, ) - assert test(hass) + assert test.async_check() hass.states.async_set("number.low", "unknown") - assert not test(hass) + assert not test.async_check() hass.states.async_set("number.low", "unavailable") - assert not test(hass) + assert not test.async_check() with pytest.raises(ConditionError): condition.async_numeric_state( @@ -1948,8 +2015,7 @@ async def test_numeric_state_using_input_number(hass: HomeAssistant) -> None: ) -@pytest.mark.usefixtures("hass") -async def test_extract_entities() -> None: +async def test_extract_entities(hass: HomeAssistant) -> None: """Test extracting entities.""" assert condition.async_extract_entities( { @@ -2005,7 +2071,7 @@ async def test_extract_entities() -> None: "entity_id": ["sensor.temperature_9", "sensor.temperature_10"], "below": 110, }, - Template("{{ is_state('light.example', 'on') }}"), + Template("{{ is_state('light.example', 'on') }}", hass), ], } ) == { @@ -2022,8 +2088,7 @@ async def test_extract_entities() -> None: } -@pytest.mark.usefixtures("hass") -async def test_extract_devices() -> None: +async def test_extract_devices(hass: HomeAssistant) -> None: """Test extracting devices.""" assert condition.async_extract_devices( { @@ -2066,7 +2131,7 @@ async def test_extract_devices() -> None: }, ], }, - Template("{{ is_state('light.example', 'on') }}"), + Template("{{ is_state('light.example', 'on') }}", hass), ], } ) == {"abcd", "qwer", "abcd_not", "qwer_not", "abcd_or", "qwer_or"} @@ -2080,7 +2145,7 @@ async def test_condition_template_error(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) with pytest.raises(ConditionError, match="template"): - test(hass) + test.async_check() async def test_condition_template_invalid_results(hass: HomeAssistant) -> None: @@ -2089,25 +2154,25 @@ async def test_condition_template_invalid_results(hass: HomeAssistant) -> None: config = cv.CONDITION_SCHEMA(config) config = await condition.async_validate_condition_config(hass, config) test = await condition.async_from_config(hass, config) - assert not test(hass) + assert not test.async_check() config = {"condition": "template", "value_template": "{{ 10.1 }}"} config = cv.CONDITION_SCHEMA(config) config = await condition.async_validate_condition_config(hass, config) test = await condition.async_from_config(hass, config) - assert not test(hass) + assert not test.async_check() config = {"condition": "template", "value_template": "{{ 42 }}"} config = cv.CONDITION_SCHEMA(config) config = await condition.async_validate_condition_config(hass, config) test = await condition.async_from_config(hass, config) - assert not test(hass) + assert not test.async_check() config = {"condition": "template", "value_template": "{{ [1, 2, 3] }}"} config = cv.CONDITION_SCHEMA(config) config = await condition.async_validate_condition_config(hass, config) test = await condition.async_from_config(hass, config) - assert not test(hass) + assert not test.async_check() async def test_trigger(hass: HomeAssistant) -> None: @@ -2117,11 +2182,11 @@ async def test_trigger(hass: HomeAssistant) -> None: config = await condition.async_validate_condition_config(hass, config) test = await condition.async_from_config(hass, config) - assert not test(hass) - assert not test(hass, {}) - assert not test(hass, {"other_var": "123456"}) - assert not test(hass, {"trigger": {"trigger_id": "123456"}}) - assert test(hass, {"trigger": {"id": "123456"}}) + assert not test.async_check() + assert not test.async_check(variables={}) + assert not test.async_check(variables={"other_var": "123456"}) + assert not test.async_check(variables={"trigger": {"trigger_id": "123456"}}) + assert test.async_check(variables={"trigger": {"id": "123456"}}) async def test_platform_async_get_conditions(hass: HomeAssistant) -> None: @@ -2183,11 +2248,11 @@ async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition] ): await async_validate_condition_config(hass, config_3) - cond_func = await condition.async_from_config(hass, config_1) - assert cond_func(hass, {}) is True + cond = await condition.async_from_config(hass, config_1) + assert cond.async_check(variables={}) is True - cond_func = await condition.async_from_config(hass, config_2) - assert cond_func(hass, {}) is False + cond = await condition.async_from_config(hass, config_2) + assert cond.async_check(variables={}) is False with pytest.raises(KeyError): await condition.async_from_config(hass, config_3) @@ -2352,11 +2417,11 @@ async def test_enabled_condition( test = await condition.async_from_config(hass, config) hass.states.async_set("binary_sensor.test", "on") - assert test(hass) is True + assert test.async_check() is True # Still passes, condition is not enabled hass.states.async_set("binary_sensor.test", "off") - assert test(hass) is False + assert test.async_check() is False @pytest.mark.parametrize("enabled_value", [False, "{{ 1 == 9 }}"]) @@ -2375,11 +2440,11 @@ async def test_disabled_condition( test = await condition.async_from_config(hass, config) hass.states.async_set("binary_sensor.test", "on") - assert test(hass) is None + assert test.async_check() is None # Still passes, condition is not enabled hass.states.async_set("binary_sensor.test", "off") - assert test(hass) is None + assert test.async_check() is None async def test_condition_enabled_template_limited(hass: HomeAssistant) -> None: @@ -2421,7 +2486,7 @@ async def test_and_condition_with_disabled_condition(hass: HomeAssistant) -> Non test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 120) - assert not test(hass) + assert not test.async_check() assert_condition_trace( { "": [{"result": {"result": False}}], @@ -2440,7 +2505,7 @@ async def test_and_condition_with_disabled_condition(hass: HomeAssistant) -> Non ) hass.states.async_set("sensor.temperature", 105) - assert test(hass) + assert test.async_check() assert_condition_trace( { "": [{"result": {"result": True}}], @@ -2451,7 +2516,7 @@ async def test_and_condition_with_disabled_condition(hass: HomeAssistant) -> Non ) hass.states.async_set("sensor.temperature", 100) - assert test(hass) + assert test.async_check() assert_condition_trace( { "": [{"result": {"result": True}}], @@ -2486,7 +2551,7 @@ async def test_or_condition_with_disabled_condition(hass: HomeAssistant) -> None test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 120) - assert not test(hass) + assert not test.async_check() assert_condition_trace( { "": [{"result": {"result": False}}], @@ -2505,7 +2570,7 @@ async def test_or_condition_with_disabled_condition(hass: HomeAssistant) -> None ) hass.states.async_set("sensor.temperature", 105) - assert test(hass) + assert test.async_check() assert_condition_trace( { "": [{"result": {"result": True}}], @@ -2516,7 +2581,7 @@ async def test_or_condition_with_disabled_condition(hass: HomeAssistant) -> None ) hass.states.async_set("sensor.temperature", 100) - assert test(hass) + assert test.async_check() assert_condition_trace( { "": [{"result": {"result": True}}], @@ -3219,7 +3284,7 @@ async def test_numerical_condition_thresholds( ) hass.states.async_set("test.entity_1", state_value) - assert test(hass) is expected + assert test.async_check() is expected @pytest.mark.parametrize( @@ -3237,7 +3302,7 @@ async def test_numerical_condition_invalid_state( ) hass.states.async_set("test.entity_1", state_value) - assert test(hass) is False + assert test.async_check() is False async def test_numerical_condition_attribute_value_source( @@ -3253,15 +3318,15 @@ async def test_numerical_condition_attribute_value_source( # Attribute above threshold -> True hass.states.async_set("test.entity_1", "on", {"brightness": 200}) - assert test(hass) is True + assert test.async_check() is True # Attribute below threshold -> False hass.states.async_set("test.entity_1", "on", {"brightness": 50}) - assert test(hass) is False + assert test.async_check() is False # Missing attribute -> False hass.states.async_set("test.entity_1", "on", {}) - assert test(hass) is False + assert test.async_check() is False async def test_numerical_condition_attribute_value_source_skips_unit_check( @@ -3284,10 +3349,10 @@ async def test_numerical_condition_attribute_value_source_skips_unit_check( # Entity has no ATTR_UNIT_OF_MEASUREMENT but has the attribute value # The unit check should be skipped for attribute-based value sources hass.states.async_set("test.entity_1", "auto", {"humidity": 75}) - assert test(hass) is True + assert test.async_check() is True hass.states.async_set("test.entity_1", "auto", {"humidity": 25}) - assert test(hass) is False + assert test.async_check() is False @pytest.mark.parametrize( @@ -3322,7 +3387,7 @@ async def test_numerical_condition_valid_unit( attrs = {ATTR_UNIT_OF_MEASUREMENT: entity_unit} if entity_unit else {} hass.states.async_set("test.entity_1", "75", attrs) - assert test(hass) is expected + assert test.async_check() is expected @pytest.mark.parametrize( @@ -3350,15 +3415,15 @@ async def test_numerical_condition_behavior( # Both above -> True for any and all hass.states.async_set("test.entity_1", "75") hass.states.async_set("test.entity_2", "80") - assert test(hass) is True + assert test.async_check() is True # Only one above -> depends on behavior hass.states.async_set("test.entity_2", "25") - assert test(hass) is one_match_expected + assert test.async_check() is one_match_expected # Neither above -> False for any and all hass.states.async_set("test.entity_1", "25") - assert test(hass) is False + assert test.async_check() is False async def test_numerical_condition_schema_requires_above_or_below( @@ -3590,7 +3655,7 @@ async def test_numerical_condition_with_unit_thresholds( state_value, {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ) - assert test(hass) is expected + assert test.async_check() is expected async def test_numerical_condition_with_unit_entity_reference( @@ -3617,7 +3682,7 @@ async def test_numerical_condition_with_unit_entity_reference( "75", {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT}, ) - assert test(hass) is True + assert test.async_check() is True # 75°F ≈ 23.89°C, 20°C < 23.89°C → False hass.states.async_set( @@ -3625,7 +3690,7 @@ async def test_numerical_condition_with_unit_entity_reference( "20", {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ) - assert test(hass) is False + assert test.async_check() is False async def test_numerical_condition_with_unit_entity_reference_incompatible_unit( @@ -3651,7 +3716,7 @@ async def test_numerical_condition_with_unit_entity_reference_incompatible_unit( "75", {ATTR_UNIT_OF_MEASUREMENT: "%"}, ) - assert test(hass) is False + assert test.async_check() is False async def test_numerical_condition_with_unit_tracked_value_conversion( @@ -3675,7 +3740,7 @@ async def test_numerical_condition_with_unit_tracked_value_conversion( "80", {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT}, ) - assert test(hass) is True + assert test.async_check() is True # Entity reports in °F: 50°F ≈ 10°C < 20°C → False hass.states.async_set( @@ -3683,7 +3748,7 @@ async def test_numerical_condition_with_unit_tracked_value_conversion( "50", {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT}, ) - assert test(hass) is False + assert test.async_check() is False async def test_numerical_condition_with_unit_attribute_value_source( @@ -3713,7 +3778,7 @@ async def test_numerical_condition_with_unit_attribute_value_source( ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, }, ) - assert test(hass) is True + assert test.async_check() is True # 75°F ≈ 23.89°C, attribute=20°C < 23.89°C → False hass.states.async_set( @@ -3724,11 +3789,11 @@ async def test_numerical_condition_with_unit_attribute_value_source( ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, }, ) - assert test(hass) is False + assert test.async_check() is False # Missing attribute → False hass.states.async_set("test.entity_1", "on", {}) - assert test(hass) is False + assert test.async_check() is False async def test_numerical_condition_with_unit_get_entity_unit_override( @@ -3773,11 +3838,11 @@ async def async_get_conditions( # Entity attribute is 80 — _get_entity_unit returns °F, # so 80°F ≈ 26.67°C > 20°C → True hass.states.async_set("test.entity_1", "on", {"temperature": 80}) - assert test(hass) is True + assert test.async_check() is True # Entity attribute is 50 — 50°F ≈ 10°C < 20°C → False hass.states.async_set("test.entity_1", "on", {"temperature": 50}) - assert test(hass) is False + assert test.async_check() is False async def test_numerical_condition_with_unit_schema_accepts_valid_units( @@ -3870,7 +3935,7 @@ async def test_numerical_condition_with_unit_invalid_state( state_value, {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ) - assert test(hass) is False + assert test.async_check() is False async def test_numerical_condition_with_unit_missing_entity_reference( @@ -3890,7 +3955,7 @@ async def test_numerical_condition_with_unit_missing_entity_reference( "25", {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ) - assert test(hass) is False + assert test.async_check() is False @pytest.mark.parametrize( @@ -3929,7 +3994,7 @@ async def test_numerical_condition_with_unit_behavior( "80", {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ) - assert test(hass) is True + assert test.async_check() is True # Only one above → depends on behavior hass.states.async_set( @@ -3937,7 +4002,7 @@ async def test_numerical_condition_with_unit_behavior( "25", {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ) - assert test(hass) is one_match_expected + assert test.async_check() is one_match_expected # Neither above → False for any and all hass.states.async_set( @@ -3945,7 +4010,7 @@ async def test_numerical_condition_with_unit_behavior( "25", {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ) - assert test(hass) is False + assert test.async_check() is False async def _setup_state_condition( @@ -3995,10 +4060,10 @@ async def test_state_condition_single_entity(hass: HomeAssistant) -> None: ) hass.states.async_set("test.entity_1", STATE_ON) - assert test(hass) is True + assert test.async_check() is True hass.states.async_set("test.entity_1", STATE_OFF) - assert test(hass) is False + assert test.async_check() is False async def test_state_condition_multiple_target_states(hass: HomeAssistant) -> None: @@ -4008,13 +4073,13 @@ async def test_state_condition_multiple_target_states(hass: HomeAssistant) -> No ) hass.states.async_set("test.entity_1", "on") - assert test(hass) is True + assert test.async_check() is True hass.states.async_set("test.entity_1", "heat") - assert test(hass) is True + assert test.async_check() is True hass.states.async_set("test.entity_1", "off") - assert test(hass) is False + assert test.async_check() is False @pytest.mark.parametrize( @@ -4037,7 +4102,7 @@ async def test_state_condition_unavailable_unknown( hass, entity_ids="test.entity_1", states=STATE_ON ) hass.states.async_set("test.entity_1", state_value) - assert test_single(hass) is False + assert test_single.async_check() is False # behavior any: entity_1=on, entity_2=unavailable, entity_3=off # → True (entity_1 matches, entity_2 is skipped) @@ -4050,12 +4115,12 @@ async def test_state_condition_unavailable_unknown( hass.states.async_set("test.entity_1", STATE_ON) hass.states.async_set("test.entity_2", state_value) hass.states.async_set("test.entity_3", STATE_OFF) - assert test_any(hass) is True + assert test_any.async_check() is True # behavior any: entity_1=off, entity_2=unavailable, entity_3=off # → False (no available entity matches) hass.states.async_set("test.entity_1", STATE_OFF) - assert test_any(hass) is False + assert test_any.async_check() is False # behavior all: entity_1=on, entity_2=unavailable, entity_3=on # → True (all *available* entities match, entity_2 is skipped) @@ -4068,12 +4133,12 @@ async def test_state_condition_unavailable_unknown( hass.states.async_set("test.entity_1", STATE_ON) hass.states.async_set("test.entity_2", state_value) hass.states.async_set("test.entity_3", STATE_ON) - assert test_all(hass) is True + assert test_all.async_check() is True # behavior all: entity_1=on, entity_2=unavailable, entity_3=off # → False (entity_3 is available and doesn't match) hass.states.async_set("test.entity_3", STATE_OFF) - assert test_all(hass) is False + assert test_all.async_check() is False async def test_state_condition_entity_not_found(hass: HomeAssistant) -> None: @@ -4083,7 +4148,7 @@ async def test_state_condition_entity_not_found(hass: HomeAssistant) -> None: ) # Entity doesn't exist — condition should be false - assert test(hass) is False + assert test.async_check() is False async def test_state_condition_attribute_value_source(hass: HomeAssistant) -> None: @@ -4096,14 +4161,14 @@ async def test_state_condition_attribute_value_source(hass: HomeAssistant) -> No ) hass.states.async_set("test.entity_1", "on", {"hvac_action": "heat"}) - assert test(hass) is True + assert test.async_check() is True hass.states.async_set("test.entity_1", "on", {"hvac_action": "idle"}) - assert test(hass) is False + assert test.async_check() is False # Missing attribute hass.states.async_set("test.entity_1", "on", {}) - assert test(hass) is False + assert test.async_check() is False @pytest.mark.parametrize( @@ -4124,15 +4189,15 @@ async def test_state_condition_behavior( # Both on → True for any and all hass.states.async_set("test.entity_1", STATE_ON) hass.states.async_set("test.entity_2", STATE_ON) - assert test(hass) is True + assert test.async_check() is True # Only one on → depends on behavior hass.states.async_set("test.entity_2", STATE_OFF) - assert test(hass) is one_match_expected + assert test.async_check() is one_match_expected # Neither on → False for any and all hass.states.async_set("test.entity_1", STATE_OFF) - assert test(hass) is False + assert test.async_check() is False async def test_state_condition_duration_not_met( @@ -4151,11 +4216,11 @@ async def test_state_condition_duration_not_met( await hass.async_block_till_done() # Just turned on — duration not met - assert test(hass) is False + assert test.async_check() is False # Advance 5 seconds — still not enough freezer.tick(timedelta(seconds=5)) - assert test(hass) is False + assert test.async_check() is False async def test_state_condition_duration_met( @@ -4175,7 +4240,7 @@ async def test_state_condition_duration_met( # Advance past duration freezer.tick(timedelta(seconds=11)) - assert test(hass) is True + assert test.async_check() is True async def test_state_condition_duration_zero_behaves_like_no_duration( @@ -4199,7 +4264,7 @@ async def test_state_condition_duration_zero_behaves_like_no_duration( await hass.async_block_till_done() # Should pass immediately — zero duration is the same as no duration - assert test(hass) is True + assert test.async_check() is True async def test_state_condition_duration_wrong_state( @@ -4218,7 +4283,7 @@ async def test_state_condition_duration_wrong_state( await hass.async_block_till_done() freezer.tick(timedelta(seconds=11)) - assert test(hass) is False + assert test.async_check() is False async def test_state_condition_duration_reset_on_state_change( @@ -4245,11 +4310,11 @@ async def test_state_condition_duration_reset_on_state_change( # 5 seconds after retrigger — not enough freezer.tick(timedelta(seconds=5)) - assert test(hass) is False + assert test.async_check() is False # 6 more seconds (11 from retrigger) — now met freezer.tick(timedelta(seconds=6)) - assert test(hass) is True + assert test.async_check() is True @pytest.mark.parametrize( @@ -4276,21 +4341,21 @@ async def test_state_condition_duration_behavior( await hass.async_block_till_done() # Both on but duration not met - assert test(hass) is False + assert test.async_check() is False # Advance past duration — both on for long enough freezer.tick(timedelta(seconds=11)) - assert test(hass) is True + assert test.async_check() is True # Turn entity_2 off — only one on for duration → depends on behavior hass.states.async_set("test.entity_2", STATE_OFF) await hass.async_block_till_done() - assert test(hass) is one_match_expected + assert test.async_check() is one_match_expected # Neither on → False for any and all hass.states.async_set("test.entity_1", STATE_OFF) await hass.async_block_till_done() - assert test(hass) is False + assert test.async_check() is False @pytest.mark.parametrize( @@ -4319,7 +4384,7 @@ async def test_state_condition_duration_unavailable_unknown( await hass.async_block_till_done() freezer.tick(timedelta(seconds=11)) - assert test_any(hass) is True + assert test_any.async_check() is True # behavior all: entity_1=on, entity_2=unavailable, entity_3=on (all long enough) # → True (all available entities match and meet duration) @@ -4336,13 +4401,30 @@ async def test_state_condition_duration_unavailable_unknown( await hass.async_block_till_done() freezer.tick(timedelta(seconds=11)) - assert test_all(hass) is True + assert test_all.async_check() is True # entity_3 off → not all available match hass.states.async_set("test.entity_3", STATE_OFF) await hass.async_block_till_done() freezer.tick(timedelta(seconds=11)) - assert test_all(hass) is False + assert test_all.async_check() is False + + +async def test_condition_checker_call_calls_async_check( + hass: HomeAssistant, +) -> None: + """Test that __call__ calls async_check.""" + + class MockChecker(ConditionChecker): + def _async_check(self, **kwargs: Any) -> bool: + return True + + checker = MockChecker(hass) + check_mock = Mock(wraps=checker.async_check) + checker.async_check = check_mock + + assert checker(hass) is True + check_mock.assert_called_once() async def test_condition_checker_del_calls_async_unload( @@ -4442,7 +4524,7 @@ async def test_compound_condition_forwards_async_unload( config = await condition.async_validate_condition_config(hass, config) test = await condition.async_from_config(hass, config) - # The compound checker should hold child conditions + # The compound checker should hold child checkers assert hasattr(test, "_conditions") assert len(test._conditions) == 2 @@ -4545,3 +4627,527 @@ async def test_conditions_from_config_nested_forwards_async_unload( test._conditions[0]._conditions[0].async_unload.assert_called_once() test._conditions[1].async_unload.assert_called_once() + + +_ATTR_DOMAIN_SPECS: Mapping[str, DomainSpec] = { + "test": DomainSpec(value_source="test_attr") +} + + +async def _setup_attr_state_condition( + hass: HomeAssistant, + entity_ids: str | list[str], + states: str | bool | set[str | bool], + condition_options: dict[str, Any] | None = None, +) -> condition.ConditionChecker: + """Set up an attribute-based state condition and return the checker.""" + condition_cls = make_entity_state_condition( + _ATTR_DOMAIN_SPECS, + states, + support_duration=True, + ) + + async def async_get_conditions( + hass: HomeAssistant, + ) -> dict[str, type[Condition]]: + return {"_": condition_cls} + + mock_integration(hass, MockModule("test")) + mock_platform( + hass, "test.condition", Mock(async_get_conditions=async_get_conditions) + ) + + if isinstance(entity_ids, str): + entity_ids = [entity_ids] + + config: dict[str, Any] = { + CONF_CONDITION: "test", + CONF_TARGET: {CONF_ENTITY_ID: entity_ids}, + CONF_OPTIONS: condition_options or {}, + } + + config = await async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + assert test is not None + return test + + +async def test_state_condition_attr_duration_not_met( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test attribute-based condition with duration: not met yet.""" + test = await _setup_attr_state_condition( + hass, + entity_ids="test.entity_1", + states={True}, + condition_options={CONF_FOR: {"seconds": 10}}, + ) + + hass.states.async_set("test.entity_1", STATE_ON, {"test_attr": True}) + await hass.async_block_till_done() + + # Just set — duration not met + assert test.async_check() is False + + freezer.tick(timedelta(seconds=5)) + assert test.async_check() is False + + +async def test_state_condition_attr_duration_met( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test attribute-based condition with duration: met after waiting.""" + test = await _setup_attr_state_condition( + hass, + entity_ids="test.entity_1", + states={True}, + condition_options={CONF_FOR: {"seconds": 10}}, + ) + + hass.states.async_set("test.entity_1", STATE_ON, {"test_attr": True}) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=11)) + assert test.async_check() is True + + +async def test_state_condition_attr_duration_reset_on_attr_change( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test attribute-based condition: timer resets when attribute changes. + + This is the key difference from state-based duration: the tracked value + is in an attribute, so state.last_changed does not capture it. The + _valid_since tracking in async_setup handles this correctly. + """ + test = await _setup_attr_state_condition( + hass, + entity_ids="test.entity_1", + states={True}, + condition_options={CONF_FOR: {"seconds": 10}}, + ) + + # Set attribute to True + hass.states.async_set("test.entity_1", STATE_ON, {"test_attr": True}) + await hass.async_block_till_done() + + # After 8s, change attribute to False (state stays the same) + freezer.tick(timedelta(seconds=8)) + hass.states.async_set("test.entity_1", STATE_ON, {"test_attr": False}) + await hass.async_block_till_done() + + # Set attribute back to True + hass.states.async_set("test.entity_1", STATE_ON, {"test_attr": True}) + await hass.async_block_till_done() + + # 5s after re-set — not enough (timer was reset) + freezer.tick(timedelta(seconds=5)) + assert test.async_check() is False + + # 6 more seconds (11 from re-set) — now met + freezer.tick(timedelta(seconds=6)) + assert test.async_check() is True + + +@pytest.mark.parametrize( + ("behavior", "one_match_expected"), + [(BEHAVIOR_ANY, True), (BEHAVIOR_ALL, False)], +) +async def test_state_condition_attr_duration_behavior( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + behavior: str, + one_match_expected: bool, +) -> None: + """Test attribute-based condition with duration and behavior any/all.""" + test = await _setup_attr_state_condition( + hass, + entity_ids=["test.entity_1", "test.entity_2"], + states={True}, + condition_options={ATTR_BEHAVIOR: behavior, CONF_FOR: {"seconds": 10}}, + ) + + hass.states.async_set("test.entity_1", STATE_ON, {"test_attr": True}) + hass.states.async_set("test.entity_2", STATE_ON, {"test_attr": True}) + await hass.async_block_till_done() + + # Both matching but duration not met + assert test.async_check() is False + + # Advance past duration — both matching long enough + freezer.tick(timedelta(seconds=11)) + assert test.async_check() is True + + # Change entity_2 attribute — only one matching for duration + hass.states.async_set("test.entity_2", STATE_ON, {"test_attr": False}) + await hass.async_block_till_done() + assert test.async_check() is one_match_expected + + +@dataclass +class _AttrInitStep: + """A state update step before the condition is created.""" + + state: str + attrs: dict[str, Any] = field(default_factory=dict) + delay_before: int = 0 + + +@pytest.mark.parametrize( + ("steps", "duration", "initially_met"), + [ + # Attribute set to valid 10s ago, no further changes → met (10 >= 5) + ( + [_AttrInitStep(STATE_ON, {"test_attr": True})], + 10, + True, + ), + # Attribute set to valid 3s ago → not met (3 < 5) + ( + [_AttrInitStep(STATE_ON, {"test_attr": True})], + 3, + False, + ), + # Attribute set to valid, then main state changes 2s later + # (attribute stays valid). last_updated is bumped by the state change, + # so the effective duration is only 2s from the second update → not met + ( + [ + _AttrInitStep(STATE_ON, {"test_attr": True}), + _AttrInitStep(STATE_OFF, {"test_attr": True}, delay_before=8), + ], + 2, + False, + ), + # Same as above but enough time after the state change → met + ( + [ + _AttrInitStep(STATE_ON, {"test_attr": True}), + _AttrInitStep(STATE_OFF, {"test_attr": True}, delay_before=2), + ], + 8, + True, + ), + # Attribute was invalid, then set to valid 4s ago → not met (4 < 5) + ( + [ + _AttrInitStep(STATE_ON, {"test_attr": False}), + _AttrInitStep(STATE_ON, {"test_attr": True}, delay_before=6), + ], + 4, + False, + ), + # Attribute was invalid, then set to valid 6s ago → met (6 >= 5) + ( + [ + _AttrInitStep(STATE_ON, {"test_attr": False}), + _AttrInitStep(STATE_ON, {"test_attr": True}, delay_before=4), + ], + 6, + True, + ), + # Attribute valid → invalid → valid 3s ago → not met (3 < 5) + ( + [ + _AttrInitStep(STATE_ON, {"test_attr": True}), + _AttrInitStep(STATE_ON, {"test_attr": False}, delay_before=5), + _AttrInitStep(STATE_ON, {"test_attr": True}, delay_before=2), + ], + 3, + False, + ), + ], + ids=[ + "valid_long_enough", + "valid_too_short", + "state_change_bumps_last_updated_not_met", + "state_change_bumps_last_updated_met", + "invalid_then_valid_not_met", + "invalid_then_valid_met", + "valid_invalid_valid_not_met", + ], +) +async def test_state_condition_attr_duration_initial_state( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + steps: list[_AttrInitStep], + duration: int, + initially_met: bool, +) -> None: + """Test attribute-based condition initialization from existing state. + + The condition uses last_updated (not last_changed) to determine how long + an attribute-based condition has been true. This is conservative: when + the main state changes but the tracked attribute stays the same, + last_updated is bumped and the effective duration resets. + """ + for step in steps: + freezer.tick(timedelta(seconds=step.delay_before)) + hass.states.async_set("test.entity_1", step.state, step.attrs) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=duration)) + test = await _setup_attr_state_condition( + hass, + entity_ids="test.entity_1", + states={True}, + condition_options={CONF_FOR: {"seconds": 5}}, + ) + + assert test.async_check() is initially_met + + +async def _setup_attr_state_condition_with_target( + hass: HomeAssistant, + target: dict[str, Any], + states: str | bool | set[str | bool], + condition_options: dict[str, Any] | None = None, +) -> condition.ConditionChecker: + """Set up an attribute-based state condition with a custom target.""" + condition_cls = make_entity_state_condition( + _ATTR_DOMAIN_SPECS, + states, + support_duration=True, + ) + + async def async_get_conditions( + hass: HomeAssistant, + ) -> dict[str, type[Condition]]: + return {"_": condition_cls} + + mock_integration(hass, MockModule("test")) + mock_platform( + hass, "test.condition", Mock(async_get_conditions=async_get_conditions) + ) + + config: dict[str, Any] = { + CONF_CONDITION: "test", + CONF_TARGET: target, + CONF_OPTIONS: condition_options or {}, + } + + config = await async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + assert test is not None + return test + + +async def test_state_condition_attr_duration_entity_added_to_target( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test that _valid_since is primed when an entity is added to the tracked set. + + When targeting by label, adding a label to an entity should make it + tracked, and if it's already in a valid state, its duration should be + primed from the state timestamps. + """ + label_reg = lr.async_get(hass) + label = label_reg.async_create("Test Duration") + + entity_reg = er.async_get(hass) + entry = entity_reg.async_get_or_create( + domain="test", platform="test", unique_id="duration_add" + ) + + # Entity starts valid but without the label + hass.states.async_set(entry.entity_id, STATE_ON, {"test_attr": True}) + await hass.async_block_till_done() + + # Create condition targeting the label + test = await _setup_attr_state_condition_with_target( + hass, + target={ATTR_LABEL_ID: label.label_id}, + states={True}, + condition_options={CONF_FOR: {"seconds": 5}}, + ) + + # No entities have the label yet — condition has no entities to check, + # behavior "any" with no matching entities returns False + assert test.async_check() is False + + # Add the label to the entity — entity is already in valid state + freezer.tick(timedelta(seconds=1)) + entity_reg.async_update_entity(entry.entity_id, labels={label.label_id}) + await hass.async_block_till_done() + + # Just added — duration not met yet + assert test.async_check() is False + + # Wait past the duration from when entity was last_updated + freezer.tick(timedelta(seconds=5)) + assert test.async_check() is True + + +async def test_state_condition_attr_duration_entity_removed_from_target( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test that _valid_since is evicted when an entity is removed from the tracked set.""" + label_reg = lr.async_get(hass) + label = label_reg.async_create("Test Duration Remove") + + entity_reg = er.async_get(hass) + entry1 = entity_reg.async_get_or_create( + domain="test", platform="test", unique_id="duration_remove_1" + ) + entry2 = entity_reg.async_get_or_create( + domain="test", platform="test", unique_id="duration_remove_2" + ) + # Both entities start with the label + entity_reg.async_update_entity(entry1.entity_id, labels={label.label_id}) + entity_reg.async_update_entity(entry2.entity_id, labels={label.label_id}) + + # Both entities in valid state + hass.states.async_set(entry1.entity_id, STATE_ON, {"test_attr": True}) + hass.states.async_set(entry2.entity_id, STATE_ON, {"test_attr": True}) + await hass.async_block_till_done() + + test = await _setup_attr_state_condition_with_target( + hass, + target={ATTR_LABEL_ID: label.label_id}, + states={True}, + condition_options={ + ATTR_BEHAVIOR: BEHAVIOR_ALL, + CONF_FOR: {"seconds": 5}, + }, + ) + + # Wait past duration — both valid + freezer.tick(timedelta(seconds=6)) + assert test.async_check() is True + + # Remove label from entry2 + entity_reg.async_update_entity(entry2.entity_id, labels=set()) + await hass.async_block_till_done() + + # Condition should still be True — only entry1 is tracked now, and it's valid + assert test.async_check() is True + + # Now remove label from entry1 too + entity_reg.async_update_entity(entry1.entity_id, labels=set()) + await hass.async_block_till_done() + + # No entities tracked — "all" with empty set is vacuously True + assert test.async_check() is True + + # Change entry1 to invalid state and re-add its label + hass.states.async_set(entry1.entity_id, STATE_ON, {"test_attr": False}) + await hass.async_block_till_done() + entity_reg.async_update_entity(entry1.entity_id, labels={label.label_id}) + await hass.async_block_till_done() + + # entry1 is now tracked again but invalid — "all" fails + freezer.tick(timedelta(seconds=10)) + assert test.async_check() is False + + +async def test_state_condition_attr_duration_entity_added_then_state_changes( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test that a newly added entity's state changes are properly tracked.""" + label_reg = lr.async_get(hass) + label = label_reg.async_create("Test Duration Track") + + entity_reg = er.async_get(hass) + entry = entity_reg.async_get_or_create( + domain="test", platform="test", unique_id="duration_track" + ) + + # Entity starts in invalid state + hass.states.async_set(entry.entity_id, STATE_ON, {"test_attr": False}) + await hass.async_block_till_done() + + # Create condition targeting the label + test = await _setup_attr_state_condition_with_target( + hass, + target={ATTR_LABEL_ID: label.label_id}, + states={True}, + condition_options={CONF_FOR: {"seconds": 5}}, + ) + + # Add the label — entity is invalid, so no priming + entity_reg.async_update_entity(entry.entity_id, labels={label.label_id}) + await hass.async_block_till_done() + assert test.async_check() is False + + # Now change to valid state + freezer.tick(timedelta(seconds=1)) + hass.states.async_set(entry.entity_id, STATE_ON, {"test_attr": True}) + await hass.async_block_till_done() + + # Just became valid — not long enough + freezer.tick(timedelta(seconds=3)) + assert test.async_check() is False + + # Now past the duration + freezer.tick(timedelta(seconds=3)) + assert test.async_check() is True + + +async def test_state_condition_attr_duration_unrelated_attr_update( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test that unrelated attribute updates don't reset the duration timer. + + When the tracked attribute stays valid but another attribute changes, + _update_valid_since must not overwrite the existing timestamp. + """ + test = await _setup_attr_state_condition( + hass, + entity_ids="test.entity_1", + states={True}, + condition_options={CONF_FOR: {"seconds": 10}}, + ) + + # Set tracked attribute to True + hass.states.async_set("test.entity_1", STATE_ON, {"test_attr": True, "other": "a"}) + await hass.async_block_till_done() + + # After 6s, change an unrelated attribute (tracked attr stays True) + freezer.tick(timedelta(seconds=6)) + hass.states.async_set("test.entity_1", STATE_ON, {"test_attr": True, "other": "b"}) + await hass.async_block_till_done() + + # After 5 more seconds (11 total from initial set), the duration + # should be met — the unrelated attribute change must NOT have + # reset the timer. + freezer.tick(timedelta(seconds=5)) + assert test.async_check() is True + + +async def test_async_from_config_calls_async_setup_on_checker( + hass: HomeAssistant, +) -> None: + """Test that async_from_config calls async_setup on ConditionChecker from factory path.""" + + class StubChecker(condition.ConditionChecker): + """Stub checker to track async_setup calls.""" + + def __init__(self, hass: HomeAssistant) -> None: + super().__init__(hass) + self.setup_called = False + + async def async_setup(self) -> None: + self.setup_called = True + + def _async_check(self, **kwargs: Any) -> bool: + return True + + stub = StubChecker(hass) + + async def fake_factory( + hass: HomeAssistant, config: ConfigType + ) -> condition.ConditionChecker: + return stub + + with ( + patch.object( + condition, "async_stub_checker_from_config", fake_factory, create=True + ), + patch.dict(condition._PLATFORM_ALIASES, {"stub_checker": None}), + ): + config = {"condition": "stub_checker"} + config = cv.CONDITION_SCHEMA(config) + result = await condition.async_from_config(hass, config) + + assert result is stub + assert stub.setup_called diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 7b0e220bb17a9a..b730a07a718413 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -747,36 +747,6 @@ def test_dynamic_template(hass: HomeAssistant) -> None: schema(value) -async def test_dynamic_template_no_hass(hass: HomeAssistant) -> None: - """Test dynamic template validator.""" - schema = vol.Schema(cv.dynamic_template) - - for value in ( - None, - 1, - "{{ partial_print }", - "{% if True %}Hello", - ["test"], - "just a string", - # Filter added as an extension by Home Assistant - "{{ ['group.foo']|expand|map(attribute='entity_id')|list }}", - ): - with pytest.raises(vol.Invalid): - await hass.async_add_executor_job(schema, value) - - options = ( - "{{ beer }}", - "{% if 1 == 1 %}Hello{% else %}World{% endif %}", - # Function 'expand' added as an extension by Home Assistant, no error - # because non existing functions are not detected by Jinja2 - "{{ expand('group.foo')|map(attribute='entity_id')|list }}", - # Non existing function 'no_such_function' is not detected by Jinja2 - "{{ no_such_function('group.foo')|map(attribute='entity_id')|list }}", - ) - for value in options: - await hass.async_add_executor_job(schema, value) - - @pytest.mark.usefixtures("hass") def test_template_complex() -> None: """Test template_complex validator.""" diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index a0acbd305e9b02..6d76bf75624206 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -22,7 +22,7 @@ HomeAssistant, callback, ) -from homeassistant.exceptions import HomeAssistantError, TemplateError +from homeassistant.exceptions import TemplateError from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from homeassistant.helpers.event import ( @@ -4973,27 +4973,3 @@ def on_state_report(event: Event[EventStateReportedData]) -> None: "light.bowl": ["on", "on", "off", "off"], "light.top": ["on", "on", "off", "off"], } - - -async def test_async_track_template_no_hass_fails(hass: HomeAssistant) -> None: - """Test async_track_template with a template without hass now fails.""" - message = "Calls async_track_template_result with template without hass" - - with pytest.raises(HomeAssistantError, match=message): - async_track_template(hass, Template("blah"), lambda x, y, z: None) - - async_track_template(hass, Template("blah", hass), lambda x, y, z: None) - - -async def test_async_track_template_result_no_hass_fails(hass: HomeAssistant) -> None: - """Test async_track_template_result with a template without hass now fails.""" - message = "Calls async_track_template_result with template without hass" - - with pytest.raises(HomeAssistantError, match=message): - async_track_template_result( - hass, [TrackTemplate(Template("blah"), None)], lambda x, y, z: None - ) - - async_track_template_result( - hass, [TrackTemplate(Template("blah", hass), None)], lambda x, y, z: None - ) diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 429cbbc1e112c9..3be1d3acde9894 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -750,6 +750,295 @@ def create_entity( ) +async def test_get_live_context_tool_filter( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, +) -> None: + """Test the filter parameters of the GetLiveContext tool.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + context = Context() + llm_context = llm.LLMContext( + platform="test_platform", + context=context, + language="*", + assistant="conversation", + device_id=None, + ) + + entry = MockConfigEntry(title=None) + entry.add_to_hass(hass) + + office = area_registry.async_create("Office") + area_registry.async_update(office.id, aliases={"Workspace"}) + area_registry.async_create("Kitchen") + + office_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={("test", "office-1")}, + suggested_area="Office", + ) + kitchen_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={("test", "kitchen-1")}, + suggested_area="Kitchen", + ) + + office_light = entity_registry.async_get_or_create( + "light", + "test", + "office_light", + original_name="Office Light", + device_id=office_device.id, + suggested_object_id="office_light", + ) + kitchen_light = entity_registry.async_get_or_create( + "light", + "test", + "kitchen_light", + original_name="Kitchen Light", + device_id=kitchen_device.id, + suggested_object_id="kitchen_light", + ) + office_switch = entity_registry.async_get_or_create( + "switch", + "test", + "office_switch", + original_name="Office Switch", + device_id=office_device.id, + suggested_object_id="office_switch", + ) + front_door = entity_registry.async_get_or_create( + "lock", + "test", + "front_door", + original_name="Front Door", + suggested_object_id="front_door", + ) + entity_registry.async_update_entity( + kitchen_light.entity_id, aliases=[er.COMPUTED_NAME, "Cooking Lamp"] + ) + + for entity_id in ( + office_light.entity_id, + kitchen_light.entity_id, + office_switch.entity_id, + front_door.entity_id, + ): + async_expose_entity(hass, "conversation", entity_id, True) + + hass.states.async_set(office_light.entity_id, "on") + hass.states.async_set(kitchen_light.entity_id, "off") + hass.states.async_set(office_switch.entity_id, "on") + hass.states.async_set(front_door.entity_id, "locked") + + api = await llm.async_get_api(hass, "assist", llm_context) + + # Filter by area and domain (example 1) + result = await api.async_call_tool( + llm.ToolInput( + tool_name="GetLiveContext", + tool_args={"area": "Office", "domain": "light"}, + ) + ) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Kitchen Light" not in result["result"] + assert "Office Switch" not in result["result"] + assert "Front Door" not in result["result"] + + # Filter by name (example 2) + result = await api.async_call_tool( + llm.ToolInput( + tool_name="GetLiveContext", + tool_args={"name": "Front Door"}, + ) + ) + assert result["success"] is True + assert "Front Door" in result["result"] + assert "Office Light" not in result["result"] + assert "Kitchen Light" not in result["result"] + assert "Office Switch" not in result["result"] + + # Name filter is case insensitive + result = await api.async_call_tool( + llm.ToolInput( + tool_name="GetLiveContext", + tool_args={"name": "front door"}, + ) + ) + assert result["success"] is True + assert "Front Door" in result["result"] + + # Area filter matches area aliases + result = await api.async_call_tool( + llm.ToolInput( + tool_name="GetLiveContext", + tool_args={"area": "workspace"}, + ) + ) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Office Switch" in result["result"] + assert "Kitchen Light" not in result["result"] + assert "Front Door" not in result["result"] + + # Domain filter accepts a list + result = await api.async_call_tool( + llm.ToolInput( + tool_name="GetLiveContext", + tool_args={"domain": ["switch", "lock"]}, + ) + ) + assert result["success"] is True + assert "Office Switch" in result["result"] + assert "Front Door" in result["result"] + assert "Office Light" not in result["result"] + assert "Kitchen Light" not in result["result"] + + # Domain filter is case insensitive + result = await api.async_call_tool( + llm.ToolInput( + tool_name="GetLiveContext", + tool_args={"domain": "Light"}, + ) + ) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Kitchen Light" in result["result"] + assert "Office Switch" not in result["result"] + assert "Front Door" not in result["result"] + + # No filters returns all exposed entities + result = await api.async_call_tool( + llm.ToolInput(tool_name="GetLiveContext", tool_args={}) + ) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Kitchen Light" in result["result"] + assert "Office Switch" in result["result"] + assert "Front Door" in result["result"] + + # Filter that matches nothing returns a descriptive error + result = await api.async_call_tool( + llm.ToolInput( + tool_name="GetLiveContext", + tool_args={"name": "Does Not Exist"}, + ) + ) + assert result == { + "success": False, + "error": "No exposed entities matched name 'Does Not Exist'", + } + + # Name filter strips surrounding whitespace + result = await api.async_call_tool( + llm.ToolInput( + tool_name="GetLiveContext", + tool_args={"name": " Front Door "}, + ) + ) + assert result["success"] is True + assert "Front Door" in result["result"] + + # Area filter strips surrounding whitespace + result = await api.async_call_tool( + llm.ToolInput( + tool_name="GetLiveContext", + tool_args={"area": " Office "}, + ) + ) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Office Switch" in result["result"] + assert "Kitchen Light" not in result["result"] + + # Name filter accepts entity_id + result = await api.async_call_tool( + llm.ToolInput( + tool_name="GetLiveContext", + tool_args={"name": office_light.entity_id}, + ) + ) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Kitchen Light" not in result["result"] + assert "Office Switch" not in result["result"] + + # Area filter accepts area_id + result = await api.async_call_tool( + llm.ToolInput( + tool_name="GetLiveContext", + tool_args={"area": office.id}, + ) + ) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Office Switch" in result["result"] + assert "Kitchen Light" not in result["result"] + assert "Front Door" not in result["result"] + + # Name filter matches entity aliases + result = await api.async_call_tool( + llm.ToolInput( + tool_name="GetLiveContext", + tool_args={"name": "cooking lamp"}, + ) + ) + assert result["success"] is True + assert "Kitchen Light" in result["result"] + assert "Office Light" not in result["result"] + + # Combining name + area narrows the result + result = await api.async_call_tool( + llm.ToolInput( + tool_name="GetLiveContext", + tool_args={"name": "Office Light", "area": "Office"}, + ) + ) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Office Switch" not in result["result"] + + # Combining name + area returns the failing constraint in the error + result = await api.async_call_tool( + llm.ToolInput( + tool_name="GetLiveContext", + tool_args={"name": "Office Light", "area": "Kitchen"}, + ) + ) + assert result == { + "success": False, + "error": "No exposed entities found in area 'Kitchen'", + } + + # Unknown area distinguishes "invalid area" from "no entities in area" + result = await api.async_call_tool( + llm.ToolInput( + tool_name="GetLiveContext", + tool_args={"area": "Garage"}, + ) + ) + assert result == { + "success": False, + "error": "Area 'Garage' does not exist", + } + + # Unknown domain reports which domain(s) failed + result = await api.async_call_tool( + llm.ToolInput( + tool_name="GetLiveContext", + tool_args={"domain": "climate"}, + ) + ) + assert result == { + "success": False, + "error": "No exposed entities found in domain(s): climate", + } + + async def test_script_tool( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 7641bd90f08307..eed983b9b20a4e 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -1127,6 +1127,17 @@ def test_state_selector_schema(schema, valid_selections, invalid_selections) -> [ (None, ("/dev/ttyUSB0", "/dev/ttyACM1", "COM3"), (None, 1, True)), ({}, ("/dev/ttyUSB0",), (None,)), + ( + { + "extra_recommended_domains": [ + "homeassistant_yellow", + "homeassistant_sky_connect", + ] + }, + ("/dev/ttyUSB0",), + (None,), + ), + ({"extra_recommended_domains": []}, ("/dev/ttyUSB0",), (None,)), ], ) def test_serial_port_selector_schema( diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 652d436b82144c..517bc144cb7efe 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1,13 +1,13 @@ """Test service helpers.""" import asyncio -from collections.abc import Callable, Iterable +from collections.abc import Callable, Generator, Iterable from copy import deepcopy import dataclasses import io import threading from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, call as mock_call, patch import pytest from pytest_unordered import unordered @@ -48,6 +48,7 @@ entity_registry as er, service, ) +from homeassistant.helpers.entity import Entity from homeassistant.loader import ( Integration, async_get_integration, @@ -75,17 +76,6 @@ SUPPORT_C = 4 -@pytest.fixture -def mock_handle_entity_call(): - """Mock service platform call.""" - with patch( - "homeassistant.helpers.service._handle_single_entity_call", - new_callable=AsyncMock, - return_value=None, - ) as mock_call: - yield mock_call - - @pytest.fixture def mock_entities(hass: HomeAssistant) -> dict[str, MockEntity]: """Return mock entities in an ordered dict.""" @@ -127,6 +117,18 @@ def mock_entities(hass: HomeAssistant) -> dict[str, MockEntity]: return entities +@pytest.fixture +def mock_entities_method() -> Generator[AsyncMock]: + """Patch test_method on the base Entity class.""" + mock = AsyncMock() + + async def _stub(self: Entity, **kwargs: Any) -> None: + await mock(self, **kwargs) + + with patch.object(Entity, "test_method", _stub, create=True): + yield mock + + @pytest.fixture def floor_area_mock(hass: HomeAssistant) -> None: """Mock including floor and area info.""" @@ -1686,7 +1688,9 @@ async def test_call_with_sync_attr(hass: HomeAssistant, mock_entities) -> None: async def test_call_single_entity_uses_parallel_updates( - hass: HomeAssistant, mock_handle_entity_call, mock_entities + hass: HomeAssistant, + mock_entities: dict[str, MockEntity], + mock_entities_method: AsyncMock, ) -> None: """Check that single entity calls go through async_request_call.""" entity = mock_entities["light.kitchen"] @@ -1698,7 +1702,7 @@ async def test_call_single_entity_uses_parallel_updates( service_call = service.entity_service_call( hass, mock_entities, - Mock(), + "test_method", ServiceCall( hass, "test_domain", @@ -1710,13 +1714,13 @@ async def test_call_single_entity_uses_parallel_updates( # Give the event loop a chance to progress; the call should be blocked await asyncio.sleep(0) - assert mock_handle_entity_call.await_count == 0 + mock_entities_method.assert_not_called() # Release the semaphore so the call can proceed entity.parallel_updates.release() await task - assert mock_handle_entity_call.await_count == 1 + mock_entities_method.assert_called_once_with(entity) async def test_call_context_user_not_exist(hass: HomeAssistant) -> None: @@ -1738,7 +1742,9 @@ async def test_call_context_user_not_exist(hass: HomeAssistant) -> None: async def test_call_context_target_all( - hass: HomeAssistant, mock_handle_entity_call, mock_entities + hass: HomeAssistant, + mock_entities: dict[str, MockEntity], + mock_entities_method: AsyncMock, ) -> None: """Check we only target allowed entities if targeting all.""" with patch( @@ -1753,7 +1759,7 @@ async def test_call_context_target_all( await service.entity_service_call( hass, mock_entities, - Mock(), + "test_method", ServiceCall( hass, "test_domain", @@ -1763,12 +1769,13 @@ async def test_call_context_target_all( ), ) - assert len(mock_handle_entity_call.mock_calls) == 1 - assert mock_handle_entity_call.mock_calls[0][1][1].entity_id == "light.kitchen" + mock_entities_method.assert_called_once_with(mock_entities["light.kitchen"]) async def test_call_context_target_specific( - hass: HomeAssistant, mock_handle_entity_call, mock_entities + hass: HomeAssistant, + mock_entities: dict[str, MockEntity], + mock_entities_method: AsyncMock, ) -> None: """Check targeting specific entities.""" with patch( @@ -1782,7 +1789,7 @@ async def test_call_context_target_specific( await service.entity_service_call( hass, mock_entities, - Mock(), + "test_method", ServiceCall( hass, "test_domain", @@ -1792,12 +1799,12 @@ async def test_call_context_target_specific( ), ) - assert len(mock_handle_entity_call.mock_calls) == 1 - assert mock_handle_entity_call.mock_calls[0][1][1].entity_id == "light.kitchen" + mock_entities_method.assert_called_once_with(mock_entities["light.kitchen"]) async def test_call_context_target_specific_no_auth( - hass: HomeAssistant, mock_handle_entity_call, mock_entities + hass: HomeAssistant, + mock_entities: dict[str, MockEntity], ) -> None: """Check targeting specific entities without auth.""" with ( @@ -1810,7 +1817,7 @@ async def test_call_context_target_specific_no_auth( await service.entity_service_call( hass, mock_entities, - Mock(), + "test_method", ServiceCall( hass, "test_domain", @@ -1825,32 +1832,35 @@ async def test_call_context_target_specific_no_auth( async def test_call_no_context_target_all( - hass: HomeAssistant, mock_handle_entity_call, mock_entities + hass: HomeAssistant, + mock_entities: dict[str, MockEntity], + mock_entities_method: AsyncMock, ) -> None: """Check we target all if no user context given.""" await service.entity_service_call( hass, mock_entities, - Mock(), + "test_method", ServiceCall( hass, "test_domain", "test_service", data={"entity_id": ENTITY_MATCH_ALL} ), ) - assert len(mock_handle_entity_call.mock_calls) == 4 - assert [call[1][1] for call in mock_handle_entity_call.mock_calls] == list( - mock_entities.values() + assert mock_entities_method.call_args_list == unordered( + mock_call(entity) for entity in mock_entities.values() ) async def test_call_no_context_target_specific( - hass: HomeAssistant, mock_handle_entity_call, mock_entities + hass: HomeAssistant, + mock_entities: dict[str, MockEntity], + mock_entities_method: AsyncMock, ) -> None: """Check we can target specified entities.""" await service.entity_service_call( hass, mock_entities, - Mock(), + "test_method", ServiceCall( hass, "test_domain", @@ -1859,42 +1869,23 @@ async def test_call_no_context_target_specific( ), ) - assert len(mock_handle_entity_call.mock_calls) == 1 - assert mock_handle_entity_call.mock_calls[0][1][1].entity_id == "light.kitchen" - - -async def test_call_with_match_all( - hass: HomeAssistant, - mock_handle_entity_call, - mock_entities, - caplog: pytest.LogCaptureFixture, -) -> None: - """Check we only target allowed entities if targeting all.""" - await service.entity_service_call( - hass, - mock_entities, - Mock(), - ServiceCall(hass, "test_domain", "test_service", {"entity_id": "all"}), - ) - - assert len(mock_handle_entity_call.mock_calls) == 4 - assert [call[1][1] for call in mock_handle_entity_call.mock_calls] == list( - mock_entities.values() - ) + mock_entities_method.assert_called_once_with(mock_entities["light.kitchen"]) async def test_call_with_omit_entity_id( - hass: HomeAssistant, mock_handle_entity_call, mock_entities + hass: HomeAssistant, + mock_entities: dict[str, MockEntity], + mock_entities_method: AsyncMock, ) -> None: """Check service call if we do not pass an entity ID.""" await service.entity_service_call( hass, mock_entities, - Mock(), + "test_method", ServiceCall(hass, "test_domain", "test_service"), ) - assert len(mock_handle_entity_call.mock_calls) == 0 + mock_entities_method.assert_not_called() async def test_register_admin_service( diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py index 114e3445bf0575..fea3ab4a92942e 100644 --- a/tests/helpers/test_target.py +++ b/tests/helpers/test_target.py @@ -793,3 +793,121 @@ async def set_states_and_check_events( ) unsub() + + +async def test_async_track_target_selector_state_change_event_on_entities_update( + hass: HomeAssistant, +) -> None: + """Test on_entities_update callback reports added and removed entities.""" + entity_updates: list[tuple[set[str], set[str]]] = [] + + @callback + def state_change_callback(event: target.TargetStateChangedData) -> None: + """Handle state change events.""" + + @callback + def on_entities_update(added: set[str], removed: set[str]) -> None: + """Track entity set changes.""" + entity_updates.append((added, removed)) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + entity_reg = er.async_get(hass) + label_reg = lr.async_get(hass) + label = label_reg.async_create("Track Test") + + entity_a = entity_reg.async_get_or_create( + domain="light", platform="test", unique_id="track_a" + ) + entity_b = entity_reg.async_get_or_create( + domain="light", platform="test", unique_id="track_b" + ) + + # entity_a starts with the label + entity_reg.async_update_entity(entity_a.entity_id, labels={label.label_id}) + + hass.states.async_set(entity_a.entity_id, STATE_ON) + hass.states.async_set(entity_b.entity_id, STATE_ON) + await hass.async_block_till_done() + + unsub = target.async_track_target_selector_state_change_event( + hass, + {ATTR_LABEL_ID: label.label_id}, + state_change_callback, + on_entities_update=on_entities_update, + ) + + # Initial setup fires on_entities_update with all entities as "added" + assert len(entity_updates) == 1 + assert entity_updates[-1] == ({entity_a.entity_id}, set()) + entity_updates.clear() + + # Add label to entity_b → added + entity_reg.async_update_entity(entity_b.entity_id, labels={label.label_id}) + await hass.async_block_till_done() + + assert len(entity_updates) == 1 + assert entity_updates[-1] == ({entity_b.entity_id}, set()) + entity_updates.clear() + + # Remove label from entity_a → removed + entity_reg.async_update_entity(entity_a.entity_id, labels=set()) + await hass.async_block_till_done() + + assert len(entity_updates) == 1 + assert entity_updates[-1] == (set(), {entity_a.entity_id}) + entity_updates.clear() + + # Remove label from entity_b → removed + entity_reg.async_update_entity(entity_b.entity_id, labels=set()) + await hass.async_block_till_done() + + assert len(entity_updates) == 1 + assert entity_updates[-1] == (set(), {entity_b.entity_id}) + entity_updates.clear() + + # Re-add both labels at once — entity_a first, then entity_b + entity_reg.async_update_entity(entity_a.entity_id, labels={label.label_id}) + await hass.async_block_till_done() + entity_reg.async_update_entity(entity_b.entity_id, labels={label.label_id}) + await hass.async_block_till_done() + + assert len(entity_updates) == 2 + assert entity_updates[0] == ({entity_a.entity_id}, set()) + assert entity_updates[1] == ({entity_b.entity_id}, set()) + entity_updates.clear() + + # After unsubscribing, no more callbacks + unsub() + entity_reg.async_update_entity(entity_a.entity_id, labels=set()) + await hass.async_block_till_done() + assert len(entity_updates) == 0 + + +async def test_async_track_target_selector_no_on_entities_update( + hass: HomeAssistant, +) -> None: + """Test that on_entities_update is optional and defaults to no callback.""" + events: list[target.TargetStateChangedData] = [] + + @callback + def state_change_callback(event: target.TargetStateChangedData) -> None: + events.append(event) + + entity_id = "light.test_no_callback" + hass.states.async_set(entity_id, STATE_ON) + await hass.async_block_till_done() + + # No on_entities_update — should work without errors + unsub = target.async_track_target_selector_state_change_event( + hass, + {ATTR_ENTITY_ID: entity_id}, + state_change_callback, + ) + + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + assert len(events) == 1 + + unsub() diff --git a/tests/helpers/test_trigger_template_entity.py b/tests/helpers/test_trigger_template_entity.py index 08f6c7de8197e0..0bbe521eef000b 100644 --- a/tests/helpers/test_trigger_template_entity.py +++ b/tests/helpers/test_trigger_template_entity.py @@ -296,14 +296,20 @@ def some_other_key(self) -> dict[str, Any] | None: assert entity.some_other_key == {"test_key": "test_data"} +@pytest.mark.parametrize( + "device_class", + [SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME], +) async def test_manual_trigger_sensor_entity_with_date( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_class: SensorDeviceClass, ) -> None: """Test manual trigger template entity when availability template isn't used.""" config = { CONF_NAME: template.Template("test_entity", hass), CONF_STATE: template.Template("{{ as_datetime(value) }}", hass), - CONF_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, + CONF_DEVICE_CLASS: device_class, } class TestEntity(ManualTriggerSensorEntity): @@ -328,4 +334,4 @@ def state(self) -> bool | None: "2025-01-01T00:00:00+00:00", entity.entity_id, entity.device_class ) assert entity.state == "2025-01-01T00:00:00+00:00" - assert entity.device_class == SensorDeviceClass.TIMESTAMP + assert entity.device_class == device_class diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index d4a35c7aa55523..4c9ad8550a7008 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -1333,3 +1333,36 @@ async def test_refresh_known_errors_retry_after( unsub() crd._unschedule_refresh() + + +async def test_callbacks_does_not_stop_coordinator( + crd: update_coordinator.DataUpdateCoordinator[int], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test async_refresh for update coordinator.""" + + update_1 = Mock() + update_2 = Mock() + + crd.async_add_listener(update_1) + crd.async_add_listener(update_2) + await crd.async_refresh() + assert update_1.call_count == 1 + assert update_2.call_count == 1 + assert crd.last_update_success is True + + # Trigger exception in callback + update_1.side_effect = Exception("Failure in callback") + caplog.clear() + await crd.async_refresh() + assert any( + message.startswith("Unexpected error updating listener ") + for message in caplog.messages + ) + + # All callbacks should still have been called + assert update_1.call_count == 2 + assert update_2.call_count == 2 + assert crd.last_update_success is True + + await crd.async_shutdown() diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index de17ae11df68bb..652cb8901db684 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -4,7 +4,6 @@ from collections.abc import Generator, Iterable import contextlib import glob -import logging import os import sys from typing import Any @@ -1280,14 +1279,14 @@ async def test_tasks_logged_that_block_stage_2( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we log tasks that delay stage 2 startup.""" - done_future = hass.loop.create_future() def gen_domain_setup(domain): async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _not_marked_background_task(): - await done_future + await asyncio.sleep(0.2) hass.async_create_task(_not_marked_background_task()) + await asyncio.sleep(0.1) return True return async_setup @@ -1301,36 +1300,16 @@ async def _not_marked_background_task(): ), ) - wanted_messages = { - "Setup timed out for stage 2 waiting on", - "waiting on", - "_not_marked_background_task", - } - - def on_message_logged(log_record: logging.LogRecord, *args): - for message in list(wanted_messages): - if message in log_record.message: - wanted_messages.remove(message) - if not done_future.done() and not wanted_messages: - done_future.set_result(None) - return - with ( patch.object(bootstrap, "STAGE_2_TIMEOUT", 0), patch.object(bootstrap, "COOLDOWN_TIME", 0), - patch.object( - caplog.handler, - "emit", - wraps=caplog.handler.emit, - side_effect=on_message_logged, - ), ): await bootstrap._async_set_up_integrations(hass, {"normal_integration": {}}) - async with asyncio.timeout(2): - await done_future await hass.async_block_till_done() - assert not wanted_messages + assert "Setup timed out for stage 2 waiting on" in caplog.text + assert "waiting on" in caplog.text + assert "_not_marked_background_task" in caplog.text @pytest.mark.parametrize("load_registries", [False])