diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 94e876aa3abf7..ae94c43069077 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -6,10 +6,14 @@ body: value: | This issue form is for reporting bugs only! + Please read our [issue reporting guide][reporting-issues] before submitting a report. + If you have a feature or enhancement request, please [request them here instead][fr]. [fr]: https://github.com/orgs/home-assistant/discussions + [reporting-issues]: https://www.home-assistant.io/help/reporting_issues/ - type: textarea + id: problem validations: required: true attributes: @@ -35,12 +39,14 @@ body: [![Open your Home Assistant instance and show the system information.](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) - type: input + id: last_working_version attributes: label: What was the last working version of Home Assistant Core? placeholder: core- description: > If known, otherwise leave blank. - type: dropdown + id: installation_type validations: required: true attributes: @@ -76,6 +82,7 @@ body: value: | # Details - type: textarea + id: diagnostics attributes: label: Diagnostics information placeholder: "drag-and-drop the diagnostics data file here (do not copy-and-paste the content)" @@ -87,6 +94,7 @@ body: It generally allows pinpointing defects and thus resolving issues faster. - type: textarea + id: configuration attributes: label: Example YAML snippet description: | @@ -94,11 +102,13 @@ body: This can be from an automation, script, scene or configuration. render: yaml - type: textarea + id: logs attributes: label: Anything in the logs that might be useful for us? description: For example, error message, or stack traces. render: txt - type: textarea + id: additional_information attributes: label: Additional information description: > diff --git a/.github/actions/cache-apt-packages/action.yml b/.github/actions/cache-apt-packages/action.yml index 515831299f936..5af058055f7c3 100644 --- a/.github/actions/cache-apt-packages/action.yml +++ b/.github/actions/cache-apt-packages/action.yml @@ -29,7 +29,7 @@ runs: shell: bash run: sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list - name: Install apt packages via cache - uses: awalsh128/cache-apt-pkgs-action@2c09a5e66da6c8016428a2172bd76e5e4f14bb17 # v1.5.3 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 with: packages: ${{ inputs.packages }} version: ${{ inputs.version }} diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index f20dfd2b83d89..7f297ff81fe90 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -4,7 +4,6 @@ import logging from typing import Any, Final, final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry @@ -22,25 +21,24 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 ATTR_CHANGED_BY, ATTR_CODE_ARM_REQUIRED, + DATA_COMPONENT, DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelEntityStateAttribute, AlarmControlPanelState, CodeFormat, ) +from .services import async_setup_services _LOGGER: Final = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[AlarmControlPanelEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" PLATFORM_SCHEMA: Final = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE: Final = cv.PLATFORM_SCHEMA_BASE @@ -48,11 +46,6 @@ CONF_DEFAULT_CODE = "default_code" -ALARM_SERVICE_SCHEMA: Final = make_entity_service_schema( - {probatio.Optional(ATTR_CODE): cv.string} -) - - # mypy: disallow-any-generics @@ -64,47 +57,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) - component.async_register_entity_service( - SERVICE_ALARM_DISARM, - ALARM_SERVICE_SCHEMA, - "async_handle_alarm_disarm", - ) - component.async_register_entity_service( - SERVICE_ALARM_ARM_HOME, - ALARM_SERVICE_SCHEMA, - "async_handle_alarm_arm_home", - [AlarmControlPanelEntityFeature.ARM_HOME], - ) - component.async_register_entity_service( - SERVICE_ALARM_ARM_AWAY, - ALARM_SERVICE_SCHEMA, - "async_handle_alarm_arm_away", - [AlarmControlPanelEntityFeature.ARM_AWAY], - ) - component.async_register_entity_service( - SERVICE_ALARM_ARM_NIGHT, - ALARM_SERVICE_SCHEMA, - "async_handle_alarm_arm_night", - [AlarmControlPanelEntityFeature.ARM_NIGHT], - ) - component.async_register_entity_service( - SERVICE_ALARM_ARM_VACATION, - ALARM_SERVICE_SCHEMA, - "async_handle_alarm_arm_vacation", - [AlarmControlPanelEntityFeature.ARM_VACATION], - ) - component.async_register_entity_service( - SERVICE_ALARM_ARM_CUSTOM_BYPASS, - ALARM_SERVICE_SCHEMA, - "async_handle_alarm_arm_custom_bypass", - [AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS], - ) - component.async_register_entity_service( - SERVICE_ALARM_TRIGGER, - ALARM_SERVICE_SCHEMA, - "async_alarm_trigger", - [AlarmControlPanelEntityFeature.TRIGGER], - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/alarm_control_panel/const.py b/homeassistant/components/alarm_control_panel/const.py index e66f25c72f9d4..659bc158df87c 100644 --- a/homeassistant/components/alarm_control_panel/const.py +++ b/homeassistant/components/alarm_control_panel/const.py @@ -1,9 +1,17 @@ """Provides the constants needed for component.""" from enum import IntFlag, StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import AlarmControlPanelEntity DOMAIN: Final = "alarm_control_panel" +DATA_COMPONENT: HassKey[EntityComponent[AlarmControlPanelEntity]] = HassKey(DOMAIN) ATTR_CHANGED_BY: Final = "changed_by" ATTR_CODE_ARM_REQUIRED: Final = "code_arm_required" diff --git a/homeassistant/components/alarm_control_panel/services.py b/homeassistant/components/alarm_control_panel/services.py new file mode 100644 index 0000000000000..5a1c6696486cd --- /dev/null +++ b/homeassistant/components/alarm_control_panel/services.py @@ -0,0 +1,73 @@ +"""Services for the Alarm control panel integration.""" + +from typing import Final + +import probatio + +from homeassistant.const import ( + ATTR_CODE, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_ARM_VACATION, + SERVICE_ALARM_DISARM, + SERVICE_ALARM_TRIGGER, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.config_validation import make_entity_service_schema + +from .const import DATA_COMPONENT, AlarmControlPanelEntityFeature + +ALARM_SERVICE_SCHEMA: Final = make_entity_service_schema( + {probatio.Optional(ATTR_CODE): cv.string} +) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the alarm control panel services.""" + component = hass.data[DATA_COMPONENT] + + component.async_register_entity_service( + SERVICE_ALARM_DISARM, + ALARM_SERVICE_SCHEMA, + "async_handle_alarm_disarm", + ) + component.async_register_entity_service( + SERVICE_ALARM_ARM_HOME, + ALARM_SERVICE_SCHEMA, + "async_handle_alarm_arm_home", + [AlarmControlPanelEntityFeature.ARM_HOME], + ) + component.async_register_entity_service( + SERVICE_ALARM_ARM_AWAY, + ALARM_SERVICE_SCHEMA, + "async_handle_alarm_arm_away", + [AlarmControlPanelEntityFeature.ARM_AWAY], + ) + component.async_register_entity_service( + SERVICE_ALARM_ARM_NIGHT, + ALARM_SERVICE_SCHEMA, + "async_handle_alarm_arm_night", + [AlarmControlPanelEntityFeature.ARM_NIGHT], + ) + component.async_register_entity_service( + SERVICE_ALARM_ARM_VACATION, + ALARM_SERVICE_SCHEMA, + "async_handle_alarm_arm_vacation", + [AlarmControlPanelEntityFeature.ARM_VACATION], + ) + component.async_register_entity_service( + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + ALARM_SERVICE_SCHEMA, + "async_handle_alarm_arm_custom_bypass", + [AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS], + ) + component.async_register_entity_service( + SERVICE_ALARM_TRIGGER, + ALARM_SERVICE_SCHEMA, + "async_alarm_trigger", + [AlarmControlPanelEntityFeature.TRIGGER], + ) diff --git a/homeassistant/components/alexa_devices/diagnostics.py b/homeassistant/components/alexa_devices/diagnostics.py index 09a061737a2a2..58342307ac39d 100644 --- a/homeassistant/components/alexa_devices/diagnostics.py +++ b/homeassistant/components/alexa_devices/diagnostics.py @@ -55,9 +55,11 @@ async def async_get_device_diagnostics( coordinator = entry.runtime_data if TYPE_CHECKING: - # alexa_devices does not create child devices, and devices have a serial number assert isinstance(device_entry, DeviceEntry) - assert device_entry.serial_number + + if device_entry.serial_number is None: + # Service device has no serial number and no matching coordinator data + return {"service device": True} return build_device_data(coordinator.data[device_entry.serial_number]) diff --git a/homeassistant/components/bosch_shc/__init__.py b/homeassistant/components/bosch_shc/__init__.py index 621942675f719..2c6c23c498962 100644 --- a/homeassistant/components/bosch_shc/__init__.py +++ b/homeassistant/components/bosch_shc/__init__.py @@ -20,6 +20,7 @@ Platform.COVER, Platform.SENSOR, Platform.SWITCH, + Platform.VALVE, ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bosch_shc/icons.json b/homeassistant/components/bosch_shc/icons.json index 2ef170b1288a8..da813238f9776 100644 --- a/homeassistant/components/bosch_shc/icons.json +++ b/homeassistant/components/bosch_shc/icons.json @@ -12,6 +12,12 @@ } }, "switch": { + "bypass": { + "default": "mdi:shield-off-outline" + }, + "bypass_infinite": { + "default": "mdi:timer-off-outline" + }, "child_lock": { "default": "mdi:lock" }, diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index a5974a0b15278..6bc27ff9c05a0 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/sensor.py @@ -7,6 +7,7 @@ from boschshcpy import ( SHCLightSwitchBSM, SHCMicromoduleShutterControl, + SHCSession, SHCSmartPlug, SHCSmartPlugCompact, SHCThermostat, @@ -22,16 +23,19 @@ SensorStateClass, ) from homeassistant.const import ( + EntityCategory, UnitOfEnergy, UnitOfPower, UnitOfRatio, UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import BoschConfigEntry +from .const import DOMAIN from .entity import SHCEntity PARALLEL_UPDATES = 0 @@ -53,6 +57,7 @@ class SHCSensorEntityDescription[_DeviceT: SHCDevice](SensorEntityDescription): TEMPERATURE_SENSOR = "temperature" HUMIDITY_SENSOR = "humidity" VALVE_TAPPET_SENSOR = "valvetappet" +VALVE_TAPPET_STATE_SENSOR = "valve_tappet_state" PURITY_SENSOR = "purity" AIR_QUALITY_SENSOR = "airquality" TEMPERATURE_RATING_SENSOR = "temperature_rating" @@ -62,6 +67,15 @@ class SHCSensorEntityDescription[_DeviceT: SHCDevice](SensorEntityDescription): ENERGY_SENSOR = "energy" COMMUNICATION_QUALITY_SENSOR = "communication_quality" + +def _valve_tappet_state_value(device: SHCThermostat) -> str | None: + """Return the valve motor status enum string, or None on unknown value.""" + try: + return str(device.valvestate.name.lower()) + except ValueError, AttributeError: + return None + + _THERMOSTAT_TEMPERATURE_DESCRIPTION: SHCSensorEntityDescription[SHCThermostat] = ( SHCSensorEntityDescription( key=TEMPERATURE_SENSOR, @@ -77,10 +91,44 @@ class SHCSensorEntityDescription[_DeviceT: SHCDevice](SensorEntityDescription): translation_key=VALVE_TAPPET_SENSOR, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + # Superseded by the "valve" platform's position entity; kept + # available (opt-in) for anyone already relying on the raw percentage. + entity_registry_enabled_default=False, + suggested_display_precision=0, value_fn=lambda device: device.position, - attributes_fn=lambda device: { - "valve_tappet_state": device.valvestate.name, - }, + # Kept for anyone already reading this attribute in a template or + # automation, even though the same value is now also a first-class + # sensor below (_VALVE_TAPPET_STATE_DESCRIPTION). + attributes_fn=lambda device: {"valve_tappet_state": device.valvestate.name}, + ) +) +_VALVE_TAPPET_STATE_DESCRIPTION: SHCSensorEntityDescription[SHCThermostat] = ( + SHCSensorEntityDescription( + key=VALVE_TAPPET_STATE_SENSOR, + translation_key=VALVE_TAPPET_STATE_SENSOR, + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + "valve_adaption_successful", + "valve_adaption_in_progress", + "valve_adaption_requested", + "range_too_big", + "range_too_small", + "run_to_start_position", + "start_position_requested", + "in_start_position", + "not_available", + "no_valve_body_error", + "no_motor_error", + "valve_too_tight", + "fix_motor_logic_requested", + "fix_motor_logic_in_progress", + "fix_motor_logic_successful", + "error", + "unknown", + ], + value_fn=_valve_tappet_state_value, ) ) _WALLTHERMOSTAT_TEMPERATURE_DESCRIPTION: SHCSensorEntityDescription[ @@ -223,6 +271,7 @@ async def async_setup_entry( for description in ( _THERMOSTAT_TEMPERATURE_DESCRIPTION, _VALVE_TAPPET_DESCRIPTION, + _VALVE_TAPPET_STATE_DESCRIPTION, ) ] @@ -297,6 +346,57 @@ async def async_setup_entry( async_add_entities(entities) + async_add_entities( + [SHCOpenWindowsSensor(session=session, parent_id=shc_info.unique_id)], + update_before_add=True, + ) + + +class SHCOpenWindowsSensor(SensorEntity): + """Whole-home summary of open doors/windows (official OpenAPI spec). + + Not tied to one SHC device, so this does not inherit SHCEntity — it's + scoped to the config entry and linked to the hub device directly. The + underlying doors-windows/openwindows endpoint is a plain GET, not + delivered by the long-poll stream, so this needs should_poll=True. + """ + + _attr_has_entity_name = True + _attr_translation_key = "open_windows_doors" + _attr_should_poll = True + + def __init__(self, session: SHCSession, parent_id: str) -> None: + """Initialize the open-windows/doors summary sensor.""" + self._session = session + self._attr_unique_id = f"{parent_id}_open_windows_doors" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, parent_id)}) + self._open_doors: list[dict[str, Any]] = [] + self._open_windows: list[dict[str, Any]] = [] + self._open_others: list[dict[str, Any]] = [] + + @property + @override + def native_value(self) -> int: + """Return the total count of open doors, windows, and other openings.""" + return len(self._open_doors) + len(self._open_windows) + len(self._open_others) + + @property + @override + def extra_state_attributes(self) -> dict[str, list[str]]: + """Return the names of each currently-open door/window/other opening.""" + return { + "open_doors": [d.get("name", "") for d in self._open_doors], + "open_windows": [w.get("name", "") for w in self._open_windows], + "open_others": [o.get("name", "") for o in self._open_others], + } + + def update(self) -> None: + """Poll the whole-home open-doors/open-windows summary.""" + data = self._session.api.get_open_windows() + self._open_doors = data.get("openDoors", []) + self._open_windows = data.get("openWindows", []) + self._open_others = data.get("openOthers", []) + class SHCSensor[_DeviceT: SHCDevice](SHCEntity, SensorEntity): """Representation of a SHC sensor.""" diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index 1cc1069f2c24d..7dac8015b5481 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -53,6 +53,9 @@ "humidity_rating": { "name": "Humidity rating" }, + "open_windows_doors": { + "name": "Open doors and windows" + }, "purity": { "name": "Purity" }, @@ -62,16 +65,49 @@ "temperature_rating": { "name": "Temperature rating" }, + "valve_tappet_state": { + "name": "Valve motor status", + "state": { + "error": "Error", + "fix_motor_logic_in_progress": "Motor logic fix in progress", + "fix_motor_logic_requested": "Motor logic fix requested", + "fix_motor_logic_successful": "Motor logic fix successful", + "in_start_position": "In start position", + "no_motor_error": "Error: no motor detected", + "no_valve_body_error": "Error: no valve body detected", + "not_available": "Not available", + "range_too_big": "Range too big", + "range_too_small": "Range too small", + "run_to_start_position": "Running to start position", + "start_position_requested": "Start position requested", + "unknown": "Unknown", + "valve_adaption_in_progress": "Adaptation in progress", + "valve_adaption_requested": "Adaptation requested", + "valve_adaption_successful": "Adaptation successful", + "valve_too_tight": "Valve too tight" + } + }, "valvetappet": { "name": "Valvetappet" } }, "switch": { + "bypass": { + "name": "Break function" + }, + "bypass_infinite": { + "name": "Break function never expires" + }, "child_lock": { "name": "Child lock" }, "routing": { - "name": "Routing" + "name": "Range extension" + } + }, + "valve": { + "valve": { + "name": "Valve" } } } diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index f1949dfd14807..34cc500cd6050 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -5,9 +5,11 @@ from typing import TYPE_CHECKING, Any, override from boschshcpy import ( + BypassService, CameraLightService, PowerSwitchService, PrivacyModeService, + SHCShutterContact2, SHCSmartPlug, ThermostatService, ) @@ -98,6 +100,14 @@ class SHCSwitchEntityDescription(SwitchEntityDescription): on_value=True, should_poll=False, ), + "bypass": SHCSwitchEntityDescription( + key="bypass", + translation_key="bypass", + device_class=SwitchDeviceClass.SWITCH, + on_key="bypass", + on_value=BypassService.State.BYPASS_ACTIVE, + should_poll=False, + ), } @@ -226,6 +236,27 @@ async def async_setup_entry( ) ) + entities.extend( + SHCSwitch( + hass=hass, + device=switch, + parent_id=shc_info.unique_id, + entry_id=config_entry.entry_id, + description=SWITCH_TYPES["bypass"], + ) + for switch in session.device_helper.shutter_contacts2 + ) + + entities.extend( + SHCBypassInfiniteSwitch( + hass=hass, + device=switch, + parent_id=shc_info.unique_id, + entry_id=config_entry.entry_id, + ) + for switch in session.device_helper.shutter_contacts2 + ) + async_add_entities(entities) @@ -308,3 +339,35 @@ def turn_on(self, **kwargs: Any) -> None: def turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" self._device.routing = False + + +class SHCBypassInfiniteSwitch(SHCEntity, SwitchEntity): + """Representation of a SHC alarm-bypass "never expires" switch.""" + + _attr_translation_key = "bypass_infinite" + _attr_device_class = SwitchDeviceClass.SWITCH + _attr_entity_category = EntityCategory.CONFIG + _device: SHCShutterContact2 + + def __init__( + self, hass: HomeAssistant, device: SHCDevice, parent_id: str, entry_id: str + ) -> None: + """Initialize an SHC bypass-never-expires switch.""" + super().__init__(hass, device, parent_id, entry_id) + self._attr_unique_id = f"{device.serial}_bypass_infinite" + + @property + @override + def is_on(self) -> bool: + """Return the state of the switch.""" + return self._device.bypass_infinite + + @override + def turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + self._device.set_bypass_configuration(infinite=True) + + @override + def turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + self._device.set_bypass_configuration(infinite=False) diff --git a/homeassistant/components/bosch_shc/valve.py b/homeassistant/components/bosch_shc/valve.py new file mode 100644 index 0000000000000..d969f8c0e147f --- /dev/null +++ b/homeassistant/components/bosch_shc/valve.py @@ -0,0 +1,57 @@ +"""Platform for valve integration.""" + +from typing import TYPE_CHECKING, override + +from boschshcpy import SHCThermostat + +from homeassistant.components.valve import ValveDeviceClass, ValveEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BoschConfigEntry +from .entity import SHCEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BoschConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the SHC valve platform.""" + session = config_entry.runtime_data + + shc_info = session.information + if TYPE_CHECKING: + assert shc_info is not None and shc_info.unique_id is not None + + async_add_entities( + SHCValve( + hass=hass, + device=device, + parent_id=shc_info.unique_id, + entry_id=config_entry.entry_id, + ) + for device in session.device_helper.thermostats + ) + + +class SHCValve(SHCEntity, ValveEntity): + """Representation of a SHC thermostat valve position.""" + + _attr_device_class = ValveDeviceClass.WATER + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_reports_position = True + _attr_translation_key = "valve" + _device: SHCThermostat + + @property + @override + def current_valve_position(self) -> int | None: + """Return current position of valve. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._device.position diff --git a/homeassistant/components/date/__init__.py b/homeassistant/components/date/__init__.py index a1a2e8cb0f487..ab675f2960bf6 100644 --- a/homeassistant/components/date/__init__.py +++ b/homeassistant/components/date/__init__.py @@ -4,23 +4,21 @@ import logging from typing import final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_DATE -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import ATTR_DATE # noqa: F401 +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN, SERVICE_SET_VALUE +from .const import DATA_COMPONENT, DOMAIN, SERVICE_SET_VALUE # noqa: F401 +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[DateEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -30,11 +28,6 @@ __all__ = ["DOMAIN", "DateEntity", "DateEntityDescription"] -async def _async_set_value(entity: DateEntity, service_call: ServiceCall) -> None: - """Service call wrapper to set a new date.""" - return await entity.async_set_value(service_call.data[ATTR_DATE]) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Date entities.""" component = hass.data[DATA_COMPONENT] = EntityComponent[DateEntity]( @@ -42,9 +35,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service( - SERVICE_SET_VALUE, {probatio.Required(ATTR_DATE): cv.date}, _async_set_value - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/date/const.py b/homeassistant/components/date/const.py index 48eef31bed2d9..e229e980eef5a 100644 --- a/homeassistant/components/date/const.py +++ b/homeassistant/components/date/const.py @@ -1,7 +1,15 @@ """Provides the constants needed for the component.""" -from typing import Final +from typing import TYPE_CHECKING, Final + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import DateEntity DOMAIN: Final = "date" +DATA_COMPONENT: HassKey[EntityComponent[DateEntity]] = HassKey(DOMAIN) SERVICE_SET_VALUE = "set_value" diff --git a/homeassistant/components/date/services.py b/homeassistant/components/date/services.py new file mode 100644 index 0000000000000..057bf1d545938 --- /dev/null +++ b/homeassistant/components/date/services.py @@ -0,0 +1,27 @@ +"""Services for the Date integration.""" + +from typing import TYPE_CHECKING + +import probatio + +from homeassistant.const import ATTR_DATE +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv + +from .const import DATA_COMPONENT, SERVICE_SET_VALUE + +if TYPE_CHECKING: + from . import DateEntity + + +async def _async_set_value(entity: DateEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new date.""" + return await entity.async_set_value(service_call.data[ATTR_DATE]) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the date services.""" + hass.data[DATA_COMPONENT].async_register_entity_service( + SERVICE_SET_VALUE, {probatio.Required(ATTR_DATE): cv.date}, _async_set_value + ) diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index b5c4f7df94504..794bae68ddc53 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -4,23 +4,25 @@ import logging from typing import final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util import dt as dt_util -from homeassistant.util.hass_dict import HassKey -from .const import ATTR_DATETIME, DOMAIN, SERVICE_SET_VALUE +from .const import ( # noqa: F401 + ATTR_DATETIME, + DATA_COMPONENT, + DOMAIN, + SERVICE_SET_VALUE, +) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[DateTimeEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -30,14 +32,6 @@ __all__ = ["ATTR_DATETIME", "DOMAIN", "DateTimeEntity", "DateTimeEntityDescription"] -async def _async_set_value(entity: DateTimeEntity, service_call: ServiceCall) -> None: - """Service call wrapper to set a new date/time.""" - value: datetime = service_call.data[ATTR_DATETIME] - if value.tzinfo is None: - value = value.replace(tzinfo=dt_util.get_default_time_zone()) - return await entity.async_set_value(value) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Date/Time entities.""" component = hass.data[DATA_COMPONENT] = EntityComponent[DateTimeEntity]( @@ -45,13 +39,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service( - SERVICE_SET_VALUE, - { - probatio.Required(ATTR_DATETIME): cv.datetime, - }, - _async_set_value, - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/datetime/const.py b/homeassistant/components/datetime/const.py index 983bf75e2436e..aa532ce4ff799 100644 --- a/homeassistant/components/datetime/const.py +++ b/homeassistant/components/datetime/const.py @@ -1,8 +1,16 @@ """Provides the constants needed for the component.""" -from typing import Final +from typing import TYPE_CHECKING, Final + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import DateTimeEntity DOMAIN: Final = "datetime" +DATA_COMPONENT: HassKey[EntityComponent[DateTimeEntity]] = HassKey(DOMAIN) ATTR_DATETIME = "datetime" diff --git a/homeassistant/components/datetime/services.py b/homeassistant/components/datetime/services.py new file mode 100644 index 0000000000000..9e47a75bc64e6 --- /dev/null +++ b/homeassistant/components/datetime/services.py @@ -0,0 +1,35 @@ +"""Services for the Date/Time integration.""" + +from datetime import datetime +from typing import TYPE_CHECKING + +import probatio + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.util import dt as dt_util + +from .const import ATTR_DATETIME, DATA_COMPONENT, SERVICE_SET_VALUE + +if TYPE_CHECKING: + from . import DateTimeEntity + + +async def _async_set_value(entity: DateTimeEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new date/time.""" + value: datetime = service_call.data[ATTR_DATETIME] + if value.tzinfo is None: + value = value.replace(tzinfo=dt_util.get_default_time_zone()) + return await entity.async_set_value(value) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the date/time services.""" + hass.data[DATA_COMPONENT].async_register_entity_service( + SERVICE_SET_VALUE, + { + probatio.Required(ATTR_DATETIME): cv.datetime, + }, + _async_set_value, + ) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 8bb9bac83c438..d95876739ae15 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -9,7 +9,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.48.1", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.48.2", "getmac==0.9.5"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index b38d044a66420..4ea22b2c38000 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["async-upnp-client==0.48.1"], + "requirements": ["async-upnp-client==0.48.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 921a6b9c3a1d3..189b05ef328cc 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -6,11 +6,10 @@ import math from typing import Any, final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -22,45 +21,41 @@ from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, ) -from .const import ( +from .const import ( # noqa: F401 + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + DATA_COMPONENT, + DIRECTION_FORWARD, + DIRECTION_REVERSE, DOMAIN, + SERVICE_DECREASE_SPEED, + SERVICE_INCREASE_SPEED, + SERVICE_OSCILLATE, + SERVICE_SET_DIRECTION, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, FanEntityCapabilityAttribute, FanEntityFeature, FanEntityStateAttribute, ) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[FanEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = timedelta(seconds=30) - -SERVICE_INCREASE_SPEED = "increase_speed" -SERVICE_DECREASE_SPEED = "decrease_speed" -SERVICE_OSCILLATE = "oscillate" -SERVICE_SET_DIRECTION = "set_direction" -SERVICE_SET_PERCENTAGE = "set_percentage" -SERVICE_SET_PRESET_MODE = "set_preset_mode" - -DIRECTION_FORWARD = "forward" -DIRECTION_REVERSE = "reverse" - -ATTR_PERCENTAGE = "percentage" -ATTR_PERCENTAGE_STEP = "percentage_step" -ATTR_OSCILLATING = "oscillating" -ATTR_DIRECTION = "direction" -ATTR_PRESET_MODE = "preset_mode" -ATTR_PRESET_MODES = "preset_modes" - # mypy: disallow-any-generics @@ -94,76 +89,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) - # After the transition to percentage and preset_modes concludes, - # switch this back to async_turn_on and remove async_turn_on_compat - component.async_register_entity_service( - SERVICE_TURN_ON, - { - probatio.Optional(ATTR_PERCENTAGE): probatio.All( - probatio.Coerce(int), probatio.Range(min=0, max=100) - ), - probatio.Optional(ATTR_PRESET_MODE): cv.string, - }, - "async_handle_turn_on_service", - [FanEntityFeature.TURN_ON], - ) - component.async_register_entity_service( - SERVICE_TURN_OFF, None, "async_turn_off", [FanEntityFeature.TURN_OFF] - ) - component.async_register_entity_service( - SERVICE_TOGGLE, - None, - "async_toggle", - [FanEntityFeature.TURN_OFF, FanEntityFeature.TURN_ON], - ) - component.async_register_entity_service( - SERVICE_INCREASE_SPEED, - { - probatio.Optional(ATTR_PERCENTAGE_STEP): probatio.All( - probatio.Coerce(int), probatio.Range(min=0, max=100) - ) - }, - "async_increase_speed", - [FanEntityFeature.SET_SPEED], - ) - component.async_register_entity_service( - SERVICE_DECREASE_SPEED, - { - probatio.Optional(ATTR_PERCENTAGE_STEP): probatio.All( - probatio.Coerce(int), probatio.Range(min=0, max=100) - ) - }, - "async_decrease_speed", - [FanEntityFeature.SET_SPEED], - ) - component.async_register_entity_service( - SERVICE_OSCILLATE, - {probatio.Required(ATTR_OSCILLATING): cv.boolean}, - "async_oscillate", - [FanEntityFeature.OSCILLATE], - ) - component.async_register_entity_service( - SERVICE_SET_DIRECTION, - {probatio.Optional(ATTR_DIRECTION): cv.string}, - "async_set_direction", - [FanEntityFeature.DIRECTION], - ) - component.async_register_entity_service( - SERVICE_SET_PERCENTAGE, - { - probatio.Required(ATTR_PERCENTAGE): probatio.All( - probatio.Coerce(int), probatio.Range(min=0, max=100) - ) - }, - "async_set_percentage", - [FanEntityFeature.SET_SPEED], - ) - component.async_register_entity_service( - SERVICE_SET_PRESET_MODE, - {probatio.Required(ATTR_PRESET_MODE): cv.string}, - "async_handle_set_preset_mode_service", - [FanEntityFeature.SET_SPEED, FanEntityFeature.PRESET_MODE], - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/fan/const.py b/homeassistant/components/fan/const.py index 1d565787f2147..97893dba42105 100644 --- a/homeassistant/components/fan/const.py +++ b/homeassistant/components/fan/const.py @@ -1,9 +1,34 @@ """Constants for the fan component.""" from enum import IntFlag, StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import FanEntity DOMAIN: Final = "fan" +DATA_COMPONENT: HassKey[EntityComponent[FanEntity]] = HassKey(DOMAIN) + +SERVICE_INCREASE_SPEED = "increase_speed" +SERVICE_DECREASE_SPEED = "decrease_speed" +SERVICE_OSCILLATE = "oscillate" +SERVICE_SET_DIRECTION = "set_direction" +SERVICE_SET_PERCENTAGE = "set_percentage" +SERVICE_SET_PRESET_MODE = "set_preset_mode" + +DIRECTION_FORWARD = "forward" +DIRECTION_REVERSE = "reverse" + +ATTR_PERCENTAGE = "percentage" +ATTR_PERCENTAGE_STEP = "percentage_step" +ATTR_OSCILLATING = "oscillating" +ATTR_DIRECTION = "direction" +ATTR_PRESET_MODE = "preset_mode" +ATTR_PRESET_MODES = "preset_modes" class FanEntityCapabilityAttribute(StrEnum): diff --git a/homeassistant/components/fan/llm.py b/homeassistant/components/fan/llm.py index 0e3c3262b8c6f..e28dfd5a7e432 100644 --- a/homeassistant/components/fan/llm.py +++ b/homeassistant/components/fan/llm.py @@ -4,13 +4,25 @@ from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import intent -from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool +from homeassistant.helpers.llm import ( + LLM_API_ASSIST, + IntentTool, + LLMContext, + Tool, + ToolAnnotations, +) from .const import DOMAIN from .intent import INTENT_FAN_SET_SPEED +# Each intent sets a value on the user's own entities, so calling one again +# with the same arguments has no further effect. +LLM_ANNOTATIONS = ToolAnnotations(idempotent=True, open_world=False) + # Intents owned by this integration that are exposed as LLM tools. -LLM_INTENTS = (INTENT_FAN_SET_SPEED,) +LLM_INTENTS = { + INTENT_FAN_SET_SPEED: "Set fan speed", +} @callback @@ -31,7 +43,13 @@ def async_get_tools( return None tools: list[Tool] = [ - IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) + IntentTool( + f"{DOMAIN}__{handler.intent_type}", + handler, + title=LLM_INTENTS[handler.intent_type], + integration=DOMAIN, + annotations=LLM_ANNOTATIONS, + ) for handler in intent.async_get(hass) if handler.intent_type in LLM_INTENTS ] diff --git a/homeassistant/components/fan/services.py b/homeassistant/components/fan/services.py new file mode 100644 index 0000000000000..63f0c0e00fec8 --- /dev/null +++ b/homeassistant/components/fan/services.py @@ -0,0 +1,100 @@ +"""Services for the Fan integration.""" + +import probatio + +from homeassistant.const import SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv + +from .const import ( + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, + ATTR_PRESET_MODE, + DATA_COMPONENT, + SERVICE_DECREASE_SPEED, + SERVICE_INCREASE_SPEED, + SERVICE_OSCILLATE, + SERVICE_SET_DIRECTION, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, + FanEntityFeature, +) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the fan services.""" + component = hass.data[DATA_COMPONENT] + + # After the transition to percentage and preset_modes concludes, + # switch this back to async_turn_on and remove async_turn_on_compat + component.async_register_entity_service( + SERVICE_TURN_ON, + { + probatio.Optional(ATTR_PERCENTAGE): probatio.All( + probatio.Coerce(int), probatio.Range(min=0, max=100) + ), + probatio.Optional(ATTR_PRESET_MODE): cv.string, + }, + "async_handle_turn_on_service", + [FanEntityFeature.TURN_ON], + ) + component.async_register_entity_service( + SERVICE_TURN_OFF, None, "async_turn_off", [FanEntityFeature.TURN_OFF] + ) + component.async_register_entity_service( + SERVICE_TOGGLE, + None, + "async_toggle", + [FanEntityFeature.TURN_OFF, FanEntityFeature.TURN_ON], + ) + component.async_register_entity_service( + SERVICE_INCREASE_SPEED, + { + probatio.Optional(ATTR_PERCENTAGE_STEP): probatio.All( + probatio.Coerce(int), probatio.Range(min=0, max=100) + ) + }, + "async_increase_speed", + [FanEntityFeature.SET_SPEED], + ) + component.async_register_entity_service( + SERVICE_DECREASE_SPEED, + { + probatio.Optional(ATTR_PERCENTAGE_STEP): probatio.All( + probatio.Coerce(int), probatio.Range(min=0, max=100) + ) + }, + "async_decrease_speed", + [FanEntityFeature.SET_SPEED], + ) + component.async_register_entity_service( + SERVICE_OSCILLATE, + {probatio.Required(ATTR_OSCILLATING): cv.boolean}, + "async_oscillate", + [FanEntityFeature.OSCILLATE], + ) + component.async_register_entity_service( + SERVICE_SET_DIRECTION, + {probatio.Optional(ATTR_DIRECTION): cv.string}, + "async_set_direction", + [FanEntityFeature.DIRECTION], + ) + component.async_register_entity_service( + SERVICE_SET_PERCENTAGE, + { + probatio.Required(ATTR_PERCENTAGE): probatio.All( + probatio.Coerce(int), probatio.Range(min=0, max=100) + ) + }, + "async_set_percentage", + [FanEntityFeature.SET_SPEED], + ) + component.async_register_entity_service( + SERVICE_SET_PRESET_MODE, + {probatio.Required(ATTR_PRESET_MODE): cv.string}, + "async_handle_set_preset_mode_service", + [FanEntityFeature.SET_SPEED, FanEntityFeature.PRESET_MODE], + ) diff --git a/homeassistant/components/gatus/quality_scale.yaml b/homeassistant/components/gatus/quality_scale.yaml index a5dadd68ea642..deb415ac2bacb 100644 --- a/homeassistant/components/gatus/quality_scale.yaml +++ b/homeassistant/components/gatus/quality_scale.yaml @@ -64,9 +64,7 @@ rules: dynamic-devices: done entity-category: done entity-device-class: done - entity-disabled-by-default: - status: exempt - comment: All entities represent monitored services and should be enabled by default. + entity-disabled-by-default: done entity-translations: done exception-translations: done icon-translations: done diff --git a/homeassistant/components/gatus/sensor.py b/homeassistant/components/gatus/sensor.py index a04022ce8081c..21682fce57e53 100644 --- a/homeassistant/components/gatus/sensor.py +++ b/homeassistant/components/gatus/sensor.py @@ -60,6 +60,7 @@ class GatusSensorEntityDescription(SensorEntityDescription): key="status_code", translation_key="status_code", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, value_fn=lambda coordinator, endpoint: ( endpoint.results[-1].status if endpoint.results else None ), @@ -70,6 +71,7 @@ class GatusSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.ENUM, options=["start", "healthy", "unhealthy", "resolved"], entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, value_fn=lambda coordinator, endpoint: ( endpoint.events[-1].type.lower() if endpoint.events else None ), @@ -79,6 +81,7 @@ class GatusSensorEntityDescription(SensorEntityDescription): translation_key="certificate_expiration", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, value_fn=lambda coordinator, endpoint: ( coordinator.last_update_time + timedelta( @@ -93,6 +96,7 @@ class GatusSensorEntityDescription(SensorEntityDescription): key="dns_rcode", translation_key="dns_rcode", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, value_fn=lambda coordinator, endpoint: ( DNS_RCODE_MAP.get( endpoint.results[-1].dns_rcode, diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 5285a5f2f4119..67abae3549478 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -18,6 +18,7 @@ from homeassistant.auth.models import User from homeassistant.components import frontend from homeassistant.components.homeassistant import async_set_stop_handler +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.components.onboarding import async_is_onboarded from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry from homeassistant.const import EVENT_CORE_CONFIG_UPDATE, HASSIO_USER_NAME, Platform @@ -57,7 +58,6 @@ ADDONS_COORDINATOR, DATA_COMPONENT, DATA_HASSIO_HOST, - DATA_HASSIO_SUPERVISOR_USER, DATA_KEY_SUPERVISOR_ISSUES, DOMAIN, ENTRY_DATA_USER, @@ -340,7 +340,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if legacy_data is not None: legacy_user_id = legacy_data.get("hassio_user") - hass.data[DATA_HASSIO_SUPERVISOR_USER] = await _async_get_or_create_supervisor_user( + hass.data[DATA_SUPERVISOR_USER] = await _async_get_or_create_supervisor_user( hass, entry, legacy_user_id ) @@ -386,7 +386,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: remove_legacy_store = True # Async setup runs first unconditionally and always populates this field - user = hass.data[DATA_HASSIO_SUPERVISOR_USER] + user = hass.data[DATA_SUPERVISOR_USER] if entry.data.get(ENTRY_DATA_USER) != user.id: hass.config_entries.async_update_entry( entry, diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py index 03065b0b4c852..4f728ec81f41c 100644 --- a/homeassistant/components/hassio/auth.py +++ b/homeassistant/components/hassio/auth.py @@ -15,12 +15,15 @@ from homeassistant.auth.providers import homeassistant as auth_ha from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView -from homeassistant.components.http.const import is_supervisor_unix_socket_request +from homeassistant.components.http.const import ( + DATA_SUPERVISOR_USER, + is_supervisor_unix_socket_request, +) from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME, DATA_HASSIO_SUPERVISOR_USER +from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME _LOGGER = logging.getLogger(__name__) @@ -41,7 +44,7 @@ def __init__(self, hass: HomeAssistant) -> None: def _check_access(self, request: web.Request) -> None: """Check if this call is from Supervisor.""" - user = self.hass.data.get(DATA_HASSIO_SUPERVISOR_USER) + user = self.hass.data.get(DATA_SUPERVISOR_USER) if user is None: raise HTTPServiceUnavailable diff --git a/homeassistant/components/hassio/config_flow.py b/homeassistant/components/hassio/config_flow.py index 95c3733df7733..54d9163fd7cab 100644 --- a/homeassistant/components/hassio/config_flow.py +++ b/homeassistant/components/hassio/config_flow.py @@ -2,15 +2,11 @@ from typing import Any +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import HASSIO_USER_NAME -from .const import ( - DATA_HASSIO_SUPERVISOR_USER, - DEFAULT_UPDATE_OPTIONS, - DOMAIN, - ENTRY_DATA_USER, -) +from .const import DEFAULT_UPDATE_OPTIONS, DOMAIN, ENTRY_DATA_USER class HassIoConfigFlow(ConfigFlow, domain=DOMAIN): @@ -23,7 +19,7 @@ async def async_step_system( ) -> ConfigFlowResult: """Handle the initial step.""" data: dict[str, Any] = {} - if (user := self.hass.data.get(DATA_HASSIO_SUPERVISOR_USER)) is not None: + if (user := self.hass.data.get(DATA_SUPERVISOR_USER)) is not None: data[ENTRY_DATA_USER] = user.id return self.async_create_entry( diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index d05a16fa2a79e..8fd735023570f 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -20,8 +20,6 @@ SupervisorInfo, ) - from homeassistant.auth.models import User - from .coordinator import ( HassioAddOnDataUpdateCoordinator, HassioMainDataUpdateCoordinator, @@ -153,7 +151,6 @@ ) DATA_KEY_MOUNTS = "mounts" DATA_HASSIO_HOST: HassKey[str] = HassKey("hassio_host") -DATA_HASSIO_SUPERVISOR_USER: HassKey[User] = HassKey("hassio_supervisor_user") ENTRY_DATA_USER = "user" diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index cbc45ee5ad0a5..e7dcaf626d5b8 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -9,11 +9,7 @@ import probatio from homeassistant.components.network import async_get_source_ip -from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, - HASSIO_USER_NAME, -) +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, issue_registry as ir @@ -48,6 +44,7 @@ CONF_TRUSTED_PROXIES, CONF_USE_X_FORWARDED_FOR, CONF_USE_X_FRAME_OPTIONS, + DATA_SUPERVISOR_USER, DEFAULT_CORS, DOMAIN, KEY_HASS_REFRESH_TOKEN_ID, @@ -215,11 +212,7 @@ async def start_server(*_: Any) -> None: async def start_supervisor_unix_socket(*_: Any) -> None: """Start the Unix socket after the Supervisor user is available.""" - if any( - user - for user in await hass.auth.async_get_users() - if user.system_generated and user.name == HASSIO_USER_NAME - ): + if hass.data.get(DATA_SUPERVISOR_USER) is not None: await server.async_start_supervisor_unix_socket() else: _LOGGER.error("Supervisor user not found; not starting Unix socket") diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index e2c6871939fcf..702eb7c8adde2 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -22,7 +22,6 @@ from homeassistant.auth import jwt_wrapper from homeassistant.auth.const import GROUP_ID_READ_ONLY from homeassistant.components import websocket_api -from homeassistant.const import HASSIO_USER_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.http import current_request from homeassistant.helpers.json import json_bytes @@ -30,6 +29,7 @@ from .auth_util import async_user_not_allowed_do_auth from .const import ( + DATA_SUPERVISOR_USER, KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER, @@ -94,7 +94,7 @@ def async_sign_path( return f"{url.path}?{url.query_string}" -async def async_setup_auth( # noqa: C901 +async def async_setup_auth( hass: HomeAssistant, app: Application, ) -> None: @@ -187,40 +187,30 @@ def async_validate_signed_request(request: Request) -> bool: request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id return True - supervisor_user_id: str | None = None - async def async_authenticate_supervisor_unix_socket(request: Request) -> bool: """Authenticate a request from a Unix socket as the Supervisor user. The Unix Socket is dedicated and only available to Supervisor. To avoid the extra overhead and round trips for the authentication and refresh tokens, we directly authenticate requests from the socket as - the Supervisor user. + the Supervisor user provided by the hassio integration. The user is + looked up in the auth store so a user removed at runtime is not + authenticated any longer. """ - nonlocal supervisor_user_id - - # Fast path: use cached user ID - if supervisor_user_id is not None: - if user := await hass.auth.async_get_user(supervisor_user_id): - request[KEY_HASS_USER] = user - return True - supervisor_user_id = None - - # Slow path: find the Supervisor user by name - for user in await hass.auth.async_get_users(): - if user.system_generated and user.name == HASSIO_USER_NAME: - supervisor_user_id = user.id - # Not setting KEY_HASS_REFRESH_TOKEN_ID since Supervisor user - # doesn't use refresh tokens. - request[KEY_HASS_USER] = user - return True - - # The Unix socket should not be serving before the hassio integration - # has created the Supervisor user. If we get here, something is wrong. - _LOGGER.error( - "Supervisor user not found; cannot authenticate Unix socket request" - ) - raise HTTPInternalServerError + if (supervisor_user := hass.data.get(DATA_SUPERVISOR_USER)) is None or ( + user := await hass.auth.async_get_user(supervisor_user.id) + ) is None: + # The Unix socket should not be serving before the hassio integration + # has provided the Supervisor user. If we get here, something is wrong. + _LOGGER.error( + "Supervisor user not found; cannot authenticate Unix socket request" + ) + raise HTTPInternalServerError + + # Not setting KEY_HASS_REFRESH_TOKEN_ID since Supervisor user + # doesn't use refresh tokens. + request[KEY_HASS_USER] = user + return True @middleware async def auth_middleware( diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index 24ada111c765c..a53b45aeb5c09 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -1,10 +1,14 @@ """HTTP specific constants.""" -from typing import Final +from typing import TYPE_CHECKING, Final from aiohttp.web import Request from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS # noqa: F401 +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.auth.models import User DOMAIN: Final = "http" @@ -12,6 +16,11 @@ KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id" KEY_SUPERVISOR_UNIX_SOCKET: Final = "ha_supervisor_unix_socket" +# System user used to authenticate requests over the Supervisor Unix socket. +# Set by the hassio integration during its setup; the Unix socket is only +# started once it is available. +DATA_SUPERVISOR_USER: HassKey[User] = HassKey("hassio_supervisor_user") + CONF_SERVER_HOST: Final = "server_host" CONF_SERVER_PORT: Final = "server_port" CONF_BASE_URL: Final = "base_url" diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 10994b43399b1..c4d10f6c4eb2e 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -4,24 +4,21 @@ import logging from typing import Any, final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 ATTR_MODE, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ServiceValidationError +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 ATTR_ACTION, @@ -31,6 +28,7 @@ ATTR_MAX_HUMIDITY, ATTR_MIN_HUMIDITY, ATTR_TARGET_HUMIDITY_STEP, + DATA_COMPONENT, DEFAULT_MAX_HUMIDITY, DEFAULT_MIN_HUMIDITY, DEVICE_CLASSES_SCHEMA, @@ -52,10 +50,10 @@ HumidifierEntityFeature, HumidifierEntityStateAttribute, ) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[HumidifierEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -84,24 +82,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service(SERVICE_TURN_ON, None, "async_turn_on") - component.async_register_entity_service(SERVICE_TURN_OFF, None, "async_turn_off") - component.async_register_entity_service(SERVICE_TOGGLE, None, "async_toggle") - component.async_register_entity_service( - SERVICE_SET_MODE, - {probatio.Required(ATTR_MODE): cv.string}, - "async_set_mode", - [HumidifierEntityFeature.MODES], - ) - component.async_register_entity_service( - SERVICE_SET_HUMIDITY, - { - probatio.Required(ATTR_HUMIDITY): probatio.All( - probatio.Coerce(int), probatio.Range(min=0, max=100) - ) - }, - async_service_humidity_set, - ) + async_setup_services(hass) return True @@ -282,30 +263,3 @@ def max_humidity(self) -> float: def supported_features(self) -> HumidifierEntityFeature: """Return the list of supported features.""" return self._attr_supported_features - - -async def async_service_humidity_set( - entity: HumidifierEntity, service_call: ServiceCall -) -> None: - """Handle set humidity service.""" - humidity = service_call.data[ATTR_HUMIDITY] - min_humidity = entity.min_humidity - max_humidity = entity.max_humidity - _LOGGER.debug( - "Check valid humidity %d in range %d - %d", - humidity, - min_humidity, - max_humidity, - ) - if humidity < min_humidity or humidity > max_humidity: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="humidity_out_of_range", - translation_placeholders={ - "humidity": str(humidity), - "min_humidity": str(min_humidity), - "max_humidity": str(max_humidity), - }, - ) - - await entity.async_set_humidity(humidity) diff --git a/homeassistant/components/humidifier/const.py b/homeassistant/components/humidifier/const.py index c0e5f77084cf1..105cca67076b9 100644 --- a/homeassistant/components/humidifier/const.py +++ b/homeassistant/components/humidifier/const.py @@ -1,11 +1,19 @@ """Provides the constants needed for component.""" from enum import IntFlag, StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final import probatio +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import HumidifierEntity + DOMAIN: Final = "humidifier" +DATA_COMPONENT: HassKey[EntityComponent[HumidifierEntity]] = HassKey(DOMAIN) MODE_NORMAL = "normal" MODE_ECO = "eco" diff --git a/homeassistant/components/humidifier/services.py b/homeassistant/components/humidifier/services.py new file mode 100644 index 0000000000000..9f6b30fd4b16e --- /dev/null +++ b/homeassistant/components/humidifier/services.py @@ -0,0 +1,82 @@ +"""Services for the Humidifier integration.""" + +import logging +from typing import TYPE_CHECKING + +import probatio + +from homeassistant.const import ( + ATTR_MODE, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv + +from .const import ( + ATTR_HUMIDITY, + DATA_COMPONENT, + DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, + HumidifierEntityFeature, +) + +if TYPE_CHECKING: + from . import HumidifierEntity + +_LOGGER = logging.getLogger(__name__) + + +async def _async_service_humidity_set( + entity: HumidifierEntity, service_call: ServiceCall +) -> None: + """Handle set humidity service.""" + humidity = service_call.data[ATTR_HUMIDITY] + min_humidity = entity.min_humidity + max_humidity = entity.max_humidity + _LOGGER.debug( + "Check valid humidity %d in range %d - %d", + humidity, + min_humidity, + max_humidity, + ) + if humidity < min_humidity or humidity > max_humidity: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="humidity_out_of_range", + translation_placeholders={ + "humidity": str(humidity), + "min_humidity": str(min_humidity), + "max_humidity": str(max_humidity), + }, + ) + + await entity.async_set_humidity(humidity) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the humidifier services.""" + component = hass.data[DATA_COMPONENT] + + component.async_register_entity_service(SERVICE_TURN_ON, None, "async_turn_on") + component.async_register_entity_service(SERVICE_TURN_OFF, None, "async_turn_off") + component.async_register_entity_service(SERVICE_TOGGLE, None, "async_toggle") + component.async_register_entity_service( + SERVICE_SET_MODE, + {probatio.Required(ATTR_MODE): cv.string}, + "async_set_mode", + [HumidifierEntityFeature.MODES], + ) + component.async_register_entity_service( + SERVICE_SET_HUMIDITY, + { + probatio.Required(ATTR_HUMIDITY): probatio.All( + probatio.Coerce(int), probatio.Range(min=0, max=100) + ) + }, + _async_service_humidity_set, + ) diff --git a/homeassistant/components/indi_allsky/__init__.py b/homeassistant/components/indi_allsky/__init__.py index 51eb4e00ba5b1..832240ccce7f7 100644 --- a/homeassistant/components/indi_allsky/__init__.py +++ b/homeassistant/components/indi_allsky/__init__.py @@ -5,7 +5,7 @@ from .coordinator import IndiAllSkyConfigEntry, IndiAllSkyDataUpdateCoordinator -_PLATFORMS: list[Platform] = [Platform.CAMERA] +_PLATFORMS: list[Platform] = [Platform.CAMERA, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: IndiAllSkyConfigEntry) -> bool: @@ -15,6 +15,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: IndiAllSkyConfigEntry) - entry.runtime_data = coordinator + entry.async_create_background_task( + hass, + coordinator.client.listen(auto_reconnect=True), + "indi_allsky_ws_events", + ) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True diff --git a/homeassistant/components/indi_allsky/coordinator.py b/homeassistant/components/indi_allsky/coordinator.py index f458005ba44c6..9a6b1a88691c5 100644 --- a/homeassistant/components/indi_allsky/coordinator.py +++ b/homeassistant/components/indi_allsky/coordinator.py @@ -1,9 +1,10 @@ """DataUpdateCoordinator for INDI Allsky integration.""" +from dataclasses import dataclass import logging from typing import override -from aioindiallsky import IndiAllSkyClient, IndiAllSkyError +from aioindiallsky import ExposureData, IndiAllSkyClient, IndiAllSkyError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL @@ -19,7 +20,14 @@ type IndiAllSkyConfigEntry = ConfigEntry[IndiAllSkyDataUpdateCoordinator] -class IndiAllSkyDataUpdateCoordinator(DataUpdateCoordinator[None]): +@dataclass +class IndiAllSkyData: + """Data model for INDI Allsky coordinator data.""" + + exposure: ExposureData | None = None + + +class IndiAllSkyDataUpdateCoordinator(DataUpdateCoordinator[IndiAllSkyData]): """Class to manage fetching INDI Allsky data from the API.""" def __init__(self, hass: HomeAssistant, entry: IndiAllSkyConfigEntry) -> None: @@ -33,6 +41,13 @@ def __init__(self, hass: HomeAssistant, entry: IndiAllSkyConfigEntry) -> None: ), session=async_get_clientsession(hass), ) + self.latest_exposure: ExposureData | None = None + + unsub = self.client.register_callback( + "exposure_complete", self._handle_exposure_complete + ) + entry.async_on_unload(unsub) + entry.async_on_unload(self.client.disconnect) super().__init__( hass, @@ -42,13 +57,22 @@ def __init__(self, hass: HomeAssistant, entry: IndiAllSkyConfigEntry) -> None: update_interval=None, ) + def _handle_exposure_complete(self, exposure: ExposureData) -> None: + """Handle new exposure_complete event from WebSocket stream.""" + self.latest_exposure = exposure + self.async_set_updated_data(IndiAllSkyData(exposure=exposure)) + @override - async def _async_update_data(self) -> None: + async def _async_update_data(self) -> IndiAllSkyData: """Fetch INDI Allsky metadata and verify connection.""" try: await self.client.fetch_image("latestimage") + if not self.client.is_connected: + await self.client.connect() except IndiAllSkyError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed", ) from err + + return IndiAllSkyData(exposure=self.latest_exposure) diff --git a/homeassistant/components/indi_allsky/manifest.json b/homeassistant/components/indi_allsky/manifest.json index fb63bfd8105df..74054ed8f762c 100644 --- a/homeassistant/components/indi_allsky/manifest.json +++ b/homeassistant/components/indi_allsky/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/indi_allsky", "integration_type": "service", - "iot_class": "local_polling", + "iot_class": "local_push", "loggers": ["aioindiallsky"], "quality_scale": "bronze", "requirements": ["aioindiallsky==0.1.2"] diff --git a/homeassistant/components/indi_allsky/sensor.py b/homeassistant/components/indi_allsky/sensor.py new file mode 100644 index 0000000000000..c90868471ea26 --- /dev/null +++ b/homeassistant/components/indi_allsky/sensor.py @@ -0,0 +1,120 @@ +"""Support for INDI Allsky sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import ( + IndiAllSkyConfigEntry, + IndiAllSkyData, + IndiAllSkyDataUpdateCoordinator, +) +from .entity import IndiAllSkyEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class IndiAllSkySensorEntityDescription(SensorEntityDescription): + """Class describing INDI Allsky sensor entities.""" + + value_fn: Callable[[IndiAllSkyData], StateType] + + +SENSOR_DESCRIPTIONS: tuple[IndiAllSkySensorEntityDescription, ...] = ( + IndiAllSkySensorEntityDescription( + key="binmode", + translation_key="binmode", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.exposure.binmode if data.exposure else None, + ), + IndiAllSkySensorEntityDescription( + key="exposure", + translation_key="exposure", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.exposure.exposure if data.exposure else None, + ), + IndiAllSkySensorEntityDescription( + key="filename", + translation_key="filename", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.exposure.filename if data.exposure else None, + ), + IndiAllSkySensorEntityDescription( + key="gain", + translation_key="gain", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.exposure.gain if data.exposure else None, + ), + IndiAllSkySensorEntityDescription( + key="sqm", + translation_key="sqm", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.exposure.sqm if data.exposure else None, + ), + IndiAllSkySensorEntityDescription( + key="stars", + translation_key="stars", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.exposure.stars if data.exposure else None, + ), + IndiAllSkySensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.exposure.temp if data.exposure else None, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IndiAllSkyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up INDI Allsky sensors based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + IndiAllSkySensor(coordinator, entry, description) + for description in SENSOR_DESCRIPTIONS + ) + + +class IndiAllSkySensor(IndiAllSkyEntity, SensorEntity): + """Representation of an INDI Allsky sensor.""" + + entity_description: IndiAllSkySensorEntityDescription + + def __init__( + self, + coordinator: IndiAllSkyDataUpdateCoordinator, + entry: IndiAllSkyConfigEntry, + description: IndiAllSkySensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, entry) + self.entity_description = description + self._attr_unique_id = f"{entry.entry_id}_{description.key}" + + @property + @override + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/indi_allsky/strings.json b/homeassistant/components/indi_allsky/strings.json index d4884470a3fdc..20318e771506c 100644 --- a/homeassistant/components/indi_allsky/strings.json +++ b/homeassistant/components/indi_allsky/strings.json @@ -26,6 +26,28 @@ } } }, + "entity": { + "sensor": { + "binmode": { + "name": "Binning mode" + }, + "exposure": { + "name": "Exposure time" + }, + "filename": { + "name": "Filename" + }, + "gain": { + "name": "Gain" + }, + "sqm": { + "name": "Sky quality" + }, + "stars": { + "name": "Stars" + } + } + }, "exceptions": { "update_failed": { "message": "Error communicating with INDI Allsky API" diff --git a/homeassistant/components/irm_kmi/coordinator.py b/homeassistant/components/irm_kmi/coordinator.py index feafb5b88b36c..cea0057d5899c 100644 --- a/homeassistant/components/irm_kmi/coordinator.py +++ b/homeassistant/components/irm_kmi/coordinator.py @@ -1,8 +1,8 @@ """DataUpdateCoordinator for the IRM KMI integration.""" -from datetime import timedelta +from datetime import datetime, timedelta import logging -from typing import override +from typing import Final, override from irm_kmi_api import IrmKmiApiClientHa, IrmKmiApiError @@ -14,13 +14,15 @@ UpdateFailed, ) from homeassistant.util import dt as dt_util -from homeassistant.util.dt import utcnow from .data import ProcessedCoordinatorData from .utils import preferred_language _LOGGER = logging.getLogger(__name__) +UPDATE_INTERVAL: Final = timedelta(minutes=7) +GRACE_PERIOD: Final = 2.5 * UPDATE_INTERVAL + type IrmKmiConfigEntry = ConfigEntry[IrmKmiCoordinator] @@ -39,19 +41,22 @@ def __init__( _LOGGER, config_entry=entry, name="IRM KMI weather", - update_interval=timedelta(minutes=7), + update_interval=UPDATE_INTERVAL, ) self._api = api_client self._location = entry.data[CONF_LOCATION] + # last_update_success_time is also renewed while serving old data + self._last_api_success_time: datetime | None = None + + def _within_grace(self, last_success: datetime | None) -> bool: + """Return whether data from the last success may still be served.""" + return ( + last_success is not None and dt_util.utcnow() - last_success < GRACE_PERIOD + ) @override async def _async_update_data(self) -> ProcessedCoordinatorData: - """Fetch data from API endpoint. - - Pre-process the data to lookup tables so entities - can quickly look up their data. - """ - + """Fetch and process the IRM KMI data.""" self._api.expire_cache() try: @@ -63,26 +68,17 @@ async def _async_update_data(self) -> ProcessedCoordinatorData: ) except IrmKmiApiError as err: - if ( - self.last_update_success_time is not None - and self.update_interval is not None - and self.last_update_success_time - utcnow() - < timedelta(seconds=2.5 * self.update_interval.seconds) - ): + if self._within_grace(self._last_api_success_time): return self.data - - _LOGGER.warning( - "Could not connect to the API since %s", self.last_update_success_time - ) raise UpdateFailed( f"Error communicating with API for general forecast: {err}. " - f"Last success time is: {self.last_update_success_time}" + f"Last success time is: {self._last_api_success_time}" ) from err - if not self.last_update_success: - _LOGGER.warning("Successfully reconnected to the API") - - return await self.process_api_data() + data = await self.process_api_data() + # Only once processed, so the grace period never serves missing data + self._last_api_success_time = dt_util.utcnow() + return data async def process_api_data(self) -> ProcessedCoordinatorData: """From the API data, create the object that will be used in the entities.""" diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index f5dd68f218c93..6e2f67df99971 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -31,7 +31,6 @@ CONF_RESET_AFTER, CONF_STATE_ADDRESS, CONF_SYNC_STATE, - DOMAIN, KNX_MODULE_KEY, ) from .entity import ( @@ -41,9 +40,7 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY -from .storage.entity_store_schema import BinarySensorKnxConfig +from .storage.entity_store_schema import BinarySensorKnxConfig, KnxEntityData async def async_setup_entry( @@ -158,12 +155,12 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = config[DOMAIN] + knx_conf = config.knx self._device = XknxBinarySensor( xknx=knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, group_address_state=knx_conf.ga_sensor.state_and_passive(), sync_state=knx_conf.sync_state, invert=knx_conf.invert, diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py index 53575c669c67e..e8fa5c41e87dc 100644 --- a/homeassistant/components/knx/button.py +++ b/homeassistant/components/knx/button.py @@ -14,7 +14,7 @@ ) from homeassistant.helpers.typing import ConfigType -from .const import CONF_PAYLOAD_LENGTH, CONF_VALUE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY +from .const import CONF_PAYLOAD_LENGTH, CONF_VALUE, KNX_ADDRESS, KNX_MODULE_KEY from .entity import ( KnxUiEntity, KnxUiEntityPlatformController, @@ -22,8 +22,8 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.config_store import KnxEntityData -from .storage.const import CONF_DATA, CONF_ENTITY, CONF_GA_SEND +from .storage.const import CONF_DATA, CONF_GA_SEND +from .storage.entity_store_schema import KnxEntityData from .storage.util import ConfigExtractor @@ -104,13 +104,13 @@ def __init__( self, knx_module: KNXModule, unique_id: str, config: KnxEntityData[Any] ) -> None: """Initialize a KNX button.""" - knx_conf = ConfigExtractor(config[DOMAIN]) + knx_conf = ConfigExtractor(config.knx) button_data = knx_conf.get(CONF_DATA) if CONF_PAYLOAD in button_data and CONF_PAYLOAD_LENGTH in button_data: self._payload = int(button_data[CONF_PAYLOAD], 16) self._device = XknxRawValue( xknx=knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, payload_length=button_data[CONF_PAYLOAD_LENGTH], group_address=knx_conf.get_write(CONF_GA_SEND), ) @@ -119,7 +119,7 @@ def __init__( self._payload = button_data[CONF_VALUE] self._device = XknxExposeSensor( xknx=knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, value_type=dpt_string, group_address=knx_conf.get_write(CONF_GA_SEND), respond_to_read=False, @@ -128,5 +128,5 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 608ce1b1e6e7f..a88e9b57c578a 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -37,7 +37,6 @@ CONF_SYNC_STATE, CONTROLLER_MODES, CURRENT_HVAC_ACTIONS, - DOMAIN, KNX_MODULE_KEY, ClimateConf, ) @@ -50,9 +49,7 @@ ) from .knx_module import KNXModule from .schema import ClimateSchema -from .storage.config_store import KnxEntityData from .storage.const import ( - CONF_ENTITY, CONF_GA_ACTIVE, CONF_GA_CONTROLLER_MODE, CONF_GA_CONTROLLER_STATUS, @@ -74,7 +71,11 @@ CONF_IGNORE_AUTO_MODE, CONF_TARGET_TEMPERATURE, ) -from .storage.entity_store_schema import ConfClimateFanSpeedMode, ConfSetpointShiftMode +from .storage.entity_store_schema import ( + ConfClimateFanSpeedMode, + ConfSetpointShiftMode, + KnxEntityData, +) from .storage.util import ConfigExtractor ATTR_COMMAND_VALUE = "command_value" @@ -705,11 +706,11 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = ConfigExtractor(config[DOMAIN]) + knx_conf = ConfigExtractor(config.knx) self._device = _create_climate_ui( - knx_module.xknx, knx_conf, config[CONF_ENTITY][CONF_NAME] + knx_module.xknx, knx_conf, config.entity.xknx_name ) default_hvac_mode = HVACMode(knx_conf.get(ClimateConf.DEFAULT_CONTROLLER_MODE)) diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index 087129880d6fb..4cdc94285d645 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -30,7 +30,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from .const import CONF_SYNC_STATE, DOMAIN, KNX_MODULE_KEY, CoverConf +from .const import CONF_SYNC_STATE, KNX_MODULE_KEY, CoverConf from .entity import ( KnxUiEntity, KnxUiEntityPlatformController, @@ -39,9 +39,7 @@ ) from .knx_module import KNXModule from .schema import CoverSchema -from .storage.config_store import KnxEntityData from .storage.const import ( - CONF_ENTITY, CONF_GA_ANGLE, CONF_GA_POSITION_SET, CONF_GA_POSITION_STATE, @@ -49,6 +47,7 @@ CONF_GA_STOP, CONF_GA_UP_DOWN, ) +from .storage.entity_store_schema import KnxEntityData from .storage.util import ConfigExtractor @@ -313,9 +312,9 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) self._device = _create_ui_cover( - knx_module.xknx, config[DOMAIN], config[CONF_ENTITY][CONF_NAME] + knx_module.xknx, config.knx, config.entity.xknx_name ) self.init_base() diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py index cfaa00025a0a2..0ac91cbccbaeb 100644 --- a/homeassistant/components/knx/date.py +++ b/homeassistant/components/knx/date.py @@ -21,7 +21,6 @@ CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, - DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, ) @@ -32,9 +31,7 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY -from .storage.entity_store_schema import DateKnxConfig +from .storage.entity_store_schema import DateKnxConfig, KnxEntityData async def async_setup_entry( @@ -137,12 +134,12 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = config[DOMAIN] + knx_conf = config.knx self._device = XknxDateDevice( knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, localtime=False, group_address=knx_conf.ga_date.write, group_address_state=knx_conf.ga_date.state_and_passive(), diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py index dcd0e9d8550c8..ee93adb7ea484 100644 --- a/homeassistant/components/knx/datetime.py +++ b/homeassistant/components/knx/datetime.py @@ -22,7 +22,6 @@ CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, - DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, ) @@ -33,9 +32,7 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY -from .storage.entity_store_schema import DatetimeKnxConfig +from .storage.entity_store_schema import DatetimeKnxConfig, KnxEntityData async def async_setup_entry( @@ -142,12 +139,12 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = config[DOMAIN] + knx_conf = config.knx self._device = XknxDateTimeDevice( knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, localtime=False, group_address=knx_conf.ga_datetime.write, group_address_state=knx_conf.ga_datetime.state_and_passive(), diff --git a/homeassistant/components/knx/entity.py b/homeassistant/components/knx/entity.py index bc43dceaede2a..86d3df4d9ac4a 100644 --- a/homeassistant/components/knx/entity.py +++ b/homeassistant/components/knx/entity.py @@ -27,8 +27,8 @@ from homeassistant.helpers.entity_registry import RegistryEntry from .const import CONF_DEFAULT_ENTITY_ID, DOMAIN -from .storage.config_store import KnxEntityData, PlatformControllerBase -from .storage.const import CONF_DEVICE_INFO +from .storage.config_store import PlatformControllerBase +from .storage.entity_store_schema import BaseEntityConfig, KnxEntityData if TYPE_CHECKING: from .knx_module import KNXModule @@ -260,13 +260,15 @@ class KnxUiEntity(_KnxEntityBase): """Representation of a KNX UI entity.""" def __init__( - self, knx_module: KNXModule, unique_id: str, entity_config: dict[str, Any] + self, knx_module: KNXModule, unique_id: str, entity_config: BaseEntityConfig ) -> None: """Initialize the UI entity.""" self._knx_module = knx_module - self._attr_name = entity_config[CONF_NAME] + self._attr_name = entity_config.name self._attr_unique_id = unique_id - self._attr_entity_category = entity_config[CONF_ENTITY_CATEGORY] - if device_info := entity_config[CONF_DEVICE_INFO]: - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)}) + self._attr_entity_category = entity_config.entity_category + if entity_config.device_info: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entity_config.device_info)} + ) diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index a9235efb9f860..1fad769078db9 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -33,15 +33,14 @@ ) from .knx_module import KNXModule from .schema import FanSchema -from .storage.config_store import KnxEntityData from .storage.const import ( - CONF_ENTITY, CONF_GA_OSCILLATION, CONF_GA_SPEED, CONF_GA_STEP, CONF_GA_SWITCH, CONF_SPEED, ) +from .storage.entity_store_schema import KnxEntityData from .storage.util import ConfigExtractor _LOGGER = logging.getLogger(__name__) @@ -257,13 +256,13 @@ def __init__( self, knx_module: KNXModule, unique_id: str, config: KnxEntityData[Any] ) -> None: """Initialize of KNX fan.""" - knx_conf = ConfigExtractor(config[DOMAIN]) + knx_conf = ConfigExtractor(config.knx) # max_step is required for step mode, thus can be used to differentiate modes max_step: int | None = knx_conf.get(CONF_SPEED, FanConf.MAX_STEP) super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) if max_step: # step control @@ -276,7 +275,7 @@ def __init__( self._device = XknxFan( xknx=knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, group_address_speed=speed_write, group_address_speed_state=speed_state, group_address_oscillation=knx_conf.get_write(CONF_GA_OSCILLATION), diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index d16c9f038ad38..f6a8df0b2fa70 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -27,7 +27,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util import color as color_util -from .const import CONF_SYNC_STATE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, ColorTempModes +from .const import CONF_SYNC_STATE, KNX_ADDRESS, KNX_MODULE_KEY, ColorTempModes from .entity import ( KnxUiEntity, KnxUiEntityPlatformController, @@ -36,12 +36,10 @@ ) from .knx_module import KNXModule from .schema import LightSchema -from .storage.config_store import KnxEntityData from .storage.const import ( CONF_COLOR, CONF_COLOR_TEMP_MAX, CONF_COLOR_TEMP_MIN, - CONF_ENTITY, CONF_GA_BLUE_BRIGHTNESS, CONF_GA_BLUE_SWITCH, CONF_GA_BRIGHTNESS, @@ -57,7 +55,7 @@ CONF_GA_WHITE_BRIGHTNESS, CONF_GA_WHITE_SWITCH, ) -from .storage.entity_store_schema import LightColorMode +from .storage.entity_store_schema import KnxEntityData, LightColorMode from .storage.util import ConfigExtractor @@ -610,11 +608,11 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) self._device = _create_ui_light( - knx_module.xknx, config[DOMAIN], config[CONF_ENTITY][CONF_NAME] + knx_module.xknx, config.knx, config.entity.xknx_name ) self._attr_color_mode = next(iter(self.supported_color_modes)) - self._attr_max_color_temp_kelvin: int = config[DOMAIN][CONF_COLOR_TEMP_MAX] - self._attr_min_color_temp_kelvin: int = config[DOMAIN][CONF_COLOR_TEMP_MIN] + self._attr_max_color_temp_kelvin: int = config.knx[CONF_COLOR_TEMP_MAX] + self._attr_min_color_temp_kelvin: int = config.knx[CONF_COLOR_TEMP_MIN] diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py index f427a6cb59c7d..5e9dbefe45acc 100644 --- a/homeassistant/components/knx/notify.py +++ b/homeassistant/components/knx/notify.py @@ -14,7 +14,7 @@ ) from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY +from .const import KNX_ADDRESS, KNX_MODULE_KEY from .entity import ( KnxUiEntity, KnxUiEntityPlatformController, @@ -22,9 +22,7 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY -from .storage.entity_store_schema import NotifyKnxConfig +from .storage.entity_store_schema import KnxEntityData, NotifyKnxConfig async def async_setup_entry( @@ -107,12 +105,12 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = config[DOMAIN] + knx_conf = config.knx self._device = XknxNotification( knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, group_address=knx_conf.ga_send.write, value_type=knx_conf.ga_send.dpt, ) diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index c95e4ce1232e0..65465921d604c 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -28,7 +28,6 @@ CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, - DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, NumberConf, @@ -41,9 +40,7 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY -from .storage.entity_store_schema import NumberKnxConfig +from .storage.entity_store_schema import KnxEntityData, NumberKnxConfig async def async_setup_entry( @@ -178,16 +175,16 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = config[DOMAIN] + knx_conf = config.knx dpt_string = knx_conf.ga_sensor.dpt assert dpt_string is not None # required for number dpt_info = get_supported_dpts()[dpt_string] self._device = NumericValue( knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, group_address=knx_conf.ga_sensor.write, group_address_state=knx_conf.ga_sensor.state_and_passive(), respond_to_read=knx_conf.respond_to_read, diff --git a/homeassistant/components/knx/scene.py b/homeassistant/components/knx/scene.py index de525fea3ac03..2a9e61f314a6b 100644 --- a/homeassistant/components/knx/scene.py +++ b/homeassistant/components/knx/scene.py @@ -14,7 +14,7 @@ ) from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY +from .const import KNX_ADDRESS, KNX_MODULE_KEY from .entity import ( KnxUiEntity, KnxUiEntityPlatformController, @@ -24,9 +24,7 @@ ) from .knx_module import KNXModule from .schema import SceneSchema -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY -from .storage.entity_store_schema import SceneKnxConfig +from .storage.entity_store_schema import KnxEntityData, SceneKnxConfig async def async_setup_entry( @@ -117,12 +115,12 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = config[DOMAIN] + knx_conf = config.knx self._device = XknxScene( xknx=knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, group_address=knx_conf.ga_scene.write, scene_number=knx_conf.scene_number, ) diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index 3e45e4e9911ed..205e5c54a12b0 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -29,7 +29,6 @@ CONF_STATE_ADDRESS, CONF_SYNC_STATE, CONF_VALUE, - DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, SelectConf, @@ -42,8 +41,7 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY +from .storage.entity_store_schema import KnxEntityData from .storage.util import ConfigExtractor _LOGGER = logging.getLogger(__name__) @@ -206,7 +204,7 @@ def __init__( self, knx_module: KNXModule, unique_id: str, config: KnxEntityData[Any] ) -> None: """Initialize a KNX select.""" - knx_conf = ConfigExtractor(config[DOMAIN]) + knx_conf = ConfigExtractor(config.knx) source = knx_conf.get(SelectConf.OPTIONS_SOURCE) # the group address key tells how options are defined if SelectConf.GA_ENUM in source: @@ -223,7 +221,7 @@ def __init__( self._device = RawValue( knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, payload_length=payload_length, group_address=knx_conf.get_write(SelectConf.OPTIONS_SOURCE, ga_key), group_address_state=knx_conf.get_state_and_passive( @@ -235,6 +233,6 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) self._attr_options = list(self._option_payloads) diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index e65603ded3794..18b0762aa5b62 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -36,7 +36,7 @@ from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.util.enum import try_parse_enum -from .const import ATTR_SOURCE, CONF_SYNC_STATE, DOMAIN, KNX_MODULE_KEY +from .const import ATTR_SOURCE, CONF_SYNC_STATE, KNX_MODULE_KEY from .dpt import get_supported_dpts from .entity import ( KnxUiEntity, @@ -47,9 +47,7 @@ ) from .knx_module import KNXModule from .schema import SensorSchema -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY -from .storage.entity_store_schema import SensorKnxConfig +from .storage.entity_store_schema import KnxEntityData, SensorKnxConfig SCAN_INTERVAL = timedelta(seconds=10) @@ -256,16 +254,16 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = config[DOMAIN] + knx_conf = config.knx dpt_string = knx_conf.ga_sensor.dpt assert dpt_string is not None # required for sensor dpt_info = get_supported_dpts()[dpt_string] self._device = XknxSensor( knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, group_address_state=knx_conf.ga_sensor.state_and_passive(), sync_state=knx_conf.sync_state, always_callback=True, diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index cc3d7fb2a1bf3..ef18f5c8ead90 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -9,7 +9,6 @@ Any, Final, TypedDict, - cast, get_args, get_origin, get_type_hints, @@ -30,6 +29,7 @@ from ..repairs import async_create_entity_validation_issue from . import migration from .const import CONF_DATA, CONF_ENTITY +from .entity_store_schema import KnxEntityData from .entity_store_validation import ( EntityStoreValidationException, validate_entity_data, @@ -58,24 +58,20 @@ class KNXConfigStoreModel(TypedDict): time_server: KNXTimeServerStoreModel -class KnxEntityData[KnxT](TypedDict): - """Validated entity data: the common `entity` and the platform `knx` part.""" - - entity: dict[str, Any] - knx: KnxT - - def to_storage_dict(data: KnxEntityData[Any]) -> dict[str, Any]: """Render validated entity data to its JSON serializable storage form.""" - knx_config = data[DOMAIN] + return { + CONF_ENTITY: dataclasses.asdict(data.entity), + DOMAIN: _knx_to_storage(data.knx), + } + + +def _knx_to_storage(knx_config: Any) -> dict[str, Any]: if isinstance(knx_config, dict): - return cast(dict[str, Any], data) # platform not yet migrated to a typed config + return knx_config # platform not yet migrated to a typed config return { - CONF_ENTITY: data[CONF_ENTITY], - DOMAIN: { - name: encode(getattr(knx_config, name)) - for name, encode in _storage_encoders(type(knx_config)) - }, + name: encode(getattr(knx_config, name)) + for name, encode in _storage_encoders(type(knx_config)) } @@ -227,9 +223,9 @@ def get_entity_configs( invalid.append(unique_id) continue data: KnxEntityData[Any] = result[CONF_DATA] - if config_type is not None and not isinstance(data[DOMAIN], config_type): + if config_type is not None and not isinstance(data.knx, config_type): raise TypeError( - f"{platform} schema yields {type(data[DOMAIN]).__name__}," + f"{platform} schema yields {type(data.knx).__name__}," f" not {config_type.__name__}" ) validated[unique_id] = data diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 17ed93e2f951e..94c1b91b358b3 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -3,7 +3,7 @@ from collections.abc import Hashable from dataclasses import dataclass from enum import StrEnum, unique -from typing import Annotated +from typing import Annotated, Any import probatio from probatio import Key @@ -23,9 +23,9 @@ from homeassistant.const import ( CONF_ENTITY_CATEGORY, CONF_ENTITY_ID, - CONF_NAME, CONF_PAYLOAD, CONF_PLATFORM, + EntityCategory, Platform, ) from homeassistant.helpers import selector @@ -47,7 +47,8 @@ ) from ..dpt import get_supported_dpts, raw_payload_length from ..validation import ( - entity_category_validator, + entity_category_supported, + parse_entity_category, validate_number_attributes, validate_sensor_attributes, ) @@ -56,7 +57,6 @@ CONF_COLOR_TEMP_MAX, CONF_COLOR_TEMP_MIN, CONF_DATA, - CONF_DEVICE_INFO, CONF_DPT, CONF_ENTITY, CONF_GA_ACTIVE, @@ -135,34 +135,52 @@ SyncStateAllowFalse = Annotated[bool | str | int, SyncStateSelector(allow_false=True)] +@dataclass(kw_only=True, slots=True) +class BaseEntityConfig: + """Common UI configuration of a KNX entity.""" + + name: str | None = None + device_info: str | None = None + entity_category: Annotated[ + EntityCategory | None, probatio.Coerce(parse_entity_category) + ] = None + + @property + def xknx_name(self) -> str: + """Name of the xknx device, empty when HA names the entity after its device.""" + return self.name or "" + + +def _name_or_device_required(config: BaseEntityConfig) -> BaseEntityConfig: + """Require a name, unless the entity is named after its device.""" + if not config.name and config.device_info is None: + raise probatio.AnyInvalid("One of `Device` or `Name` is required") + return config + + def base_entity_schema(platform: Platform) -> probatio.All: """Return the base entity schema for a platform.""" return probatio.All( - { - probatio.Optional(CONF_NAME, default=None): probatio.Maybe(str), - probatio.Optional(CONF_DEVICE_INFO, default=None): probatio.Maybe(str), - probatio.Optional( - CONF_ENTITY_CATEGORY, default=None - ): entity_category_validator(platform), - }, - probatio.Any( - probatio.Schema( - { - probatio.Required(CONF_NAME): probatio.All(str, probatio.IsTrue()), - }, - extra=probatio.ALLOW_EXTRA, - ), - probatio.Schema( - { - probatio.Required(CONF_DEVICE_INFO): str, - }, - extra=probatio.ALLOW_EXTRA, - ), - msg="One of `Device` or `Name` is required", + probatio.DataclassSchema( + BaseEntityConfig, + {CONF_ENTITY_CATEGORY: entity_category_supported(platform)}, ), + _name_or_device_required, ) +@dataclass(kw_only=True, slots=True) +class KnxEntityData[KnxT]: + """Validated UI entity data: the common `entity` and the platform `knx` part.""" + + entity: BaseEntityConfig + knx: KnxT + + +def _to_entity_data(data: dict[str, Any]) -> KnxEntityData[Any]: + return KnxEntityData(entity=data[CONF_ENTITY], knx=data[DOMAIN]) + + @dataclass(kw_only=True, slots=True) class BinarySensorKnxConfig: """UI configuration of a KNX binary sensor.""" @@ -1118,14 +1136,17 @@ def _sensor_attribute_sub_validator(config: SensorKnxConfig) -> SensorKnxConfig: { platform: probatio.Schema( { - probatio.Required(CONF_DATA): probatio.Schema( - { - probatio.Required(CONF_ENTITY): base_entity_schema( - platform - ), - probatio.Required(DOMAIN): knx_schema, - }, - extra=probatio.PREVENT_EXTRA, # restrict in data key for yaml edit + probatio.Required(CONF_DATA): probatio.All( + probatio.Schema( + { + probatio.Required(CONF_ENTITY): base_entity_schema( + platform + ), + probatio.Required(DOMAIN): knx_schema, + }, + extra=probatio.PREVENT_EXTRA, # restrict in data key for yaml edit + ), + _to_entity_data, ), }, extra=probatio.ALLOW_EXTRA, # eg. "type" from WS-endpoint when validating directly diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index 03f328a8caa79..090022ae20f0d 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -22,13 +22,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from .const import ( - CONF_RESPOND_TO_READ, - CONF_SYNC_STATE, - DOMAIN, - KNX_ADDRESS, - KNX_MODULE_KEY, -) +from .const import CONF_RESPOND_TO_READ, CONF_SYNC_STATE, KNX_ADDRESS, KNX_MODULE_KEY from .entity import ( KnxUiEntity, KnxUiEntityPlatformController, @@ -37,9 +31,7 @@ ) from .knx_module import KNXModule from .schema import SwitchSchema -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY -from .storage.entity_store_schema import SwitchKnxConfig +from .storage.entity_store_schema import KnxEntityData, SwitchKnxConfig async def async_setup_entry( @@ -145,12 +137,12 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = config[DOMAIN] + knx_conf = config.knx self._device = XknxSwitch( knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, group_address=knx_conf.ga_switch.write, group_address_state=knx_conf.ga_switch.state_and_passive(), respond_to_read=knx_conf.respond_to_read, diff --git a/homeassistant/components/knx/text.py b/homeassistant/components/knx/text.py index 6d3d7870940f4..3f0d885580516 100644 --- a/homeassistant/components/knx/text.py +++ b/homeassistant/components/knx/text.py @@ -28,7 +28,6 @@ CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, - DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, ) @@ -39,9 +38,7 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY -from .storage.entity_store_schema import TextKnxConfig +from .storage.entity_store_schema import KnxEntityData, TextKnxConfig async def async_setup_entry( @@ -155,12 +152,12 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = config[DOMAIN] + knx_conf = config.knx self._device = XknxNotification( knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, group_address=knx_conf.ga_text.write, group_address_state=knx_conf.ga_text.state_and_passive(), respond_to_read=knx_conf.respond_to_read, diff --git a/homeassistant/components/knx/time.py b/homeassistant/components/knx/time.py index 1e73bc49c3ac2..469bb74976326 100644 --- a/homeassistant/components/knx/time.py +++ b/homeassistant/components/knx/time.py @@ -21,7 +21,6 @@ CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, - DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, ) @@ -32,9 +31,7 @@ build_yaml_unique_id, ) from .knx_module import KNXModule -from .storage.config_store import KnxEntityData -from .storage.const import CONF_ENTITY -from .storage.entity_store_schema import TimeKnxConfig +from .storage.entity_store_schema import KnxEntityData, TimeKnxConfig async def async_setup_entry( @@ -137,12 +134,12 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = config[DOMAIN] + knx_conf = config.knx self._device = XknxTimeDevice( knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, localtime=False, group_address=knx_conf.ga_time.write, group_address_state=knx_conf.ga_time.state_and_passive(), diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py index ff15edcf8df41..d58782655d02d 100644 --- a/homeassistant/components/knx/validation.py +++ b/homeassistant/components/knx/validation.py @@ -65,28 +65,27 @@ def dpt_value_validator(value: Any) -> str | int: sensor_type_validator = probatio.Any(numeric_type_validator, string_type_validator) -def entity_category_validator( - platform: Platform, -) -> Callable[[Any], EntityCategory | None]: - """Validate the entity category is supported by the platform. +def parse_entity_category(value: Any) -> EntityCategory | None: + """Parse an entity category; `None` and "" (the UI clears with it) mean none.""" + if value is None or value == "": + return None + try: + return EntityCategory(value) + except ValueError: + raise probatio.Invalid(f"'{value}' is not a valid entity category") from None - Works for both, UI and YAML configuration schema. - """ + +def entity_category_supported( + platform: Platform, +) -> Callable[[EntityCategory | None], EntityCategory | None]: + """Validate a parsed entity category is supported by the platform.""" valid_categories = set(EntityCategory) if platform in PLATFORMS_WITHOUT_CONFIG_CATEGORY: valid_categories -= {EntityCategory.CONFIG} - def validate(value: Any) -> EntityCategory | None: + def validate(entity_category: EntityCategory | None) -> EntityCategory | None: """Validate the entity category.""" - if value is None or value == "": # UI sends an empty value to clear it - return None - try: - entity_category = EntityCategory(value) - except ValueError: - raise probatio.Invalid( - f"'{value}' is not a valid entity category" - ) from None - if entity_category not in valid_categories: + if entity_category is not None and entity_category not in valid_categories: _options = ", ".join(sorted(valid_categories)) raise probatio.Invalid( f"Entity category '{entity_category}' is not supported by the" @@ -97,6 +96,14 @@ def validate(value: Any) -> EntityCategory | None: return validate +def entity_category_validator(platform: Platform) -> probatio.All: + """Validate the entity category is supported by the platform. + + Works for both, UI and YAML configuration schema. + """ + return probatio.All(parse_entity_category, entity_category_supported(platform)) + + def ga_validator(value: Any) -> str | int: """Validate that value is parsable as GroupAddress or InternalGroupAddress.""" if not isinstance(value, (str, int)): diff --git a/homeassistant/components/knx/weather.py b/homeassistant/components/knx/weather.py index db149debedfcf..afa784d09a864 100644 --- a/homeassistant/components/knx/weather.py +++ b/homeassistant/components/knx/weather.py @@ -20,7 +20,7 @@ ) from homeassistant.helpers.typing import ConfigType -from .const import CONF_SYNC_STATE, DOMAIN, KNX_MODULE_KEY +from .const import CONF_SYNC_STATE, KNX_MODULE_KEY from .entity import ( KnxUiEntity, KnxUiEntityPlatformController, @@ -29,9 +29,7 @@ ) from .knx_module import KNXModule from .schema import WeatherSchema -from .storage.config_store import KnxEntityData from .storage.const import ( - CONF_ENTITY, CONF_GA_AIR_PRESSURE, CONF_GA_BRIGHTNESS_EAST, CONF_GA_BRIGHTNESS_NORTH, @@ -47,6 +45,7 @@ CONF_GA_WIND_SPEED, CONF_INVERT_DAY_NIGHT, ) +from .storage.entity_store_schema import KnxEntityData from .storage.util import ConfigExtractor @@ -197,12 +196,12 @@ def __init__( super().__init__( knx_module=knx_module, unique_id=unique_id, - entity_config=config[CONF_ENTITY], + entity_config=config.entity, ) - knx_conf = ConfigExtractor(config[DOMAIN]) + knx_conf = ConfigExtractor(config.knx) self._device = XknxWeather( knx_module.xknx, - name=config[CONF_ENTITY][CONF_NAME], + name=config.entity.xknx_name, sync_state=knx_conf.get(CONF_SYNC_STATE), group_address_temperature=knx_conf.get_state_and_passive( CONF_GA_TEMPERATURE diff --git a/homeassistant/components/lawn_mower/llm.py b/homeassistant/components/lawn_mower/llm.py index 16b9b5f7b8ce9..6fe3b76067b3f 100644 --- a/homeassistant/components/lawn_mower/llm.py +++ b/homeassistant/components/lawn_mower/llm.py @@ -4,13 +4,26 @@ from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import intent -from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool +from homeassistant.helpers.llm import ( + LLM_API_ASSIST, + IntentTool, + LLMContext, + Tool, + ToolAnnotations, +) from .const import DOMAIN from .intent import INTENT_LANW_MOWER_DOCK, INTENT_LANW_MOWER_START_MOWING +# Each intent sets a value on the user's own entities, so calling one again +# with the same arguments has no further effect. +LLM_ANNOTATIONS = ToolAnnotations(idempotent=True, open_world=False) + # Intents owned by this integration that are exposed as LLM tools. -LLM_INTENTS = (INTENT_LANW_MOWER_DOCK, INTENT_LANW_MOWER_START_MOWING) +LLM_INTENTS = { + INTENT_LANW_MOWER_DOCK: "Dock lawn mower", + INTENT_LANW_MOWER_START_MOWING: "Start mowing", +} @callback @@ -31,7 +44,13 @@ def async_get_tools( return None tools: list[Tool] = [ - IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) + IntentTool( + f"{DOMAIN}__{handler.intent_type}", + handler, + title=LLM_INTENTS[handler.intent_type], + integration=DOMAIN, + annotations=LLM_ANNOTATIONS, + ) for handler in intent.async_get(hass) if handler.intent_type in LLM_INTENTS ] diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index 54cdfb5903e4c..d6fee8a44df91 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -829,9 +829,9 @@ async def async_added_to_hass(self) -> None: """Handle added to Hass.""" await super().async_added_to_hass() if self.coordinator.update_energy_at_time_of_day is None: - # random time 01:00:00 ~ 02:59:00 + # Random time between 01:00:00 and 05:59:00 self.coordinator.update_energy_at_time_of_day = time( - hour=random.randint(1, 2), minute=random.randint(0, 59) + hour=random.randint(1, 5), minute=random.randint(0, 59) ) _LOGGER.debug( "[%s] Set energy update time: %s", diff --git a/homeassistant/components/librenms/__init__.py b/homeassistant/components/librenms/__init__.py index acd4e75d356bc..d78b20b95dd9c 100644 --- a/homeassistant/components/librenms/__init__.py +++ b/homeassistant/components/librenms/__init__.py @@ -1,9 +1,23 @@ """The LibreNMS integration.""" -from homeassistant.const import Platform +from aiolibrenms import Librenms +from aiolibrenms.const import CONNECT_ERRORS +from aiolibrenms.exceptions import LibrenmsUnauthenticatedError + +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_VERIFY_SSL, + Platform, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import LibrenmsConfigEntry, LibrenmsDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import LibrenmsCentralDataUpdateCoordinator, LibrenmsConfigEntry PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -11,7 +25,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: LibrenmsConfigEntry) -> bool: """Set up LibreNMS from a config entry.""" - coordinator = LibrenmsDataUpdateCoordinator(hass, entry) + api = Librenms( + async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]), + entry.data[CONF_API_KEY], + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_SSL], + ) + + try: + await api.system.async_get_system_info() + except LibrenmsUnauthenticatedError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + ) from err + except CONNECT_ERRORS as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + + coordinator = LibrenmsCentralDataUpdateCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/librenms/binary_sensor.py b/homeassistant/components/librenms/binary_sensor.py index 683ac0596a25d..ef7e2ac04d802 100644 --- a/homeassistant/components/librenms/binary_sensor.py +++ b/homeassistant/components/librenms/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import LibrenmsConfigEntry, LibrenmsDataUpdateCoordinator +from .coordinator import LibrenmsCentralDataUpdateCoordinator, LibrenmsConfigEntry from .entity import LibrenmsDeviceEntity _LOGGER = logging.getLogger(__name__) @@ -64,7 +64,7 @@ class LibrenmsDeviceBinarySensorEntity(LibrenmsDeviceEntity, BinarySensorEntity) def __init__( self, - coordinator: LibrenmsDataUpdateCoordinator, + coordinator: LibrenmsCentralDataUpdateCoordinator, description: LibrenmsDeviceBinarySensorEntityDescription, device_id: int, ) -> None: diff --git a/homeassistant/components/librenms/coordinator.py b/homeassistant/components/librenms/coordinator.py index 09785ab71f190..8a3508fa217f7 100644 --- a/homeassistant/components/librenms/coordinator.py +++ b/homeassistant/components/librenms/coordinator.py @@ -13,16 +13,10 @@ from yarl import URL from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_API_KEY, - CONF_HOST, - CONF_PORT, - CONF_SSL, - CONF_VERIFY_SSL, -) +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -31,30 +25,30 @@ @dataclass -class LibrenmsData: +class LibrenmsCentralData: """Data class for storing data from the API.""" system: LibrenmsSystemInfo devices: dict[int, LibrenmsDeviceInfo] -type LibrenmsConfigEntry = ConfigEntry[LibrenmsDataUpdateCoordinator] +type LibrenmsConfigEntry = ConfigEntry[LibrenmsCentralDataUpdateCoordinator] -class LibrenmsDataUpdateCoordinator(DataUpdateCoordinator[LibrenmsData]): - """Class to manage fetching LibreNMS data.""" +class LibrenmsBaseDataUpdateCoordinator[T](DataUpdateCoordinator[T]): + """Base class to manage fetching LibreNMS data.""" config_entry: LibrenmsConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: LibrenmsConfigEntry) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: LibrenmsConfigEntry, + api: Librenms, + update_interval: timedelta, + ) -> None: """Initialize the data update coordinator.""" - self.api = Librenms( - async_get_clientsession(hass, config_entry.data[CONF_VERIFY_SSL]), - config_entry.data[CONF_API_KEY], - config_entry.data[CONF_HOST], - config_entry.data[CONF_PORT], - config_entry.data[CONF_SSL], - ) + self.api = api self.configuration_url = str( URL.build( scheme="https" if config_entry.data[CONF_SSL] else "http", @@ -67,27 +61,28 @@ def __init__(self, hass: HomeAssistant, config_entry: LibrenmsConfigEntry) -> No _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=timedelta(seconds=60), + update_interval=update_interval, ) - @override - async def _async_setup(self) -> None: - """Handle setup of the coordinator.""" - try: - await self.api.system.async_get_system_info() - except LibrenmsUnauthenticatedError as err: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="auth_error", - ) from err - except CONNECT_ERRORS as err: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="cannot_connect", - ) from err + +class LibrenmsCentralDataUpdateCoordinator( + LibrenmsBaseDataUpdateCoordinator[LibrenmsCentralData] +): + """Coordinator to fetch librenms system data and monitored devices meta data.""" + + def __init__( + self, hass: HomeAssistant, config_entry: LibrenmsConfigEntry, api: Librenms + ) -> None: + """Initialize the data update coordinator.""" + super().__init__( + hass, + config_entry=config_entry, + api=api, + update_interval=timedelta(seconds=60), + ) @override - async def _async_update_data(self) -> LibrenmsData: + async def _async_update_data(self) -> LibrenmsCentralData: """Update data via internal method.""" try: system = await self.api.system.async_get_system_info() @@ -104,4 +99,24 @@ async def _async_update_data(self) -> LibrenmsData: translation_placeholders={"error": str(err)}, ) from err - return LibrenmsData(system, {dev.device_id: dev for dev in devices}) + device_reg = dr.async_get(self.hass) + for device in devices: + identifier = f"{self.config_entry.entry_id}_{device.device_id}" + sw_version = device.version + model = None + if device.os != "ping": + if sw_version and (feature := device.features) is not None: + sw_version += f" ({feature})" + model = device.hardware + + device_reg.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, identifier)}, + sw_version=sw_version, + configuration_url=f"{self.configuration_url}/device/{device.device_id}", + name=device.display, + model=model, + serial_number=device.serial, + ) + + return LibrenmsCentralData(system, {dev.device_id: dev for dev in devices}) diff --git a/homeassistant/components/librenms/entity.py b/homeassistant/components/librenms/entity.py index 906e611bad601..b2d89ce267f56 100644 --- a/homeassistant/components/librenms/entity.py +++ b/homeassistant/components/librenms/entity.py @@ -8,39 +8,26 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import LibrenmsDataUpdateCoordinator +from .coordinator import LibrenmsCentralDataUpdateCoordinator -class LibrenmsDeviceEntity(CoordinatorEntity[LibrenmsDataUpdateCoordinator]): +class LibrenmsDeviceEntity(CoordinatorEntity[LibrenmsCentralDataUpdateCoordinator]): """Define LibreNMS device base entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: LibrenmsDataUpdateCoordinator, + coordinator: LibrenmsCentralDataUpdateCoordinator, device_id: int, ) -> None: """Initialize.""" super().__init__(coordinator) self.device_id = device_id - - identifier = f"{coordinator.config_entry.entry_id}_{self.device_id}" - sw_version = self._data.version - model = None - configuration_url = f"{coordinator.configuration_url}/device/{self.device_id}" - if self._data.os != "ping": - if sw_version and (feature := self._data.features) is not None: - sw_version += f" ({feature})" - model = self._data.hardware - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, identifier)}, - sw_version=sw_version, - configuration_url=configuration_url, - name=self._data.display, - model=model, - serial_number=self._data.serial, + identifiers={ + (DOMAIN, f"{coordinator.config_entry.entry_id}_{self.device_id}") + } ) @property @@ -55,14 +42,14 @@ def _data(self) -> LibrenmsDeviceInfo: return self.coordinator.data.devices[self.device_id] -class LibrenmsSystemEntity(CoordinatorEntity[LibrenmsDataUpdateCoordinator]): +class LibrenmsSystemEntity(CoordinatorEntity[LibrenmsCentralDataUpdateCoordinator]): """Define LibreNMS base entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: LibrenmsDataUpdateCoordinator, + coordinator: LibrenmsCentralDataUpdateCoordinator, ) -> None: """Initialize.""" super().__init__(coordinator) diff --git a/homeassistant/components/librenms/sensor.py b/homeassistant/components/librenms/sensor.py index 09e3dd237d963..84017422b66c8 100644 --- a/homeassistant/components/librenms/sensor.py +++ b/homeassistant/components/librenms/sensor.py @@ -15,9 +15,9 @@ from homeassistant.helpers.typing import StateType from .coordinator import ( + LibrenmsCentralData, + LibrenmsCentralDataUpdateCoordinator, LibrenmsConfigEntry, - LibrenmsData, - LibrenmsDataUpdateCoordinator, ) from .entity import LibrenmsSystemEntity @@ -29,8 +29,8 @@ class LibrenmsSystemSensorEntityDescription(SensorEntityDescription): """Librenms system sensor entity description.""" - value: Callable[[LibrenmsData], StateType] - is_suitable: Callable[[LibrenmsData], bool] = lambda _: True + value: Callable[[LibrenmsCentralData], StateType] + is_suitable: Callable[[LibrenmsCentralData], bool] = lambda _: True SYSTEM_SENSOR_TYPES: tuple[LibrenmsSystemSensorEntityDescription, ...] = ( @@ -99,7 +99,7 @@ class LibrenmsSystemSensorEntity(LibrenmsSystemEntity, SensorEntity): def __init__( self, - coordinator: LibrenmsDataUpdateCoordinator, + coordinator: LibrenmsCentralDataUpdateCoordinator, description: LibrenmsSystemSensorEntityDescription, ) -> None: """Initialize.""" diff --git a/homeassistant/components/light/llm.py b/homeassistant/components/light/llm.py index 17df38c98e962..4fce9f00e7c59 100644 --- a/homeassistant/components/light/llm.py +++ b/homeassistant/components/light/llm.py @@ -4,13 +4,25 @@ from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import intent -from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool +from homeassistant.helpers.llm import ( + LLM_API_ASSIST, + IntentTool, + LLMContext, + Tool, + ToolAnnotations, +) from .const import DOMAIN from .intent import INTENT_SET +# Each intent sets a value on the user's own entities, so calling one again +# with the same arguments has no further effect. +LLM_ANNOTATIONS = ToolAnnotations(idempotent=True, open_world=False) + # Intents owned by this integration that are exposed as LLM tools. -LLM_INTENTS = (INTENT_SET,) +LLM_INTENTS = { + INTENT_SET: "Set light", +} @callback @@ -31,7 +43,13 @@ def async_get_tools( return None tools: list[Tool] = [ - IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) + IntentTool( + f"{DOMAIN}__{handler.intent_type}", + handler, + title=LLM_INTENTS[handler.intent_type], + integration=DOMAIN, + annotations=LLM_ANNOTATIONS, + ) for handler in intent.async_get(hass) if handler.intent_type in LLM_INTENTS ] diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 66c6566ff0d5a..0b07d6c978dd6 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -6,7 +6,6 @@ import re from typing import TYPE_CHECKING, Any, final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry @@ -23,13 +22,18 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, StateType -from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN, LockEntityFeature, LockEntityStateAttribute, LockState +from .const import ( + DATA_COMPONENT, + DOMAIN, + LockEntityFeature, + LockEntityStateAttribute, + LockState, +) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[LockEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -40,10 +44,6 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -LOCK_SERVICE_SCHEMA = cv.make_entity_service_schema( - {probatio.Optional(ATTR_CODE): cv.string} -) - PROP_TO_ATTR = { "changed_by": LockEntityStateAttribute.CHANGED_BY, @@ -61,18 +61,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) - component.async_register_entity_service( - SERVICE_UNLOCK, LOCK_SERVICE_SCHEMA, "async_handle_unlock_service" - ) - component.async_register_entity_service( - SERVICE_LOCK, LOCK_SERVICE_SCHEMA, "async_handle_lock_service" - ) - component.async_register_entity_service( - SERVICE_OPEN, - LOCK_SERVICE_SCHEMA, - "async_handle_open_service", - [LockEntityFeature.OPEN], - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/lock/const.py b/homeassistant/components/lock/const.py index 8e89832581160..fee089854add1 100644 --- a/homeassistant/components/lock/const.py +++ b/homeassistant/components/lock/const.py @@ -1,9 +1,17 @@ """Constants for the lock entity platform.""" from enum import IntFlag, StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import LockEntity DOMAIN: Final = "lock" +DATA_COMPONENT: HassKey[EntityComponent[LockEntity]] = HassKey(DOMAIN) class LockEntityStateAttribute(StrEnum): diff --git a/homeassistant/components/lock/services.py b/homeassistant/components/lock/services.py new file mode 100644 index 0000000000000..5ebd0bed9578d --- /dev/null +++ b/homeassistant/components/lock/services.py @@ -0,0 +1,32 @@ +"""Services for the Lock integration.""" + +import probatio + +from homeassistant.const import ATTR_CODE, SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv + +from .const import DATA_COMPONENT, LockEntityFeature + +LOCK_SERVICE_SCHEMA = cv.make_entity_service_schema( + {probatio.Optional(ATTR_CODE): cv.string} +) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the lock services.""" + component = hass.data[DATA_COMPONENT] + + component.async_register_entity_service( + SERVICE_UNLOCK, LOCK_SERVICE_SCHEMA, "async_handle_unlock_service" + ) + component.async_register_entity_service( + SERVICE_LOCK, LOCK_SERVICE_SCHEMA, "async_handle_lock_service" + ) + component.async_register_entity_service( + SERVICE_OPEN, + LOCK_SERVICE_SCHEMA, + "async_handle_open_service", + [LockEntityFeature.OPEN], + ) diff --git a/homeassistant/components/media_player/llm.py b/homeassistant/components/media_player/llm.py index 11bc45a40936e..5c41144721194 100644 --- a/homeassistant/components/media_player/llm.py +++ b/homeassistant/components/media_player/llm.py @@ -4,7 +4,13 @@ from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import intent -from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool +from homeassistant.helpers.llm import ( + LLM_API_ASSIST, + IntentTool, + LLMContext, + Tool, + ToolAnnotations, +) from .const import ( DOMAIN, @@ -20,17 +26,36 @@ ) # Intents owned by this integration that are exposed as LLM tools. -LLM_INTENTS = ( - INTENT_MEDIA_NEXT, - INTENT_MEDIA_PAUSE, - INTENT_PLAYER_MUTE, - INTENT_PLAYER_UNMUTE, - INTENT_MEDIA_PREVIOUS, - INTENT_MEDIA_SEARCH_AND_PLAY, - INTENT_MEDIA_UNPAUSE, - INTENT_SET_VOLUME, - INTENT_SET_VOLUME_RELATIVE, -) +LLM_INTENTS = { + INTENT_MEDIA_NEXT: "Next track", + INTENT_MEDIA_PAUSE: "Pause media", + INTENT_PLAYER_MUTE: "Mute player", + INTENT_PLAYER_UNMUTE: "Unmute player", + INTENT_MEDIA_PREVIOUS: "Previous track", + INTENT_MEDIA_SEARCH_AND_PLAY: "Search and play media", + INTENT_MEDIA_UNPAUSE: "Resume media", + INTENT_SET_VOLUME: "Set volume", + INTENT_SET_VOLUME_RELATIVE: "Change volume", +} + +# Setting a value on the user's own player has no further effect when it is +# repeated. Stepping through tracks or volume has an effect on every call, and +# a search reaches the media the player can read. +_CONTROL = ToolAnnotations(idempotent=True, open_world=False) +_CUMULATIVE = ToolAnnotations(open_world=False) + +INTENT_ANNOTATIONS = { + INTENT_MEDIA_PAUSE: _CONTROL, + # Unpausing clears the players it remembered, so a repeat can resume more. + INTENT_MEDIA_UNPAUSE: _CUMULATIVE, + INTENT_PLAYER_MUTE: _CONTROL, + INTENT_PLAYER_UNMUTE: _CONTROL, + INTENT_SET_VOLUME: _CONTROL, + INTENT_MEDIA_NEXT: _CUMULATIVE, + INTENT_MEDIA_PREVIOUS: _CUMULATIVE, + INTENT_SET_VOLUME_RELATIVE: _CUMULATIVE, + INTENT_MEDIA_SEARCH_AND_PLAY: ToolAnnotations(), +} @callback @@ -51,7 +76,13 @@ def async_get_tools( return None tools: list[Tool] = [ - IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) + IntentTool( + f"{DOMAIN}__{handler.intent_type}", + handler, + title=LLM_INTENTS[handler.intent_type], + integration=DOMAIN, + annotations=INTENT_ANNOTATIONS[handler.intent_type], + ) for handler in intent.async_get(hass) if handler.intent_type in LLM_INTENTS ] diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index a20acb1573254..4acf92e9e4df0 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -8,7 +8,6 @@ from math import ceil, floor from typing import TYPE_CHECKING, Any, Self, final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry @@ -17,20 +16,13 @@ CONF_UNIT_OF_MEASUREMENT, UnitOfTemperature, ) -from homeassistant.core import ( - HomeAssistant, - ServiceCall, - async_get_hass_or_none, - callback, -) -from homeassistant.exceptions import ServiceValidationError +from homeassistant.core import HomeAssistant, async_get_hass_or_none, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_suggest_report_issue -from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 AMBIGUOUS_UNITS, @@ -38,6 +30,7 @@ ATTR_MIN, ATTR_STEP, ATTR_VALUE, + DATA_COMPONENT, DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, DEFAULT_STEP, @@ -50,11 +43,11 @@ NumberEntityCapabilityAttribute, NumberMode, ) +from .services import async_setup_services from .websocket_api import async_setup as async_setup_ws_api _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[NumberEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -94,41 +87,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_setup_ws_api(hass) await component.async_setup(config) - component.async_register_entity_service( - SERVICE_SET_VALUE, - {probatio.Required(ATTR_VALUE): probatio.Coerce(float)}, - async_set_value, - ) + async_setup_services(hass) return True -async def async_set_value(entity: NumberEntity, service_call: ServiceCall) -> None: - """Service call wrapper to set a new value.""" - value = service_call.data["value"] - if value < entity.min_value or value > entity.max_value: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="out_of_range", - translation_placeholders={ - "value": value, - "entity_id": entity.entity_id, - "min_value": str(entity.min_value), - "max_value": str(entity.max_value), - }, - ) - - try: - native_value = entity.convert_to_native_value(value) - # Clamp to the native range - native_value = min( - max(native_value, entity.native_min_value), entity.native_max_value - ) - await entity.async_set_native_value(native_value) - except NotImplementedError: - await entity.async_set_value(value) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" return await hass.data[DATA_COMPONENT].async_setup_entry(entry) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 5344b46ecaf18..b25135ae8f750 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -1,7 +1,7 @@ """Provides the constants needed for the component.""" from enum import StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final import probatio @@ -40,6 +40,7 @@ UnitOfVolumeFlowRate, UnitOfVolumetricFlux, ) +from homeassistant.util.hass_dict import HassKey from homeassistant.util.unit_conversion import ( ApparentPowerConverter, AreaConverter, @@ -77,6 +78,13 @@ DOMAIN: Final = "number" +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import NumberEntity + +DATA_COMPONENT: HassKey[EntityComponent[NumberEntity]] = HassKey(DOMAIN) + ATTR_VALUE = "value" ATTR_MIN = "min" ATTR_MAX = "max" diff --git a/homeassistant/components/number/services.py b/homeassistant/components/number/services.py new file mode 100644 index 0000000000000..cb395d95524eb --- /dev/null +++ b/homeassistant/components/number/services.py @@ -0,0 +1,49 @@ +"""Services for the Number integration.""" + +from typing import TYPE_CHECKING + +import probatio + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError + +from .const import ATTR_VALUE, DATA_COMPONENT, DOMAIN, SERVICE_SET_VALUE + +if TYPE_CHECKING: + from . import NumberEntity + + +async def _async_set_value(entity: NumberEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new value.""" + value = service_call.data["value"] + if value < entity.min_value or value > entity.max_value: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="out_of_range", + translation_placeholders={ + "value": value, + "entity_id": entity.entity_id, + "min_value": str(entity.min_value), + "max_value": str(entity.max_value), + }, + ) + + try: + native_value = entity.convert_to_native_value(value) + # Clamp to the native range + native_value = min( + max(native_value, entity.native_min_value), entity.native_max_value + ) + await entity.async_set_native_value(native_value) + except NotImplementedError: + await entity.async_set_value(value) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the number services.""" + hass.data[DATA_COMPONENT].async_register_entity_service( + SERVICE_SET_VALUE, + {probatio.Required(ATTR_VALUE): probatio.Coerce(float)}, + _async_set_value, + ) diff --git a/homeassistant/components/paj_gps/manifest.json b/homeassistant/components/paj_gps/manifest.json index 872572b8c0136..af25f23099e1b 100644 --- a/homeassistant/components/paj_gps/manifest.json +++ b/homeassistant/components/paj_gps/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["pajgps-api==0.4.0"] + "requirements": ["pajgps-api==0.4.1"] } diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index aab883a5961f6..85c81fc4921c3 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -6,11 +6,10 @@ import logging from typing import Any, final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 ATTR_COMMAND, SERVICE_TOGGLE, SERVICE_TURN_OFF, @@ -22,45 +21,41 @@ from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN, RemoteEntityFeature, RemoteEntityStateAttribute +from .const import ( # noqa: F401 + ATTR_ACTIVITY, + ATTR_ACTIVITY_LIST, + ATTR_ALTERNATIVE, + ATTR_COMMAND_TYPE, + ATTR_CURRENT_ACTIVITY, + ATTR_DELAY_SECS, + ATTR_DEVICE, + ATTR_HOLD_SECS, + ATTR_NUM_REPEATS, + ATTR_TIMEOUT, + DATA_COMPONENT, + DEFAULT_DELAY_SECS, + DEFAULT_HOLD_SECS, + DEFAULT_NUM_REPEATS, + DOMAIN, + SERVICE_DELETE_COMMAND, + SERVICE_LEARN_COMMAND, + SERVICE_SEND_COMMAND, + SERVICE_SYNC, + RemoteEntityFeature, + RemoteEntityStateAttribute, +) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[RemoteEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = timedelta(seconds=30) -ATTR_ACTIVITY = "activity" -ATTR_ACTIVITY_LIST = "activity_list" -ATTR_CURRENT_ACTIVITY = "current_activity" -ATTR_COMMAND_TYPE = "command_type" -ATTR_DEVICE = "device" -ATTR_NUM_REPEATS = "num_repeats" -ATTR_DELAY_SECS = "delay_secs" -ATTR_HOLD_SECS = "hold_secs" -ATTR_ALTERNATIVE = "alternative" -ATTR_TIMEOUT = "timeout" - MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -SERVICE_SEND_COMMAND = "send_command" -SERVICE_LEARN_COMMAND = "learn_command" -SERVICE_DELETE_COMMAND = "delete_command" -SERVICE_SYNC = "sync" - -DEFAULT_NUM_REPEATS = 1 -DEFAULT_DELAY_SECS = 0.4 -DEFAULT_HOLD_SECS = 0 - - -REMOTE_SERVICE_ACTIVITY_SCHEMA = cv.make_entity_service_schema( - {probatio.Optional(ATTR_ACTIVITY): cv.string} -) - def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Return if the remote is on based on the statemachine.""" @@ -74,54 +69,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service( - SERVICE_TURN_OFF, REMOTE_SERVICE_ACTIVITY_SCHEMA, "async_turn_off" - ) - - component.async_register_entity_service( - SERVICE_TURN_ON, REMOTE_SERVICE_ACTIVITY_SCHEMA, "async_turn_on" - ) - - component.async_register_entity_service( - SERVICE_TOGGLE, REMOTE_SERVICE_ACTIVITY_SCHEMA, "async_toggle" - ) - - component.async_register_entity_service( - SERVICE_SEND_COMMAND, - { - probatio.Required(ATTR_COMMAND): probatio.All(cv.ensure_list, [cv.string]), - probatio.Optional(ATTR_DEVICE): cv.string, - probatio.Optional( - ATTR_NUM_REPEATS, default=DEFAULT_NUM_REPEATS - ): cv.positive_int, - probatio.Optional(ATTR_DELAY_SECS): probatio.Coerce(float), - probatio.Optional( - ATTR_HOLD_SECS, default=DEFAULT_HOLD_SECS - ): probatio.Coerce(float), - }, - "async_send_command", - ) - - component.async_register_entity_service( - SERVICE_LEARN_COMMAND, - { - probatio.Optional(ATTR_DEVICE): cv.string, - probatio.Optional(ATTR_COMMAND): probatio.All(cv.ensure_list, [cv.string]), - probatio.Optional(ATTR_COMMAND_TYPE): cv.string, - probatio.Optional(ATTR_ALTERNATIVE): cv.boolean, - probatio.Optional(ATTR_TIMEOUT): cv.positive_int, - }, - "async_learn_command", - ) - - component.async_register_entity_service( - SERVICE_DELETE_COMMAND, - { - probatio.Required(ATTR_COMMAND): probatio.All(cv.ensure_list, [cv.string]), - probatio.Optional(ATTR_DEVICE): cv.string, - }, - "async_delete_command", - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/remote/const.py b/homeassistant/components/remote/const.py index 72aa671df9237..ac35c5d201128 100644 --- a/homeassistant/components/remote/const.py +++ b/homeassistant/components/remote/const.py @@ -1,9 +1,37 @@ """Constants for the remote component.""" from enum import IntFlag, StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import RemoteEntity DOMAIN: Final = "remote" +DATA_COMPONENT: HassKey[EntityComponent[RemoteEntity]] = HassKey(DOMAIN) + +ATTR_ACTIVITY = "activity" +ATTR_ACTIVITY_LIST = "activity_list" +ATTR_CURRENT_ACTIVITY = "current_activity" +ATTR_COMMAND_TYPE = "command_type" +ATTR_DEVICE = "device" +ATTR_NUM_REPEATS = "num_repeats" +ATTR_DELAY_SECS = "delay_secs" +ATTR_HOLD_SECS = "hold_secs" +ATTR_ALTERNATIVE = "alternative" +ATTR_TIMEOUT = "timeout" + +SERVICE_SEND_COMMAND = "send_command" +SERVICE_LEARN_COMMAND = "learn_command" +SERVICE_DELETE_COMMAND = "delete_command" +SERVICE_SYNC = "sync" + +DEFAULT_NUM_REPEATS = 1 +DEFAULT_DELAY_SECS = 0.4 +DEFAULT_HOLD_SECS = 0 class RemoteEntityStateAttribute(StrEnum): diff --git a/homeassistant/components/remote/services.py b/homeassistant/components/remote/services.py new file mode 100644 index 0000000000000..c0b22553a1191 --- /dev/null +++ b/homeassistant/components/remote/services.py @@ -0,0 +1,88 @@ +"""Services for the Remote integration.""" + +import probatio + +from homeassistant.const import ( + ATTR_COMMAND, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv + +from .const import ( + ATTR_ACTIVITY, + ATTR_ALTERNATIVE, + ATTR_COMMAND_TYPE, + ATTR_DELAY_SECS, + ATTR_DEVICE, + ATTR_HOLD_SECS, + ATTR_NUM_REPEATS, + ATTR_TIMEOUT, + DATA_COMPONENT, + DEFAULT_HOLD_SECS, + DEFAULT_NUM_REPEATS, + SERVICE_DELETE_COMMAND, + SERVICE_LEARN_COMMAND, + SERVICE_SEND_COMMAND, +) + +REMOTE_SERVICE_ACTIVITY_SCHEMA = cv.make_entity_service_schema( + {probatio.Optional(ATTR_ACTIVITY): cv.string} +) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the remote services.""" + component = hass.data[DATA_COMPONENT] + + component.async_register_entity_service( + SERVICE_TURN_OFF, REMOTE_SERVICE_ACTIVITY_SCHEMA, "async_turn_off" + ) + + component.async_register_entity_service( + SERVICE_TURN_ON, REMOTE_SERVICE_ACTIVITY_SCHEMA, "async_turn_on" + ) + + component.async_register_entity_service( + SERVICE_TOGGLE, REMOTE_SERVICE_ACTIVITY_SCHEMA, "async_toggle" + ) + + component.async_register_entity_service( + SERVICE_SEND_COMMAND, + { + probatio.Required(ATTR_COMMAND): probatio.All(cv.ensure_list, [cv.string]), + probatio.Optional(ATTR_DEVICE): cv.string, + probatio.Optional( + ATTR_NUM_REPEATS, default=DEFAULT_NUM_REPEATS + ): cv.positive_int, + probatio.Optional(ATTR_DELAY_SECS): probatio.Coerce(float), + probatio.Optional( + ATTR_HOLD_SECS, default=DEFAULT_HOLD_SECS + ): probatio.Coerce(float), + }, + "async_send_command", + ) + + component.async_register_entity_service( + SERVICE_LEARN_COMMAND, + { + probatio.Optional(ATTR_DEVICE): cv.string, + probatio.Optional(ATTR_COMMAND): probatio.All(cv.ensure_list, [cv.string]), + probatio.Optional(ATTR_COMMAND_TYPE): cv.string, + probatio.Optional(ATTR_ALTERNATIVE): cv.boolean, + probatio.Optional(ATTR_TIMEOUT): cv.positive_int, + }, + "async_learn_command", + ) + + component.async_register_entity_service( + SERVICE_DELETE_COMMAND, + { + probatio.Required(ATTR_COMMAND): probatio.All(cv.ensure_list, [cv.string]), + probatio.Optional(ATTR_DEVICE): cv.string, + }, + "async_delete_command", + ) diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 3b113bb57a76a..ff0ade0945de4 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -40,7 +40,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==3.0.6", "wakeonlan==3.3.0", - "async-upnp-client==0.48.1" + "async-upnp-client==0.48.2" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index c85982a1e9164..dcde4bd6a42b7 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.48.1"] + "requirements": ["async-upnp-client==0.48.2"] } diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index e1cab0c89c311..e6e299d929632 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -261,6 +261,17 @@ class SupportedModels(StrEnum): ROLLER_SHADE_SPEED_QUIET: 1, } +# Curtain movement speeds, exposed as cover speeds. +# Only the Curtain 3 (identified by its advertised model friendly name) +# honours the speed byte; other curtain models always move at normal speed. +CURTAIN_3_MODEL_FRIENDLY_NAME = "Curtain 3" +CURTAIN_SPEED_NORMAL = "normal" +CURTAIN_SPEED_SILENT = "silent" +CURTAIN_SPEED_TO_VALUE = { + CURTAIN_SPEED_NORMAL: 255, + CURTAIN_SPEED_SILENT: 1, +} + AIRPURIFIER_BASIC_MODELS = { SwitchbotModel.AIR_PURIFIER_JP, SwitchbotModel.AIR_PURIFIER_US, diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 411e4f05dc26d..021fc9231853a 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -20,6 +20,8 @@ from .const import ( CONF_CURTAIN_SPEED, + CURTAIN_3_MODEL_FRIENDLY_NAME, + CURTAIN_SPEED_TO_VALUE, DEFAULT_CURTAIN_SPEED, ROLLER_SHADE_SPEED_PERFORMANCE, ROLLER_SHADE_SPEED_TO_MODE, @@ -67,10 +69,22 @@ def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: """Initialize the Switchbot.""" super().__init__(coordinator) self._attr_is_closed = None + # Only the Curtain 3 honours the speed byte; expose cover speeds + # (e.g. silent mode) for that model only. + if self._device.data.get("modelFriendlyName") == CURTAIN_3_MODEL_FRIENDLY_NAME: + self._attr_supported_features |= CoverEntityFeature.SPEED + self._attr_supported_speeds = list(CURTAIN_SPEED_TO_VALUE) @callback - def _get_curtain_speed(self) -> int: - """Return the configured curtain speed.""" + def _get_curtain_speed(self, kwargs: dict[str, Any]) -> int: + """Return the movement speed for the requested cover speed. + + A speed passed to the action takes precedence; otherwise the speed + configured in the options flow is used. + """ + if (speed := kwargs.get(ATTR_SPEED)) is not None: + # The cover entity validated the speed against _attr_supported_speeds. + return CURTAIN_SPEED_TO_VALUE[speed] return int( self.coordinator.config_entry.options.get( CONF_CURTAIN_SPEED, DEFAULT_CURTAIN_SPEED @@ -101,7 +115,7 @@ async def async_open_cover(self, **kwargs: Any) -> None: """Open the curtain.""" _LOGGER.debug("Switchbot to open curtain %s", self._address) - speed = self._get_curtain_speed() + speed = self._get_curtain_speed(kwargs) self._last_run_success = bool(await self._device.open(speed)) self._attr_is_opening = self._device.is_opening() self._attr_is_closing = self._device.is_closing() @@ -113,7 +127,7 @@ async def async_close_cover(self, **kwargs: Any) -> None: """Close the curtain.""" _LOGGER.debug("Switchbot to close the curtain %s", self._address) - speed = self._get_curtain_speed() + speed = self._get_curtain_speed(kwargs) self._last_run_success = bool(await self._device.close(speed)) self._attr_is_opening = self._device.is_opening() self._attr_is_closing = self._device.is_closing() @@ -134,10 +148,11 @@ async def async_stop_cover(self, **kwargs: Any) -> None: @override async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover shutter to a specific position.""" - position = kwargs.get(ATTR_POSITION) + position = kwargs[ATTR_POSITION] _LOGGER.debug("Switchbot to move at %d %s", position, self._address) - self._last_run_success = bool(await self._device.set_position(position)) + speed = self._get_curtain_speed(kwargs) + self._last_run_success = bool(await self._device.set_position(position, speed)) self._attr_is_opening = self._device.is_opening() self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 87a6c93ebb768..9034516c04097 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -157,6 +157,12 @@ "false": "[%key:component::binary_sensor::entity_component::problem::state::on%]", "true": "[%key:component::binary_sensor::entity_component::problem::state::off%]" } + }, + "speed": { + "state": { + "normal": "Normal", + "silent": "Silent" + } } } }, diff --git a/homeassistant/components/teslemetry/config_flow.py b/homeassistant/components/teslemetry/config_flow.py index bd166b3e9f7a2..d7efe5aa34e99 100644 --- a/homeassistant/components/teslemetry/config_flow.py +++ b/homeassistant/components/teslemetry/config_flow.py @@ -451,6 +451,7 @@ def __init__(self) -> None: self._discovered_host: str = "" self._site_id: int | None = None self._site_name: str = "" + self._approval_expired: bool = False async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -603,6 +604,11 @@ async def async_step_pair( if user_input is None: return self.async_show_form(step_id="pair") + if self._approval_expired: + # The user saw the expired-window notice and submitted to try again. + self._approval_expired = False + return await self._async_begin_pairing() + try: client = await self._find_authorized_client() except PowerwallLookupError: @@ -618,6 +624,10 @@ async def async_step_pair( return await self.async_step_credentials() if client.state == AuthorizedClientState.PENDING_VERIFICATION: return self.async_show_form(step_id="pair", errors={"base": "key_pending"}) + if client.state == AuthorizedClientState.PENDING_VERIFICATION_TIMEOUT: + # Surface the expiry; the user's next submit reopens the window. + self._approval_expired = True + return self.async_show_form(step_id="pair", errors={"base": "key_expired"}) # An unrecognized state reported as pending would trap the user forever. LOGGER.debug("Unrecognized authorized-client state: %s", client.state) return self.async_show_form(step_id="pair", errors={"base": "cannot_connect"}) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 1d69696b2b679..09e5e0a0544bb 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -58,6 +58,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_password": "[%key:common::config_flow::error::invalid_auth%]", + "key_expired": "The approval window on your Powerwall closed before the key was approved. Submit to open a new window, flick the On/Off switch on your primary Powerwall off and back on to approve it, then submit again.", "key_not_approved": "Your Powerwall system rejected Home Assistant's key because it has not been approved. Flick the On/Off switch on your primary Powerwall off and back on to approve it, then submit again.", "key_not_registered": "Home Assistant's key is no longer registered on your Powerwall system. Restart setup to register it again.", "key_pending": "Home Assistant's key has not been approved yet. Flick the On/Off switch on your primary Powerwall off and back on to approve it, then submit again." diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index cee4d3c1e9077..fe654986e5530 100644 --- a/homeassistant/components/text/__init__.py +++ b/homeassistant/components/text/__init__.py @@ -7,32 +7,31 @@ import re from typing import Any, final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODE, MAX_LENGTH_STATE_STATE # noqa: F401 -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 ATTR_MAX, ATTR_MIN, ATTR_PATTERN, ATTR_VALUE, + DATA_COMPONENT, DOMAIN, SERVICE_SET_VALUE, TextEntityCapabilityAttribute, ) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[TextEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -57,36 +56,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service( - SERVICE_SET_VALUE, - {probatio.Required(ATTR_VALUE): cv.string}, - _async_set_value, - ) + async_setup_services(hass) return True -async def _async_set_value(entity: TextEntity, service_call: ServiceCall) -> None: - """Service call wrapper to set a new value.""" - value = service_call.data[ATTR_VALUE] - if len(value) < entity.min: - raise ValueError( - f"Value {value} for {entity.entity_id} is too short (minimum length" - f" {entity.min})" - ) - if len(value) > entity.max: - raise ValueError( - f"Value {value} for {entity.entity_id}" - f" is too long (maximum length {entity.max})" - ) - if entity.pattern_cmp and not entity.pattern_cmp.match(value): - raise ValueError( - f"Value {value} for {entity.entity_id}" - f" doesn't match pattern {entity.pattern}" - ) - await entity.async_set_value(value) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" return await hass.data[DATA_COMPONENT].async_setup_entry(entry) diff --git a/homeassistant/components/text/const.py b/homeassistant/components/text/const.py index b0f52a9b6983b..8056df4eb7b6d 100644 --- a/homeassistant/components/text/const.py +++ b/homeassistant/components/text/const.py @@ -1,9 +1,17 @@ """Provides the constants needed for the component.""" from enum import StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import TextEntity DOMAIN: Final = "text" +DATA_COMPONENT: HassKey[EntityComponent[TextEntity]] = HassKey(DOMAIN) class TextEntityCapabilityAttribute(StrEnum): diff --git a/homeassistant/components/text/services.py b/homeassistant/components/text/services.py new file mode 100644 index 0000000000000..ff0a62562fc80 --- /dev/null +++ b/homeassistant/components/text/services.py @@ -0,0 +1,44 @@ +"""Services for the Text integration.""" + +from typing import TYPE_CHECKING + +import probatio + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv + +from .const import ATTR_VALUE, DATA_COMPONENT, SERVICE_SET_VALUE + +if TYPE_CHECKING: + from . import TextEntity + + +async def _async_set_value(entity: TextEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new value.""" + value = service_call.data[ATTR_VALUE] + if len(value) < entity.min: + raise ValueError( + f"Value {value} for {entity.entity_id} is too short (minimum length" + f" {entity.min})" + ) + if len(value) > entity.max: + raise ValueError( + f"Value {value} for {entity.entity_id}" + f" is too long (maximum length {entity.max})" + ) + if entity.pattern_cmp and not entity.pattern_cmp.match(value): + raise ValueError( + f"Value {value} for {entity.entity_id}" + f" doesn't match pattern {entity.pattern}" + ) + await entity.async_set_value(value) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the text services.""" + hass.data[DATA_COMPONENT].async_register_entity_service( + SERVICE_SET_VALUE, + {probatio.Required(ATTR_VALUE): cv.string}, + _async_set_value, + ) diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py index 56a9e9e81c789..fa7fb8c292dc7 100644 --- a/homeassistant/components/time/__init__.py +++ b/homeassistant/components/time/__init__.py @@ -4,23 +4,21 @@ import logging from typing import final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TIME -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import ATTR_TIME # noqa: F401 +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN, SERVICE_SET_VALUE +from .const import DATA_COMPONENT, DOMAIN, SERVICE_SET_VALUE # noqa: F401 +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[TimeEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -30,11 +28,6 @@ __all__ = ["DOMAIN", "TimeEntity", "TimeEntityDescription"] -async def _async_set_value(entity: TimeEntity, service_call: ServiceCall) -> None: - """Service call wrapper to set a new date.""" - return await entity.async_set_value(service_call.data[ATTR_TIME]) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Time entities.""" component = hass.data[DATA_COMPONENT] = EntityComponent[TimeEntity]( @@ -42,9 +35,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service( - SERVICE_SET_VALUE, {probatio.Required(ATTR_TIME): cv.time}, _async_set_value - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/time/const.py b/homeassistant/components/time/const.py index 441f87a914a99..d1fba65fea643 100644 --- a/homeassistant/components/time/const.py +++ b/homeassistant/components/time/const.py @@ -1,7 +1,15 @@ """Provides the constants needed for the component.""" -from typing import Final +from typing import TYPE_CHECKING, Final + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import TimeEntity DOMAIN: Final = "time" +DATA_COMPONENT: HassKey[EntityComponent[TimeEntity]] = HassKey(DOMAIN) SERVICE_SET_VALUE = "set_value" diff --git a/homeassistant/components/time/services.py b/homeassistant/components/time/services.py new file mode 100644 index 0000000000000..bf9dc6b0b4fc0 --- /dev/null +++ b/homeassistant/components/time/services.py @@ -0,0 +1,27 @@ +"""Services for the Time integration.""" + +from typing import TYPE_CHECKING + +import probatio + +from homeassistant.const import ATTR_TIME +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv + +from .const import DATA_COMPONENT, SERVICE_SET_VALUE + +if TYPE_CHECKING: + from . import TimeEntity + + +async def _async_set_value(entity: TimeEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new date.""" + return await entity.async_set_value(service_call.data[ATTR_TIME]) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the time services.""" + hass.data[DATA_COMPONENT].async_register_entity_service( + SERVICE_SET_VALUE, {probatio.Required(ATTR_TIME): cv.time}, _async_set_value + ) diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 554d6cb04c716..8052a00b03ba3 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -17,14 +17,13 @@ EntityCategory, EntityStateAttribute, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ABCCachedProperties, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 ATTR_AUTO_UPDATE, @@ -39,6 +38,7 @@ ATTR_TITLE, ATTR_UPDATE_PERCENTAGE, ATTR_VERSION, + DATA_COMPONENT, DEVICE_CLASSES_SCHEMA, DOMAIN, SERVICE_INSTALL, @@ -47,10 +47,10 @@ UpdateEntityFeature, UpdateEntityStateAttribute, ) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[UpdateEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -85,29 +85,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service( - SERVICE_INSTALL, - { - probatio.Optional(ATTR_VERSION): cv.string, - probatio.Optional(ATTR_BACKUP, default=False): cv.boolean, - }, - async_install, - [UpdateEntityFeature.INSTALL], - admin_only=True, - ) - - component.async_register_entity_service( - SERVICE_SKIP, - None, - async_skip, - admin_only=True, - ) - component.async_register_entity_service( - "clear_skipped", - None, - async_clear_skipped, - admin_only=True, - ) + async_setup_services(hass) websocket_api.async_register_command(hass, websocket_release_notes) @@ -124,73 +102,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.data[DATA_COMPONENT].async_unload_entry(entry) -async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None: - """Service call wrapper to validate the call.""" - # If version is not specified, but no update is available. - if (version := service_call.data.get(ATTR_VERSION)) is None and ( - entity.installed_version == entity.latest_version - or entity.latest_version is None - ): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="no_update_available", - translation_placeholders={"entity_id": entity.entity_id}, - ) - - # If version is specified, but not supported by the entity. - if ( - version is not None - and UpdateEntityFeature.SPECIFIC_VERSION not in entity.supported_features - ): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="specific_version_not_supported", - translation_placeholders={"entity_id": entity.entity_id}, - ) - - # If backup is requested, but not supported by the entity. - if ( - backup := service_call.data[ATTR_BACKUP] - ) and UpdateEntityFeature.BACKUP not in entity.supported_features: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="backup_not_supported", - translation_placeholders={"entity_id": entity.entity_id}, - ) - - # Update is already in progress. - if entity.in_progress is not False: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="update_in_progress", - translation_placeholders={"entity_id": entity.entity_id}, - ) - - await entity.async_install_with_progress(version, backup) - - -async def async_skip(entity: UpdateEntity, service_call: ServiceCall) -> None: - """Service call wrapper to validate the call.""" - if entity.auto_update: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="skip_not_supported", - translation_placeholders={"entity_id": entity.entity_id}, - ) - await entity.async_skip() - - -async def async_clear_skipped(entity: UpdateEntity, service_call: ServiceCall) -> None: - """Service call wrapper to validate the call.""" - if entity.auto_update: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="clear_skipped_not_supported", - translation_placeholders={"entity_id": entity.entity_id}, - ) - await entity.async_clear_skipped() - - class UpdateEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes update entities.""" diff --git a/homeassistant/components/update/const.py b/homeassistant/components/update/const.py index 50c7bed340f2d..363ef27c31fd0 100644 --- a/homeassistant/components/update/const.py +++ b/homeassistant/components/update/const.py @@ -1,11 +1,19 @@ """Constants for the update component.""" from enum import IntFlag, StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final import probatio +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import UpdateEntity + DOMAIN: Final = "update" +DATA_COMPONENT: HassKey[EntityComponent[UpdateEntity]] = HassKey(DOMAIN) class UpdateEntityStateAttribute(StrEnum): diff --git a/homeassistant/components/update/services.py b/homeassistant/components/update/services.py new file mode 100644 index 0000000000000..1e69f03840af3 --- /dev/null +++ b/homeassistant/components/update/services.py @@ -0,0 +1,119 @@ +"""Services for the Update integration.""" + +from typing import TYPE_CHECKING + +import probatio + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv + +from .const import ( + ATTR_BACKUP, + ATTR_VERSION, + DATA_COMPONENT, + DOMAIN, + SERVICE_INSTALL, + SERVICE_SKIP, + UpdateEntityFeature, +) + +if TYPE_CHECKING: + from . import UpdateEntity + + +async def _async_install(entity: UpdateEntity, service_call: ServiceCall) -> None: + """Service call wrapper to validate the call.""" + # If version is not specified, but no update is available. + if (version := service_call.data.get(ATTR_VERSION)) is None and ( + entity.installed_version == entity.latest_version + or entity.latest_version is None + ): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_update_available", + translation_placeholders={"entity_id": entity.entity_id}, + ) + + # If version is specified, but not supported by the entity. + if ( + version is not None + and UpdateEntityFeature.SPECIFIC_VERSION not in entity.supported_features + ): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="specific_version_not_supported", + translation_placeholders={"entity_id": entity.entity_id}, + ) + + # If backup is requested, but not supported by the entity. + if ( + backup := service_call.data[ATTR_BACKUP] + ) and UpdateEntityFeature.BACKUP not in entity.supported_features: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="backup_not_supported", + translation_placeholders={"entity_id": entity.entity_id}, + ) + + # Update is already in progress. + if entity.in_progress is not False: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_in_progress", + translation_placeholders={"entity_id": entity.entity_id}, + ) + + await entity.async_install_with_progress(version, backup) + + +async def _async_skip(entity: UpdateEntity, service_call: ServiceCall) -> None: + """Service call wrapper to validate the call.""" + if entity.auto_update: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="skip_not_supported", + translation_placeholders={"entity_id": entity.entity_id}, + ) + await entity.async_skip() + + +async def _async_clear_skipped(entity: UpdateEntity, service_call: ServiceCall) -> None: + """Service call wrapper to validate the call.""" + if entity.auto_update: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="clear_skipped_not_supported", + translation_placeholders={"entity_id": entity.entity_id}, + ) + await entity.async_clear_skipped() + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the update services.""" + component = hass.data[DATA_COMPONENT] + + component.async_register_entity_service( + SERVICE_INSTALL, + { + probatio.Optional(ATTR_VERSION): cv.string, + probatio.Optional(ATTR_BACKUP, default=False): cv.boolean, + }, + _async_install, + [UpdateEntityFeature.INSTALL], + admin_only=True, + ) + + component.async_register_entity_service( + SERVICE_SKIP, + None, + _async_skip, + admin_only=True, + ) + component.async_register_entity_service( + "clear_skipped", + None, + _async_clear_skipped, + admin_only=True, + ) diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index c88801ad2a940..2a03b771ea935 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.48.1", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.48.2", "getmac==0.9.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/wiim/manifest.json b/homeassistant/components/wiim/manifest.json index 5079d1d8491e2..297c75c25ebe2 100644 --- a/homeassistant/components/wiim/manifest.json +++ b/homeassistant/components/wiim/manifest.json @@ -9,6 +9,6 @@ "iot_class": "local_push", "loggers": ["wiim.sdk", "async_upnp_client"], "quality_scale": "platinum", - "requirements": ["async-upnp-client==0.48.1", "wiim==0.1.7"], + "requirements": ["async-upnp-client==0.48.2", "wiim==0.1.7"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 4faf9961d3c68..f64004c4b155a 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], - "requirements": ["yeelight==0.7.16", "async-upnp-client==0.48.1"], + "requirements": ["yeelight==0.7.16", "async-upnp-client==0.48.2"], "zeroconf": [ { "name": "yeelink-*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e3b3f19c4e736..3d4f871b0019c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3377,7 +3377,7 @@ "name": "INDI Allsky", "integration_type": "service", "config_flow": true, - "iot_class": "local_polling" + "iot_class": "local_push" }, "indianamichiganpower": { "name": "Indiana Michigan Power", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6b79833b1c4f0..980e5f4f7be7a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,7 +13,7 @@ aiozoneinfo==0.2.3 annotatedyaml==1.0.2 astral==2.2 async-interrupt==1.2.2 -async-upnp-client==0.48.1 +async-upnp-client==0.48.2 atomicwrites-homeassistant==1.4.1 attrs==26.1.0 audioop-lts==0.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index a0a82a1fb34f3..04426585ac607 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -600,7 +600,7 @@ asusrouter==1.21.3 # homeassistant.components.upnp # homeassistant.components.wiim # homeassistant.components.yeelight -async-upnp-client==0.48.1 +async-upnp-client==0.48.2 # homeassistant.components.arve asyncarve==0.1.1 @@ -1887,7 +1887,7 @@ p1monitor==3.2.0 paho-mqtt==2.1.0 # homeassistant.components.paj_gps -pajgps-api==0.4.0 +pajgps-api==0.4.1 # homeassistant.components.panasonic_bluray panacotta==0.2 diff --git a/tests/components/alexa_devices/test_diagnostics.py b/tests/components/alexa_devices/test_diagnostics.py index 00a63db8ca8f4..4c8388f6280f4 100644 --- a/tests/components/alexa_devices/test_diagnostics.py +++ b/tests/components/alexa_devices/test_diagnostics.py @@ -6,6 +6,7 @@ from syrupy.filters import props from homeassistant.components.alexa_devices.const import DOMAIN +from homeassistant.components.alexa_devices.entity import service_device_id from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -66,3 +67,25 @@ async def test_device_diagnostics( "modified_at", ) ) + + +async def test_service_device_diagnostics( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test Amazon service device diagnostics, which has no serial number.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, service_device_id(mock_config_entry.runtime_data)), + mock_config_entry.entry_id, + ) + assert device, repr(device_registry._devices) + assert device.serial_number is None + + assert await get_diagnostics_for_device( + hass, hass_client, mock_config_entry, device + ) == {"service device": True} diff --git a/tests/components/bosch_shc/conftest.py b/tests/components/bosch_shc/conftest.py index c8cf981a38dd5..c12836b1ebbd8 100644 --- a/tests/components/bosch_shc/conftest.py +++ b/tests/components/bosch_shc/conftest.py @@ -7,18 +7,25 @@ from boschshcpy import ( BatteryLevelService, + BypassService, PowerSwitchService, + RoutingService, SHCBatteryDevice, SHCLightSwitchBSM, SHCMicromoduleBlinds, SHCMicromoduleRelay, SHCPresenceSimulationSystem, + SHCShutterContact2, SHCShutterControl, + SHCSmartPlug, SHCThermostat, ShutterControlService, ThermostatService, ) -from boschshcpy.services_impl import PresenceSimulationConfigurationService +from boschshcpy.services_impl import ( + PresenceSimulationConfigurationService, + ValveTappetService, +) import pytest from homeassistant.components.bosch_shc.const import ( @@ -191,10 +198,33 @@ def micromodule_blinds_device( return device +def smart_plug_device( + device_id: str = "hdm:ZigBee:plug1", + name: str = "Smart Plug", + routing: RoutingService.State = RoutingService.State.DISABLED, +) -> SHCSmartPlug: + """Build a minimal device double for the smart_plugs bucket.""" + device = create_autospec(SHCSmartPlug, instance=True, spec_set=True) + device.name = name + device.id = device_id + device.root_device_id = "test-mac" + device.serial = f"serial-{device_id}" + device.manufacturer = "Bosch" + device.device_model = "PSM" + device.device_services = [] + device.deleted = False + device.status = "AVAILABLE" + device.switchstate = PowerSwitchService.State.OFF + device.routing = routing + return device + + def thermostat_device( device_id: str = "hdm:ZigBee:thermostat1", name: str = "Thermostat", child_lock: ThermostatService.State = ThermostatService.State.OFF, + position: int = 50, + valvestate: ValveTappetService.State = ValveTappetService.State.VALVE_ADAPTION_SUCCESSFUL, ) -> SHCThermostat: """Build a minimal device double for the thermostats/roomthermostats/wallthermostats buckets.""" device = create_autospec(SHCThermostat, instance=True, spec_set=True) @@ -208,6 +238,8 @@ def thermostat_device( device.deleted = False device.status = "AVAILABLE" device.child_lock = child_lock + device.position = position + device.valvestate = valvestate return device @@ -278,3 +310,25 @@ def presence_simulation_system_device( device.status = "AVAILABLE" device.enabled = enabled return device + + +def shutter_contact2_device( + device_id: str = "hdm:ZigBee:shuttercontact1", + name: str = "Shutter contact", + bypass: BypassService.State = BypassService.State.BYPASS_INACTIVE, + bypass_infinite: bool = False, +) -> SHCShutterContact2: + """Build a minimal device double for the shutter_contacts2 bucket.""" + device = create_autospec(SHCShutterContact2, instance=True, spec_set=True) + device.name = name + device.id = device_id + device.root_device_id = "test-mac" + device.serial = f"serial-{device_id}" + device.manufacturer = "Bosch" + device.device_model = "SWD2" + device.device_services = [] + device.deleted = False + device.status = "AVAILABLE" + device.bypass = bypass + device.bypass_infinite = bypass_infinite + return device diff --git a/tests/components/bosch_shc/test_sensor.py b/tests/components/bosch_shc/test_sensor.py new file mode 100644 index 0000000000000..4b15942c45434 --- /dev/null +++ b/tests/components/bosch_shc/test_sensor.py @@ -0,0 +1,147 @@ +"""Tests for the Bosch SHC sensor platform.""" + +from collections.abc import Generator +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from boschshcpy.services_impl import ValveTappetService +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.util import dt as dt_util + +from .conftest import setup_integration, thermostat_device + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.fixture(autouse=True) +def platforms() -> Generator[None]: + """Restrict bosch_shc setup to the sensor platform.""" + with patch("homeassistant.components.bosch_shc.PLATFORMS", [Platform.SENSOR]): + yield + + +@pytest.mark.usefixtures("mock_session") +async def test_open_windows_doors_sensor( + hass: HomeAssistant, + mock_session: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """The whole-home open-doors/open-windows summary is exposed and polled.""" + mock_session.api.get_open_windows.return_value = { + "openDoors": [{"name": "Front Door"}], + "openWindows": [{"name": "Kitchen Window"}, {"name": "Bedroom Window"}], + "openOthers": [{"name": "Cat Flap"}], + } + await setup_integration(hass, mock_config_entry) + + entity_id = "sensor.mock_title_open_doors_and_windows" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "4" + assert state.attributes["open_doors"] == ["Front Door"] + assert state.attributes["open_windows"] == ["Kitchen Window", "Bedroom Window"] + assert state.attributes["open_others"] == ["Cat Flap"] + + hub_device = device_registry.async_get_device_by_identifier( + ("bosch_shc", "test-mac"), mock_config_entry.entry_id + ) + assert hub_device is not None + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.device_id == hub_device.id + + mock_session.api.get_open_windows.return_value = { + "openDoors": [], + "openWindows": [], + "openOthers": [], + } + await async_update_entity(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "0" + + +@pytest.mark.parametrize( + "device_buckets", + [ + { + "thermostats": [ + thermostat_device(valvestate=ValveTappetService.State.VALVE_TOO_TIGHT) + ] + } + ], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_thermostat_valve_tappet_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """A thermostat's valve motor status is exposed as an ENUM sensor.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.thermostat_valve_motor_status") + assert state is not None + assert state.state == "valve_too_tight" + + +@pytest.mark.parametrize( + "device_buckets", + [{"thermostats": [thermostat_device()]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_thermostat_valvetappet_disabled_by_default( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """The raw valve tappet percentage sensor stays opt-in, superseded by the valve entity.""" + await setup_integration(hass, mock_config_entry) + + entry = entity_registry.async_get("sensor.thermostat_valvetappet") + assert entry is not None + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.parametrize( + "device_buckets", + [ + { + "thermostats": [ + thermostat_device(valvestate=ValveTappetService.State.VALVE_TOO_TIGHT) + ] + } + ], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_thermostat_valvetappet_state_attribute_preserved( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Enabling the legacy percentage sensor still surfaces its valve_tappet_state attribute.""" + await setup_integration(hass, mock_config_entry) + + with patch("homeassistant.config_entries.RELOAD_AFTER_UPDATE_DELAY", 1): + entity_registry.async_update_entity( + "sensor.thermostat_valvetappet", disabled_by=None + ) + await hass.async_block_till_done(wait_background_tasks=True) + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("sensor.thermostat_valvetappet") + assert state is not None + assert state.attributes["valve_tappet_state"] == "VALVE_TOO_TIGHT" diff --git a/tests/components/bosch_shc/test_switch.py b/tests/components/bosch_shc/test_switch.py index 4955107cc7b4f..53ced8857858c 100644 --- a/tests/components/bosch_shc/test_switch.py +++ b/tests/components/bosch_shc/test_switch.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch -from boschshcpy import ThermostatService +from boschshcpy import BypassService, ThermostatService import pytest from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -21,6 +21,8 @@ micromodule_relay_device, presence_simulation_system_device, setup_integration, + shutter_contact2_device, + smart_plug_device, thermostat_device, ) @@ -193,3 +195,125 @@ async def test_no_presence_simulation_system( await setup_integration(hass, mock_config_entry) assert hass.states.get("switch.presence_simulation") is None + + +@pytest.mark.parametrize( + "device_buckets", + [{"smart_plugs": [smart_plug_device()]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_smart_plug_routing_switch_name( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """The Smart Plug's routing switch is named "Range extension", not "Routing".""" + await setup_integration(hass, mock_config_entry) + + entry = entity_registry.async_get("switch.smart_plug_range_extension") + assert entry is not None + state = hass.states.get("switch.smart_plug_range_extension") + assert state is not None + assert state.attributes["friendly_name"] == "Smart Plug Range extension" + + +@pytest.mark.parametrize( + "device_buckets", + [ + { + "shutter_contacts2": [ + shutter_contact2_device(bypass=BypassService.State.BYPASS_INACTIVE) + ] + } + ], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_shutter_contact2_bypass( + hass: HomeAssistant, + mock_session: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A Door/Window Contact II's alarm bypass is exposed and controllable.""" + await setup_integration(hass, mock_config_entry) + device = mock_session.device_helper.shutter_contacts2[0] + + state = hass.states.get("switch.shutter_contact_break_function") + assert state is not None + assert state.state == "off" + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.shutter_contact_break_function"}, + blocking=True, + ) + assert device.bypass is True + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.shutter_contact_break_function"}, + blocking=True, + ) + assert device.bypass is False + + +@pytest.mark.parametrize( + "device_buckets", + [{"shutter_contacts2": [shutter_contact2_device(bypass_infinite=False)]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_shutter_contact2_bypass_infinite( + hass: HomeAssistant, + mock_session: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A Door/Window Contact II's bypass-never-expires option is exposed and controllable.""" + await setup_integration(hass, mock_config_entry) + device = mock_session.device_helper.shutter_contacts2[0] + + state = hass.states.get("switch.shutter_contact_break_function_never_expires") + assert state is not None + assert state.state == "off" + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.shutter_contact_break_function_never_expires"}, + blocking=True, + ) + device.set_bypass_configuration.assert_called_once_with(infinite=True) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.shutter_contact_break_function_never_expires"}, + blocking=True, + ) + device.set_bypass_configuration.assert_called_with(infinite=False) + + +@pytest.mark.parametrize( + "device_buckets", + [{"shutter_contacts2": [shutter_contact2_device()]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_shutter_contact2_bypass_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A Door/Window Contact II's two bypass switches use distinct unique_ids.""" + await setup_integration(hass, mock_config_entry) + + bypass_entry = entity_registry.async_get("switch.shutter_contact_break_function") + bypass_infinite_entry = entity_registry.async_get( + "switch.shutter_contact_break_function_never_expires" + ) + assert bypass_entry is not None + assert bypass_infinite_entry is not None + assert bypass_entry.unique_id != bypass_infinite_entry.unique_id diff --git a/tests/components/bosch_shc/test_valve.py b/tests/components/bosch_shc/test_valve.py new file mode 100644 index 0000000000000..0e56c40b3208a --- /dev/null +++ b/tests/components/bosch_shc/test_valve.py @@ -0,0 +1,38 @@ +"""Tests for the Bosch SHC valve platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .conftest import setup_integration, thermostat_device + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def platforms() -> Generator[None]: + """Restrict bosch_shc setup to the valve platform.""" + with patch("homeassistant.components.bosch_shc.PLATFORMS", [Platform.VALVE]): + yield + + +@pytest.mark.parametrize( + "device_buckets", + [{"thermostats": [thermostat_device(position=42)]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_thermostat_valve_position( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """A thermostat's valve tappet position is exposed as a valve entity.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("valve.thermostat_valve") + assert state is not None + assert state.attributes["current_position"] == 42 diff --git a/tests/components/fan/test_llm.py b/tests/components/fan/test_llm.py index c27ac4ae652db..365e84fea2287 100644 --- a/tests/components/fan/test_llm.py +++ b/tests/components/fan/test_llm.py @@ -43,7 +43,14 @@ async def _tool_names(hass: HomeAssistant) -> set[str]: async def test_intent_tool_exposed(hass: HomeAssistant) -> None: """Test the intent tool is offered for an exposed fan entity.""" - assert "fan__HassFanSetSpeed" in await _tool_names(hass) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + tools = {tool.name: tool for tool in result.tools} + assert "fan__HassFanSetSpeed" in tools + + tool = tools["fan__HassFanSetSpeed"] + assert tool.title == "Set fan speed" + assert tool.integration == "fan" + assert tool.annotations == llm.ToolAnnotations(idempotent=True, open_world=False) async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: diff --git a/tests/components/gatus/test_sensor.py b/tests/components/gatus/test_sensor.py index 0facd887b4251..9793a57c02f8f 100644 --- a/tests/components/gatus/test_sensor.py +++ b/tests/components/gatus/test_sensor.py @@ -5,6 +5,7 @@ from freezegun.api import FrozenDateTimeFactory from gatus_api import EndpointStatus, Result +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform @@ -21,6 +22,7 @@ ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_setup_and_states( hass: HomeAssistant, mock_gatus_client: AsyncMock, @@ -153,6 +155,7 @@ async def test_sensor_missing_status_code( hass: HomeAssistant, mock_gatus_client: AsyncMock, mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: """Test that a result missing status code evaluates to STATE_UNKNOWN for status code sensor.""" mock_gatus_client.get_endpoints_statuses.return_value = [ @@ -165,6 +168,11 @@ async def test_sensor_missing_status_code( ] await setup_integration(hass, mock_config_entry) + entity_registry.async_update_entity( + "sensor.backend_service_status_code", disabled_by=None + ) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() state = hass.states.get("sensor.backend_service_status_code") assert state is not None @@ -225,3 +233,25 @@ async def test_sensor_missing_dns_rcode( state = hass.states.get("sensor.backend_service_dns_response_code") assert state is None + + +async def test_diagnostic_sensors_disabled_by_default( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that diagnostic sensors are disabled by default.""" + await setup_integration(hass, mock_config_entry) + + for sensor_key in ( + "status_code", + "last_event", + "certificate_expiration", + "dns_response_code", + ): + entity_id = f"sensor.core_backend_service_{sensor_key}" + assert hass.states.get(entity_id) is None + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/hassio/test_auth.py b/tests/components/hassio/test_auth.py index 29f08d3ef66b9..86da1f215da27 100644 --- a/tests/components/hassio/test_auth.py +++ b/tests/components/hassio/test_auth.py @@ -10,7 +10,7 @@ from homeassistant.auth.providers.homeassistant import InvalidAuth from homeassistant.components.hassio.auth import HassIOBaseAuth -from homeassistant.components.hassio.const import DATA_HASSIO_SUPERVISOR_USER +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.core import HomeAssistant @@ -187,7 +187,7 @@ async def test_check_access_unix_socket_or_missing_peername( expectation: AbstractContextManager, ) -> None: """Test _check_access handles Unix socket requests and missing peername.""" - user = hass.data.get(DATA_HASSIO_SUPERVISOR_USER) + user = hass.data.get(DATA_SUPERVISOR_USER) assert user is not None auth_view = HassIOBaseAuth(hass) diff --git a/tests/components/hassio/test_config_flow.py b/tests/components/hassio/test_config_flow.py index 7905ab7c27cd6..f7336773cf22f 100644 --- a/tests/components/hassio/test_config_flow.py +++ b/tests/components/hassio/test_config_flow.py @@ -5,10 +5,10 @@ from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import ( - DATA_HASSIO_SUPERVISOR_USER, DEFAULT_UPDATE_OPTIONS, ENTRY_DATA_USER, ) +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -53,7 +53,7 @@ async def test_config_flow_uses_bootstrap_user(hass: HomeAssistant) -> None: user = await hass.auth.async_create_system_user( "Supervisor", group_ids=[GROUP_ID_ADMIN] ) - hass.data[DATA_HASSIO_SUPERVISOR_USER] = user + hass.data[DATA_SUPERVISOR_USER] = user with ( patch("homeassistant.components.hassio.async_setup", return_value=True), diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 7c57ef891cd85..c86a06e39a314 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -54,7 +54,6 @@ hostname_from_addon_slug, ) from homeassistant.components.hassio.const import ( - DATA_HASSIO_SUPERVISOR_USER, DATA_KEY_SUPERVISOR_ISSUES, DEFAULT_UPDATE_OPTIONS, ENTRY_DATA_USER, @@ -68,6 +67,7 @@ DOMAIN as HOMEASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, ) +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import HASSIO_USER_NAME @@ -368,7 +368,7 @@ async def test_setup_api_push_api_data_default( supervisor_client.homeassistant.set_options.assert_called_once_with( HomeAssistantOptions(ssl=False, port=80, refresh_token=None) ) - hassio_user = hass.data[DATA_HASSIO_SUPERVISOR_USER] + hassio_user = hass.data[DATA_SUPERVISOR_USER] assert hassio_user.system_generated assert len(hassio_user.groups) == 1 assert hassio_user.groups[0].id == GROUP_ID_ADMIN diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 7e29303b460eb..ff93c94cab6b8 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -28,6 +28,7 @@ async_sign_path, async_user_not_allowed_do_auth, ) +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.components.http.forwarded import async_setup_forwarded from homeassistant.components.http.request_context import ( current_request, @@ -766,7 +767,7 @@ async def test_unix_socket_auth_with_supervisor_user( supervisor_user = await hass.auth.async_create_system_user( HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] ) - await hass.auth.async_create_refresh_token(supervisor_user) + hass.data[DATA_SUPERVISOR_USER] = supervisor_user await async_setup_auth(hass, app) client = await aiohttp_client(app) @@ -798,16 +799,17 @@ async def test_unix_socket_auth_without_supervisor_user( assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR -async def test_unix_socket_auth_caches_user_id( +async def test_unix_socket_auth_removed_user( hass: HomeAssistant, app: web.Application, aiohttp_client: ClientSessionGenerator, ) -> None: - """Test that Unix socket auth caches the Supervisor user ID.""" + """Test that Unix socket requests fail once the Supervisor user was removed.""" supervisor_user = await hass.auth.async_create_system_user( HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] ) - await hass.auth.async_create_refresh_token(supervisor_user) + hass.data[DATA_SUPERVISOR_USER] = supervisor_user + await hass.auth.async_remove_user(supervisor_user) await async_setup_auth(hass, app) client = await aiohttp_client(app) @@ -816,20 +818,37 @@ async def test_unix_socket_auth_caches_user_id( "homeassistant.components.http.auth.is_supervisor_unix_socket_request", return_value=True, ): - # First request triggers user lookup req = await client.get("/") - assert req.status == HTTPStatus.OK - - # Second request should use cached user ID - with ( - patch( - "homeassistant.components.http.auth.is_supervisor_unix_socket_request", - return_value=True, - ), - patch.object( - hass.auth, "async_get_users", wraps=hass.auth.async_get_users - ) as mock_get_users, + assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR + + +async def test_unix_socket_auth_uses_provided_user( + hass: HomeAssistant, + app: web.Application, + aiohttp_client: ClientSessionGenerator, +) -> None: + """Test that Unix socket auth uses the user provided by hassio, not a name match. + + A stale duplicate system user named Supervisor can be left behind in the + auth store; requests must be authenticated as the user hassio actually uses. + """ + stale_user = await hass.auth.async_create_system_user( + HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] + ) + supervisor_user = await hass.auth.async_create_system_user( + HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] + ) + hass.data[DATA_SUPERVISOR_USER] = supervisor_user + + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + with patch( + "homeassistant.components.http.auth.is_supervisor_unix_socket_request", + return_value=True, ): req = await client.get("/") - assert req.status == HTTPStatus.OK - mock_get_users.assert_not_called() + assert req.status == HTTPStatus.OK + data = await req.json() + assert data["user_id"] == supervisor_user.id + assert data["user_id"] != stale_user.id diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 29e6e7bc0f1a5..5be711f3130b1 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -31,7 +31,11 @@ async_get_and_load_store, default_server_port, ) -from homeassistant.components.http.const import ENV_SETUP_PORT, ENV_SUPERVISOR +from homeassistant.components.http.const import ( + DATA_SUPERVISOR_USER, + ENV_SETUP_PORT, + ENV_SUPERVISOR, +) from homeassistant.const import EVENT_HOMEASSISTANT_STOP, HASSIO_USER_NAME, SERVER_PORT from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -958,7 +962,7 @@ async def test_unix_socket_started_with_supervisor( tmp_path: Path, ) -> None: """Test unix socket is started when running under Supervisor.""" - await hass.auth.async_create_system_user( + hass.data[DATA_SUPERVISOR_USER] = await hass.auth.async_create_system_user( HASSIO_USER_NAME, group_ids=["system-admin"] ) socket_path = tmp_path / "core.sock" @@ -1027,7 +1031,7 @@ async def test_supervisor_http_config_view( tmp_path: Path, ) -> None: """Test the HTTP config view is registered and served over the socket only.""" - await hass.auth.async_create_system_user( + hass.data[DATA_SUPERVISOR_USER] = await hass.auth.async_create_system_user( HASSIO_USER_NAME, group_ids=["system-admin"] ) socket_path = tmp_path / "core.sock" diff --git a/tests/components/indi_allsky/conftest.py b/tests/components/indi_allsky/conftest.py index 4b3ebc9e8cdf8..31d762fffd27d 100644 --- a/tests/components/indi_allsky/conftest.py +++ b/tests/components/indi_allsky/conftest.py @@ -1,14 +1,16 @@ """Common fixtures for the INDI Allsky tests.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from collections.abc import Callable, Generator +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch +from aioindiallsky import ExposureData import pytest from homeassistant.components.indi_allsky.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture(autouse=True) @@ -30,6 +32,14 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture def mock_indi_allsky_client() -> Generator[AsyncMock]: """Mock the third-party aioindiallsky client globally across coordinator and config flow.""" + callbacks: dict[str, list[Callable[..., Any]]] = {} + + def register_callback( + event_type: str, callback: Callable[..., Any] + ) -> Callable[[], None]: + callbacks.setdefault(event_type, []).append(callback) + return lambda: callbacks[event_type].remove(callback) + with ( patch( "homeassistant.components.indi_allsky.coordinator.IndiAllSkyClient", @@ -44,9 +54,22 @@ def mock_indi_allsky_client() -> Generator[AsyncMock]: client_instance.fetch_image = AsyncMock( return_value=b"\xff\xd8\xff\xe0fake_jpeg_data" ) + client_instance.connect = AsyncMock() + client_instance.listen = AsyncMock() + client_instance.disconnect = AsyncMock() + client_instance.is_connected = False + client_instance.register_callback = MagicMock(side_effect=register_callback) + client_instance.callbacks = callbacks yield client_instance +@pytest.fixture +def mock_exposure_data() -> ExposureData: + """Fixture to provide sample ExposureData from fixture JSON.""" + raw_data = load_json_object_fixture("exposure_complete.json", DOMAIN) + return ExposureData.from_dict(raw_data) + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Fixture to cleanly create an INDI Allsky configuration entry.""" diff --git a/tests/components/indi_allsky/fixtures/exposure_complete.json b/tests/components/indi_allsky/fixtures/exposure_complete.json new file mode 100644 index 0000000000000..078f1adaf3bdd --- /dev/null +++ b/tests/components/indi_allsky/fixtures/exposure_complete.json @@ -0,0 +1,13 @@ +{ + "filename": "test.jpg", + "createDate": "2026-08-13 22:53:41", + "exposure": 0.185, + "gain": 0.0, + "binmode": 1, + "night": false, + "camera_id": 1, + "id": 4718, + "temp": -273.15, + "sqm": 32928.83, + "stars": 0 +} diff --git a/tests/components/indi_allsky/snapshots/test_sensor.ambr b/tests/components/indi_allsky/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..f2b8a8a503169 --- /dev/null +++ b/tests/components/indi_allsky/snapshots/test_sensor.ambr @@ -0,0 +1,373 @@ +# serializer version: 1 +# name: test_sensor_setup_and_states[sensor.indi_allsky_binning_mode-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.indi_allsky_binning_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Binning mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Binning mode', + 'platform': 'indi_allsky', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'binmode', + 'unique_id': '1234567890abcdef1234567890abcdef_binmode', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_binning_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'INDI Allsky Binning mode', + }), + 'context': , + 'entity_id': 'sensor.indi_allsky_binning_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_exposure_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.indi_allsky_exposure_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Exposure time', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Exposure time', + 'platform': 'indi_allsky', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'exposure', + 'unique_id': '1234567890abcdef1234567890abcdef_exposure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_exposure_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'INDI Allsky Exposure time', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.indi_allsky_exposure_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_filename-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.indi_allsky_filename', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filename', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filename', + 'platform': 'indi_allsky', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filename', + 'unique_id': '1234567890abcdef1234567890abcdef_filename', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_filename-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'INDI Allsky Filename', + }), + 'context': , + 'entity_id': 'sensor.indi_allsky_filename', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_gain-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.indi_allsky_gain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Gain', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Gain', + 'platform': 'indi_allsky', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gain', + 'unique_id': '1234567890abcdef1234567890abcdef_gain', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_gain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'INDI Allsky Gain', + }), + 'context': , + 'entity_id': 'sensor.indi_allsky_gain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_sky_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.indi_allsky_sky_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sky quality', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sky quality', + 'platform': 'indi_allsky', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sqm', + 'unique_id': '1234567890abcdef1234567890abcdef_sqm', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_sky_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'INDI Allsky Sky quality', + : , + }), + 'context': , + 'entity_id': 'sensor.indi_allsky_sky_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_stars-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.indi_allsky_stars', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Stars', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stars', + 'platform': 'indi_allsky', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stars', + 'unique_id': '1234567890abcdef1234567890abcdef_stars', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_stars-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'INDI Allsky Stars', + : , + }), + 'context': , + 'entity_id': 'sensor.indi_allsky_stars', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.indi_allsky_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': 'indi_allsky', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890abcdef1234567890abcdef_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'INDI Allsky Temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.indi_allsky_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/indi_allsky/test_camera.py b/tests/components/indi_allsky/test_camera.py index 917f2615d3cda..eadb003eb6a09 100644 --- a/tests/components/indi_allsky/test_camera.py +++ b/tests/components/indi_allsky/test_camera.py @@ -1,12 +1,13 @@ """Tests for the INDI Allsky camera platform.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from aioindiallsky import IndiAllSkyError import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.camera import async_get_image +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -24,8 +25,11 @@ async def test_camera_setup_and_states( entity_registry: er.EntityRegistry, ) -> None: """Test standard successful setup and entity snapshots using snapshot_platform.""" - await setup_integration(hass, mock_config_entry) - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + with patch("homeassistant.components.indi_allsky._PLATFORMS", [Platform.CAMERA]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) @pytest.mark.parametrize( diff --git a/tests/components/indi_allsky/test_init.py b/tests/components/indi_allsky/test_init.py index 32367e5eb6025..bc1ec68442643 100644 --- a/tests/components/indi_allsky/test_init.py +++ b/tests/components/indi_allsky/test_init.py @@ -1,8 +1,9 @@ -"""Test initialization of INDI Allsky integration.""" +"""Tests for the INDI Allsky integration.""" from unittest.mock import AsyncMock from aioindiallsky import IndiAllSkyConnectionError +import pytest from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -14,29 +15,36 @@ async def test_setup_and_unload_entry( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, mock_indi_allsky_client: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test successful setup and unload of entry.""" await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED + mock_indi_allsky_client.listen.assert_called_once_with(auto_reconnect=True) await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + mock_indi_allsky_client.disconnect.assert_awaited_once() +@pytest.mark.parametrize( + "method_name", + ["fetch_image", "connect"], +) async def test_setup_failure_retry( hass: HomeAssistant, mock_indi_allsky_client: AsyncMock, mock_config_entry: MockConfigEntry, + method_name: str, ) -> None: """Test that an API connection failure during initial setup places entry in retry state.""" - mock_indi_allsky_client.fetch_image.side_effect = IndiAllSkyConnectionError( - "Cannot connect to INDI Allsky server" - ) + getattr( + mock_indi_allsky_client, method_name + ).side_effect = IndiAllSkyConnectionError("Cannot connect to INDI Allsky server") await setup_integration(hass, mock_config_entry) diff --git a/tests/components/indi_allsky/test_sensor.py b/tests/components/indi_allsky/test_sensor.py new file mode 100644 index 0000000000000..212b43f6e3c4f --- /dev/null +++ b/tests/components/indi_allsky/test_sensor.py @@ -0,0 +1,138 @@ +"""Tests for the INDI Allsky sensor platform.""" + +from dataclasses import replace +from unittest.mock import AsyncMock, patch + +from aioindiallsky import ExposureData +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNKNOWN, 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 + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "mock_indi_allsky_client" +) +async def test_sensor_setup_and_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test standard successful setup and entity snapshots using snapshot_platform.""" + with patch("homeassistant.components.indi_allsky._PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.usefixtures("mock_indi_allsky_client") +async def test_disabled_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that disabled-by-default sensors are registered as disabled.""" + with patch("homeassistant.components.indi_allsky._PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + for entity_id in ( + "sensor.indi_allsky_binning_mode", + "sensor.indi_allsky_filename", + "sensor.indi_allsky_gain", + ): + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + for entity_id in ( + "sensor.indi_allsky_exposure_time", + "sensor.indi_allsky_sky_quality", + "sensor.indi_allsky_stars", + "sensor.indi_allsky_temperature", + ): + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.disabled_by is None + + +async def test_sensor_updates( + hass: HomeAssistant, + mock_indi_allsky_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_exposure_data: ExposureData, + entity_registry: er.EntityRegistry, +) -> None: + """Test sensor state values update on exposure_complete event.""" + # Enable disabled sensors for testing + entity_registry.async_get_or_create( + domain="sensor", + platform="indi_allsky", + unique_id=f"{mock_config_entry.entry_id}_binmode", + suggested_object_id="indi_allsky_binning_mode", + disabled_by=None, + ) + entity_registry.async_get_or_create( + domain="sensor", + platform="indi_allsky", + unique_id=f"{mock_config_entry.entry_id}_filename", + suggested_object_id="indi_allsky_filename", + disabled_by=None, + ) + entity_registry.async_get_or_create( + domain="sensor", + platform="indi_allsky", + unique_id=f"{mock_config_entry.entry_id}_gain", + suggested_object_id="indi_allsky_gain", + disabled_by=None, + ) + + with patch("homeassistant.components.indi_allsky._PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + for callback in mock_indi_allsky_client.callbacks.get("exposure_complete", []): + callback(mock_exposure_data) + await hass.async_block_till_done() + + state = hass.states.get("sensor.indi_allsky_exposure_time") + assert state is not None + assert state.state == "0.185" + + state = hass.states.get("sensor.indi_allsky_temperature") + assert state is not None + assert state.state == STATE_UNKNOWN + + state = hass.states.get("sensor.indi_allsky_sky_quality") + assert state is not None + assert state.state == "32928.83" + + state = hass.states.get("sensor.indi_allsky_stars") + assert state is not None + assert state.state == "0" + + state = hass.states.get("sensor.indi_allsky_binning_mode") + assert state is not None + assert state.state == "1" + + state = hass.states.get("sensor.indi_allsky_filename") + assert state is not None + assert state.state == "test.jpg" + + state = hass.states.get("sensor.indi_allsky_gain") + assert state is not None + assert state.state == "0.0" + + for callback in mock_indi_allsky_client.callbacks.get("exposure_complete", []): + callback(replace(mock_exposure_data, temp=12.5)) + await hass.async_block_till_done() + + state = hass.states.get("sensor.indi_allsky_temperature") + assert state is not None + assert state.state == "12.5" diff --git a/tests/components/irm_kmi/test_init.py b/tests/components/irm_kmi/test_init.py index 8adedfdda3895..82e628d055dd3 100644 --- a/tests/components/irm_kmi/test_init.py +++ b/tests/components/irm_kmi/test_init.py @@ -1,16 +1,20 @@ """Tests for the IRM KMI integration.""" -from unittest.mock import MagicMock +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock +from freezegun.api import FrozenDateTimeFactory from irm_kmi_api import IrmKmiApiError import pytest from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from . import setup_integration +from .const import WEATHER_ENTITY_ID -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.usefixtures("mock_irm_kmi_api") @@ -33,6 +37,7 @@ async def test_config_entry_not_ready( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_irm_kmi_api: MagicMock, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the IRM KMI configuration entry not ready.""" mock_irm_kmi_api.refresh_forecasts_coord.side_effect = IrmKmiApiError @@ -41,3 +46,75 @@ async def test_config_entry_not_ready( assert mock_irm_kmi_api.refresh_forecasts_coord.call_count == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + # The first refresh has no last success time to compare the grace period to + assert "Unexpected error fetching" not in caplog.text + + +@pytest.mark.freeze_time("2023-12-28T15:30:00+01:00") +async def test_entity_unavailable_after_grace_period( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_get_forecasts_coord: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that the last known data is only served for the grace period.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(WEATHER_ENTITY_ID).state != STATE_UNAVAILABLE + + mock_get_forecasts_coord.side_effect = IrmKmiApiError + + freezer.tick(timedelta(minutes=8)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(WEATHER_ENTITY_ID).state != STATE_UNAVAILABLE + + freezer.tick(timedelta(minutes=7)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(WEATHER_ENTITY_ID).state != STATE_UNAVAILABLE + + freezer.tick(timedelta(minutes=7)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(WEATHER_ENTITY_ID).state == STATE_UNAVAILABLE + + mock_get_forecasts_coord.side_effect = None + + freezer.tick(timedelta(minutes=8)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(WEATHER_ENTITY_ID).state != STATE_UNAVAILABLE + + +@pytest.mark.freeze_time("2023-12-28T15:30:00+01:00") +async def test_grace_period_starts_at_the_last_usable_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_irm_kmi_api: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the grace period runs from the last refresh that produced data.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(WEATHER_ENTITY_ID).state != STATE_UNAVAILABLE + + mock_irm_kmi_api.get_daily_forecast.side_effect = TypeError + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(WEATHER_ENTITY_ID).state == STATE_UNAVAILABLE + + mock_irm_kmi_api.refresh_forecasts_coord.side_effect = IrmKmiApiError + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(WEATHER_ENTITY_ID).state == STATE_UNAVAILABLE diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index 5f09a62f413aa..c823f66f94b40 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -17,9 +17,11 @@ ) from homeassistant.components.knx.storage.const import CONF_DATA from homeassistant.components.knx.storage.entity_store_schema import ( + BaseEntityConfig, BinarySensorKnxConfig, DateKnxConfig, DatetimeKnxConfig, + KnxEntityData, NotifyKnxConfig, NumberKnxConfig, SceneKnxConfig, @@ -751,7 +753,7 @@ async def test_load_applies_schema_defaults_and_coercion( ) assert hass.states.get("light.missing_defaults") is not None config_store = hass.data[KNX_MODULE_KEY].config_store - light_config = config_store.get_entity_configs(Platform.LIGHT)[LIGHT_UID][DOMAIN] + light_config = config_store.get_entity_configs(Platform.LIGHT)[LIGHT_UID].knx assert light_config["color_temp_min"] == 2700 assert light_config["color_temp_max"] == 6000 @@ -980,16 +982,23 @@ def test_typed_config_storage_roundtrip( validated = validate_entity_data( {CONF_PLATFORM: platform, CONF_DATA: {"entity": entity_input, "knx": knx_input}} )[CONF_DATA] - assert isinstance(validated[DOMAIN], config_type) + assert isinstance(validated, KnxEntityData) + assert validated.entity == BaseEntityConfig(name="test") + assert isinstance(validated.knx, config_type) stored = to_storage_dict(validated) + assert stored["entity"] == { + "name": "test", + "device_info": None, + "entity_category": None, + } assert stored["knx"] == knx_stored assert json.loads(json.dumps(stored)) == stored # storage is JSON reloaded = validate_entity_data({CONF_PLATFORM: platform, CONF_DATA: stored})[ CONF_DATA ] - assert reloaded[DOMAIN] == validated[DOMAIN] + assert reloaded == validated assert to_storage_dict(reloaded) == stored diff --git a/tests/components/lawn_mower/test_llm.py b/tests/components/lawn_mower/test_llm.py index a16946ca4c765..d50607bae5965 100644 --- a/tests/components/lawn_mower/test_llm.py +++ b/tests/components/lawn_mower/test_llm.py @@ -44,7 +44,23 @@ async def _tool_names(hass: HomeAssistant) -> set[str]: async def test_intent_tool_exposed(hass: HomeAssistant) -> None: """Test the intent tool is offered for an exposed lawn_mower entity.""" - assert await _tool_names(hass) >= TOOL_NAMES + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + tools = {tool.name: tool for tool in result.tools} + assert tools.keys() >= TOOL_NAMES + + annotations = llm.ToolAnnotations(idempotent=True, open_world=False) + assert { + name: (tool.title, tool.integration, tool.annotations) + for name, tool in tools.items() + if name in TOOL_NAMES + } == { + "lawn_mower__HassLawnMowerDock": ("Dock lawn mower", "lawn_mower", annotations), + "lawn_mower__HassLawnMowerStartMowing": ( + "Start mowing", + "lawn_mower", + annotations, + ), + } async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: diff --git a/tests/components/librenms/conftest.py b/tests/components/librenms/conftest.py index 1bbf4b39e0b7e..645bcc4453523 100644 --- a/tests/components/librenms/conftest.py +++ b/tests/components/librenms/conftest.py @@ -58,7 +58,7 @@ async def mock_librenms( """Mock the LibreNMS API.""" with ( patch( - "homeassistant.components.librenms.coordinator.Librenms", autospec=True + "homeassistant.components.librenms.Librenms", autospec=True ) as mock_librenms, patch( "homeassistant.components.librenms.config_flow.Librenms", new=mock_librenms diff --git a/tests/components/librenms/snapshots/test_sensor.ambr b/tests/components/librenms/snapshots/test_sensor.ambr index 95e64f0e5ef76..c20eeb065d283 100644 --- a/tests/components/librenms/snapshots/test_sensor.ambr +++ b/tests/components/librenms/snapshots/test_sensor.ambr @@ -1,6 +1,118 @@ # serializer version: 1 # name: test_sensors.12 list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': 'https://librenms/device/1', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'librenms', + '01KXX1E2EMMSCDQ2K4A0C7JA9T_1', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': 'SFVH_KV01_SFOS', + 'model_id': None, + 'name': 'SophosXG', + 'name_by_user': None, + 'serial_number': 'C01yyyyyyxxxxxx', + 'sw_version': '22.0.1 MR-1-Build490', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': 'https://librenms/device/3', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'librenms', + '01KXX1E2EMMSCDQ2K4A0C7JA9T_3', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': 'MC2425adw', + 'model_id': None, + 'name': 'Drucker', + 'name_by_user': None, + 'serial_number': '7529936145YFW', + 'sw_version': 'CXNZJ.250.038', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': 'https://librenms/device/13', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'librenms', + '01KXX1E2EMMSCDQ2K4A0C7JA9T_13', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': 'QEMU Standard PC (i440FX + PIIX, 1996)', + 'model_id': None, + 'name': 'homeassistant', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': '6.18.37-haos (Home Assistant OS 18.1)', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': 'https://librenms/device/29', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'librenms', + '01KXX1E2EMMSCDQ2K4A0C7JA9T_29', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'firmware.esphome.io (104.21.87.21)', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entry_id': , diff --git a/tests/components/light/test_llm.py b/tests/components/light/test_llm.py index 3352e278260e1..d6f282da78300 100644 --- a/tests/components/light/test_llm.py +++ b/tests/components/light/test_llm.py @@ -43,7 +43,14 @@ async def _tool_names(hass: HomeAssistant) -> set[str]: async def test_intent_tool_exposed(hass: HomeAssistant) -> None: """Test the intent tool is offered for an exposed light entity.""" - assert "light__HassLightSet" in await _tool_names(hass) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + tools = {tool.name: tool for tool in result.tools} + assert "light__HassLightSet" in tools + + tool = tools["light__HassLightSet"] + assert tool.title == "Set light" + assert tool.integration == "light" + assert tool.annotations == llm.ToolAnnotations(idempotent=True, open_world=False) async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: diff --git a/tests/components/media_player/test_llm.py b/tests/components/media_player/test_llm.py index fcefaa14d9e36..559cd92b7f3e2 100644 --- a/tests/components/media_player/test_llm.py +++ b/tests/components/media_player/test_llm.py @@ -54,7 +54,39 @@ async def _tool_names(hass: HomeAssistant) -> set[str]: async def test_intent_tool_exposed(hass: HomeAssistant) -> None: """Test the intent tool is offered for an exposed media_player entity.""" - assert await _tool_names(hass) >= TOOL_NAMES + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + tools = {tool.name: tool for tool in result.tools} + assert tools.keys() >= TOOL_NAMES + + control = llm.ToolAnnotations(idempotent=True, open_world=False) + repeats = llm.ToolAnnotations(open_world=False) + assert { + name: (tool.title, tool.integration, tool.annotations) + for name, tool in tools.items() + if name in TOOL_NAMES + } == { + "media_player__HassMediaNext": ("Next track", "media_player", repeats), + "media_player__HassMediaPause": ("Pause media", "media_player", control), + "media_player__HassMediaPlayerMute": ("Mute player", "media_player", control), + "media_player__HassMediaPlayerUnmute": ( + "Unmute player", + "media_player", + control, + ), + "media_player__HassMediaPrevious": ("Previous track", "media_player", repeats), + "media_player__HassMediaSearchAndPlay": ( + "Search and play media", + "media_player", + llm.ToolAnnotations(), + ), + "media_player__HassMediaUnpause": ("Resume media", "media_player", repeats), + "media_player__HassSetVolume": ("Set volume", "media_player", control), + "media_player__HassSetVolumeRelative": ( + "Change volume", + "media_player", + repeats, + ), + } async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: diff --git a/tests/components/switchbot/test_cover.py b/tests/components/switchbot/test_cover.py index 6025464a1182e..76131bf38e8ac 100644 --- a/tests/components/switchbot/test_cover.py +++ b/tests/components/switchbot/test_cover.py @@ -15,17 +15,21 @@ ATTR_TILT_POSITION, DOMAIN as COVER_DOMAIN, CoverEntityCapabilityAttribute, + CoverEntityFeature, CoverState, ) from homeassistant.components.switchbot.const import ( CONF_CURTAIN_SPEED, CONF_RETRY_COUNT, + CURTAIN_SPEED_NORMAL, + CURTAIN_SPEED_SILENT, DEFAULT_RETRY_COUNT, ROLLER_SHADE_SPEED_PERFORMANCE, ROLLER_SHADE_SPEED_QUIET, ) from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, @@ -176,7 +180,7 @@ async def test_curtain3_controlling( ) await hass.async_block_till_done() - mock_set_position.assert_awaited_once() + mock_set_position.assert_awaited_once_with(50, 255) # Default speed state = hass.states.get(entity_id) assert state.state == CoverState.OPEN assert state.attributes[ATTR_CURRENT_POSITION] == 60 @@ -231,6 +235,116 @@ async def test_curtain3_custom_speed_controlling( mock_close.assert_awaited_once_with(50) +@pytest.mark.parametrize( + ("speed", "expected_value"), + [ + pytest.param(CURTAIN_SPEED_NORMAL, 255, id="normal"), + pytest.param(CURTAIN_SPEED_SILENT, 1, id="silent"), + ], +) +async def test_curtain3_speed( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + speed: str, + expected_value: int, +) -> None: + """Test the curtain forwards the requested cover speed to the device.""" + inject_bluetooth_service_info(hass, WOCURTAIN3_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="curtain") + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.open", + new=AsyncMock(return_value=True), + ) as mock_open, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.close", + new=AsyncMock(return_value=True), + ) as mock_close, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.set_position", + new=AsyncMock(return_value=True), + ) as mock_set_position, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "cover.test_name" + + state = hass.states.get(entity_id) + assert state.attributes[CoverEntityCapabilityAttribute.SUPPORTED_SPEEDS] == [ + CURTAIN_SPEED_NORMAL, + CURTAIN_SPEED_SILENT, + ] + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: speed}, + blocking=True, + ) + mock_open.assert_awaited_once_with(expected_value) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: speed}, + blocking=True, + ) + mock_close.assert_awaited_once_with(expected_value) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50, ATTR_SPEED: speed}, + blocking=True, + ) + mock_set_position.assert_awaited_once_with(50, expected_value) + + +async def test_curtain_speed_not_supported( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test a non-Curtain-3 curtain does not expose the speed feature.""" + # A Curtain (not Curtain 3) advertises the "c" device type in its service data. + inject_bluetooth_service_info( + hass, + make_advertisement( + "AA:BB:CC:DD:EE:FF", + b"\xcf;Zwu\x0c\x19\x0b\x00\x11D\x006", + b"c\xc06\x00\x11D", + ), + ) + + entry = mock_entry_factory(sensor_type="curtain") + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.open", + new=AsyncMock(return_value=True), + ) as mock_open: + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "cover.test_name" + + state = hass.states.get(entity_id) + assert not state.attributes[ATTR_SUPPORTED_FEATURES] & CoverEntityFeature.SPEED + assert CoverEntityCapabilityAttribute.SUPPORTED_SPEEDS not in state.attributes + + # A speed passed to a model that does not support it is ignored and the + # curtain moves at the default speed. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: CURTAIN_SPEED_SILENT}, + blocking=True, + ) + mock_open.assert_awaited_once_with(255) + + async def test_blindtilt_setup( hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] ) -> None: diff --git a/tests/components/teslemetry/test_config_flow.py b/tests/components/teslemetry/test_config_flow.py index 50db98dd7ed8b..f48de8cfb5d1a 100644 --- a/tests/components/teslemetry/test_config_flow.py +++ b/tests/components/teslemetry/test_config_flow.py @@ -2195,6 +2195,11 @@ async def test_add_authorized_client_failure_aborts(hass: HomeAssistant) -> None [ pytest.param(InvalidResponse(), "cannot_connect", id="lookup_failure"), pytest.param(_empty_clients(), "key_not_registered", id="key_not_registered"), + pytest.param( + _own_key_clients(AuthorizedClientState.PENDING_VERIFICATION_TIMEOUT), + "key_expired", + id="key_expired", + ), pytest.param(_own_key_clients("gremlin"), "cannot_connect", id="unknown_state"), ], ) @@ -2228,6 +2233,69 @@ async def test_pair_step_second_lookup_errors( assert result["errors"] == {"base": expected_error} +@pytest.mark.usefixtures("mock_rsa_key") +async def test_pair_step_timeout_retry_reopens_window_and_succeeds( + hass: HomeAssistant, +) -> None: + """Submitting the expired form reopens the window and pairing can complete.""" + entry = await _setup_account_no_subentry(hass) + + client = _mock_powerwall_client() + with ( + patch( + "tesla_fleet_api.teslemetry.energysite.TeslemetryEnergySite.find_authorized_clients", + new=AsyncMock( + side_effect=[ + _empty_clients(), + _own_key_clients( + AuthorizedClientState.PENDING_VERIFICATION_TIMEOUT + ), + _own_key_clients( + AuthorizedClientState.PENDING_VERIFICATION_TIMEOUT + ), + _own_key_clients(AuthorizedClientState.VERIFIED), + ] + ), + ), + patch( + "tesla_fleet_api.teslemetry.energysite.TeslemetryEnergySite.add_authorized_client", + new=AsyncMock(), + ) as mock_add, + patch( + "homeassistant.components.teslemetry.config_flow.PowerwallClient", + return_value=client, + ), + patch.object(hass.config_entries, "async_schedule_reload"), + ): + result = await _start_add_flow_select_site(hass, entry) + assert result["step_id"] == "pair" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["step_id"] == "pair" + assert result["errors"] == {"base": "key_expired"} + + # Submitting the expired form re-registers to open a fresh window. + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["step_id"] == "pair" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["step_id"] == "credentials" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {CONF_HOST: HOST, CONF_PASSWORD: PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_add.await_count == 2 + + @pytest.mark.usefixtures("mock_rsa_key") @pytest.mark.parametrize( ("patch_target", "error"), diff --git a/tests/components/text/test_init.py b/tests/components/text/test_init.py index 1dda5c3fb7715..f1e809b5e20be 100644 --- a/tests/components/text/test_init.py +++ b/tests/components/text/test_init.py @@ -13,8 +13,8 @@ DOMAIN, SERVICE_SET_VALUE, TextMode, - _async_set_value, ) +from homeassistant.components.text.services import _async_set_value from homeassistant.const import MAX_LENGTH_STATE_STATE from homeassistant.core import HomeAssistant, ServiceCall, State from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index 1e9cd3b0fb800..a97e7d8c8535f 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -7,6 +7,7 @@ import pytest from homeassistant.auth.providers.homeassistant import HassAuthProvider +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.components.websocket_api import DOMAIN from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, @@ -436,8 +437,8 @@ async def test_unix_socket_auth_bypass( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> None: """Test that Unix socket connections skip websocket auth phase.""" - # Create the Supervisor system user - await hass.auth.async_create_system_user( + # Create the Supervisor system user, as the hassio integration would + hass.data[DATA_SUPERVISOR_USER] = await hass.auth.async_create_system_user( HASSIO_USER_NAME, group_ids=["system-admin"] )