diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 88a8b1d541526..a58509781e20e 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -344,6 +344,8 @@ async def async_link_user( async def async_remove_user(self, user: models.User) -> None: """Remove a user.""" + if user.is_owner: + raise ValueError("Unable to remove the owner") tasks = [ self.async_remove_credentials(credentials) for credentials in user.credentials diff --git a/homeassistant/components/besen/manifest.json b/homeassistant/components/besen/manifest.json index f575d9b0f4dde..f7f2187dc8136 100644 --- a/homeassistant/components/besen/manifest.json +++ b/homeassistant/components/besen/manifest.json @@ -14,5 +14,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["besen==0.4.2"] + "requirements": ["besen==0.4.5"] } diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py index 8b6552fe3f983..75b0c88b916db 100644 --- a/homeassistant/components/config/auth.py +++ b/homeassistant/components/config/auth.py @@ -58,6 +58,24 @@ async def websocket_delete( ) return + if user.system_generated: + connection.send_message( + websocket_api.error_message( + msg["id"], + "cannot_modify_system_generated", + "Unable to delete system generated users.", + ) + ) + return + + if user.is_owner: + connection.send_message( + websocket_api.error_message( + msg["id"], "cannot_delete_owner", "Unable to delete the owner" + ) + ) + return + await hass.auth.async_remove_user(user) connection.send_message(websocket_api.result_message(msg["id"])) diff --git a/homeassistant/components/config/auth_provider_homeassistant.py b/homeassistant/components/config/auth_provider_homeassistant.py index 9fa1b952978b3..e8b5396c73c69 100644 --- a/homeassistant/components/config/auth_provider_homeassistant.py +++ b/homeassistant/components/config/auth_provider_homeassistant.py @@ -83,6 +83,15 @@ async def websocket_delete( # if not new, an existing credential exists. # Removing the credential will also remove the auth. if not credentials.is_new: + user = await hass.auth.async_get_user_by_credentials(credentials) + if user is not None and user.is_owner: + connection.send_error( + msg["id"], + "cannot_delete_owner_credentials", + "Unable to delete the credentials of the owner", + ) + return + await hass.auth.async_remove_credentials(credentials) connection.send_result(msg["id"]) diff --git a/homeassistant/components/glances/__init__.py b/homeassistant/components/glances/__init__.py index 44460ed1928b2..ebeff5aa20e0a 100644 --- a/homeassistant/components/glances/__init__.py +++ b/homeassistant/components/glances/__init__.py @@ -26,8 +26,9 @@ ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.httpx_client import create_async_httpx_client +from .const import DEFAULT_TIMEOUT from .coordinator import GlancesConfigEntry, GlancesDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -65,7 +66,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: GlancesConfigEntry) -> async def get_api(hass: HomeAssistant, entry_data: dict[str, Any]) -> Glances: """Return the api from glances_api.""" - httpx_client = get_async_client(hass, verify_ssl=entry_data[CONF_VERIFY_SSL]) + # The shared httpx client cannot be used because its 5-second timeout + # is too short for slow Glances hosts. + httpx_client = create_async_httpx_client( + hass, verify_ssl=entry_data[CONF_VERIFY_SSL], timeout=DEFAULT_TIMEOUT + ) for version in (4, 3): api = Glances( host=entry_data[CONF_HOST], diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py index 6831ccb9e3b64..63e0bcb8c0fbb 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -9,5 +9,6 @@ DEFAULT_HOST = "localhost" DEFAULT_PORT = 61208 DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) +DEFAULT_TIMEOUT = 30 CPU_ICON = f"mdi:cpu-{64 if sys.maxsize > 2**32 else 32}-bit" diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index d95ac1310bb89..d2d5383c3f255 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -47,7 +47,7 @@ async def _async_update_data(self) -> dict[str, Any]: except exceptions.GlancesApiAuthorizationError as err: raise ConfigEntryAuthFailed from err except exceptions.GlancesApiError as err: - raise UpdateFailed from err + raise UpdateFailed(str(err)) from err # Update computed values uptime: datetime | None = None up_duration: timedelta | None = None diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 05a6ab14456e7..d32622645ed80 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.16.0"] + "requirements": ["homematicip==2.17.0"] } diff --git a/homeassistant/components/ipp/icons.json b/homeassistant/components/ipp/icons.json index 1469ba0151836..555220f57eba6 100644 --- a/homeassistant/components/ipp/icons.json +++ b/homeassistant/components/ipp/icons.json @@ -1,9 +1,24 @@ { "entity": { "sensor": { + "impressions_completed": { + "default": "mdi:file-document-outline" + }, + "impressions_completed_full_color": { + "default": "mdi:file-document-outline" + }, + "impressions_completed_monochrome": { + "default": "mdi:file-document-outline" + }, "marker": { "default": "mdi:water" }, + "media_sheets_completed": { + "default": "mdi:file-document-outline" + }, + "pages_completed": { + "default": "mdi:file-document-outline" + }, "printer": { "default": "mdi:printer" }, diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py index e3cc30dc5e046..c437790c1b455 100644 --- a/homeassistant/components/ipp/sensor.py +++ b/homeassistant/components/ipp/sensor.py @@ -39,6 +39,7 @@ class IPPSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[Printer], StateType | datetime] attributes_fn: Callable[[Printer], dict[Any, StateType]] = lambda _: {} + exists_fn: Callable[[Printer], bool] = lambda _: True def _get_marker_attributes_fn( @@ -81,6 +82,59 @@ def _get_marker_value_fn( ), ) +PAGE_COUNT_SENSORS: tuple[IPPSensorEntityDescription, ...] = ( + IPPSensorEntityDescription( + key="pages_completed", + translation_key="pages_completed", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + exists_fn=lambda printer: "pages_completed" in printer.counters.supported, + value_fn=lambda printer: printer.counters.pages_completed, + ), + IPPSensorEntityDescription( + key="impressions_completed", + translation_key="impressions_completed", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + exists_fn=lambda printer: "impressions_completed" in printer.counters.supported, + value_fn=lambda printer: printer.counters.impressions_completed, + ), + IPPSensorEntityDescription( + key="media_sheets_completed", + translation_key="media_sheets_completed", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + exists_fn=lambda printer: ( + "media_sheets_completed" in printer.counters.supported + ), + value_fn=lambda printer: printer.counters.media_sheets_completed, + ), + IPPSensorEntityDescription( + key="impressions_completed_monochrome", + translation_key="impressions_completed_monochrome", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + exists_fn=lambda printer: ( + "monochrome" in printer.counters.impressions_completed_col + ), + value_fn=lambda printer: printer.counters.impressions_completed_col.get( + "monochrome" + ), + ), + IPPSensorEntityDescription( + key="impressions_completed_full_color", + translation_key="impressions_completed_full_color", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + exists_fn=lambda printer: ( + "full-color" in printer.counters.impressions_completed_col + ), + value_fn=lambda printer: printer.counters.impressions_completed_col.get( + "full-color" + ), + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -123,6 +177,12 @@ async def async_setup_entry( ) ) + sensors.extend( + IPPSensor(coordinator, description) + for description in PAGE_COUNT_SENSORS + if description.exists_fn(coordinator.data) + ) + async_add_entities(sensors, True) diff --git a/homeassistant/components/ipp/strings.json b/homeassistant/components/ipp/strings.json index 2bb9f08a701c1..f94fd835d2fd0 100644 --- a/homeassistant/components/ipp/strings.json +++ b/homeassistant/components/ipp/strings.json @@ -34,6 +34,26 @@ }, "entity": { "sensor": { + "impressions_completed": { + "name": "Impressions completed", + "unit_of_measurement": "impressions" + }, + "impressions_completed_full_color": { + "name": "Color impressions completed", + "unit_of_measurement": "[%key:component::ipp::entity::sensor::impressions_completed::unit_of_measurement%]" + }, + "impressions_completed_monochrome": { + "name": "Monochrome impressions completed", + "unit_of_measurement": "[%key:component::ipp::entity::sensor::impressions_completed::unit_of_measurement%]" + }, + "media_sheets_completed": { + "name": "Media sheets completed", + "unit_of_measurement": "sheets" + }, + "pages_completed": { + "name": "Pages completed", + "unit_of_measurement": "pages" + }, "printer": { "state": { "idle": "[%key:common::state::idle%]", diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index 28d326f4fb215..c879fbde673fc 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -80,6 +80,8 @@ KEY_ACTIONS = "actions" KEY_STATUS = "status" +EVENT_ISY994_CONTROL = "isy994_control" + NODE_PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, @@ -106,10 +108,15 @@ ROOT_NODE_PLATFORMS = [Platform.BUTTON] VARIABLE_PLATFORMS = [Platform.NUMBER, Platform.SENSOR] +# Platforms that classify in parallel with NODE_PLATFORMS — a node placed in +# one of these still falls through to its primary platform classification. +NODE_PARALLEL_PLATFORMS = [Platform.EVENT] + # Set of all platforms used by integration PLATFORMS = { *NODE_PLATFORMS, *NODE_AUX_PROP_PLATFORMS, + *NODE_PARALLEL_PLATFORMS, *PROGRAM_PLATFORMS, *ROOT_NODE_PLATFORMS, *VARIABLE_PLATFORMS, @@ -315,6 +322,47 @@ FILTER_INSTEON_TYPE: ["4.8", TYPE_CATEGORY_CLIMATE], FILTER_ZWAVE_CAT: ["140"], }, + # Additive: a node matching here still gets its primary classification. + Platform.EVENT: { + FILTER_UOM: [], + FILTER_STATES: [], + FILTER_NODE_DEF_ID: [ + "BallastRelayLampSwitch", + "BallastRelayLampSwitch_ADV", + "DimmerLampSwitch", + "DimmerLampSwitch_ADV", + "DimmerSwitchOnly", + "DimmerSwitchOnly_ADV", + "KeypadButton", + "KeypadButton_ADV", + "KeypadDimmer", + "KeypadDimmer_ADV", + "KeypadRelay", + "KeypadRelay_ADV", + "RelayLampOnly", + "RelayLampOnly_ADV", + "RelayLampSwitch", + "RelayLampSwitch_ADV", + "RelaySwitchOnlyPlusQuery", + "RelaySwitchOnlyPlusQuery_ADV", + ], + # Type prefixes derived from observed eisy node families + # (SwitchLinc / KeypadLinc / InLineLinc / BallastLinc) — catches + # legacy non-_ADV firmware variants of the same hardware. + FILTER_INSTEON_TYPE: [ + "1.14.", + "1.32.", + "1.45.", + "1.65.", + "1.66.", + "2.42.", + "2.44.", + "2.55.", + "2.57.", + "3.32.", + ], + FILTER_ZWAVE_CAT: [], + }, } NODE_AUX_FILTERS: dict[str, Platform] = { PROP_ON_LEVEL: Platform.NUMBER, diff --git a/homeassistant/components/isy994/entity.py b/homeassistant/components/isy994/entity.py index 6195fae62260a..110c8d112cb82 100644 --- a/homeassistant/components/isy994/entity.py +++ b/homeassistant/components/isy994/entity.py @@ -25,7 +25,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription -from .const import DOMAIN +from .const import DOMAIN, EVENT_ISY994_CONTROL class ISYEntity(Entity): @@ -82,7 +82,7 @@ def async_on_control(self, event: NodeProperty) -> None: # New state attributes may be available, update the state. self.async_write_ha_state() - self.hass.bus.async_fire("isy994_control", event_data) + self.hass.bus.async_fire(EVENT_ISY994_CONTROL, event_data) class ISYNodeEntity(ISYEntity): diff --git a/homeassistant/components/isy994/event.py b/homeassistant/components/isy994/event.py new file mode 100644 index 0000000000000..7d363b150a1a0 --- /dev/null +++ b/homeassistant/components/isy994/event.py @@ -0,0 +1,205 @@ +"""Event entities for ISY Insteon load and keypad-button nodes. + +Each entity represents a physical button on the device and emits one of the +standard button event types (architecture#1377) when its corresponding +control event arrives from the ISY. +""" + +from typing import TYPE_CHECKING, Final, NamedTuple, override + +from pyisy.constants import ( + ATTR_ACTION, + CMD_FADE_DOWN, + CMD_FADE_STOP, + CMD_FADE_UP, + CMD_OFF, + CMD_OFF_FAST, + CMD_ON, + CMD_ON_FAST, + ES_CONNECTED, + NC_NODE_ENABLED, + TAG_ADDRESS, +) +from pyisy.helpers import NodeProperty +from pyisy.nodes import Node, NodeChangedEvent + +from homeassistant.components.event import ( + ATTR_MULTI_PRESS_COUNT, + ButtonEventType, + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import ISYNodeEntity + +if TYPE_CHECKING: + from .models import IsyConfigEntry + +EVENT_BUTTON_UNIQUE_ID_SUFFIX = "_button" + +ATTR_DIRECTION = "direction" + +DIRECTION_UP = "up" +DIRECTION_DOWN = "down" + + +class _ControlEvent(NamedTuple): + """Standard event type + direction/count for one ISY control command.""" + + event_type: ButtonEventType + direction: str + multi_press_count: int | None = None + + +# Maps to the architecture#1377 standard button event types. `direction` +# distinguishes the two paddle positions of a single physical button rather +# than splitting into two entities. CMD_FADE_STOP +# (long-press end) isn't listed here: its direction is whichever fade most +# recently started, tracked in `ISYButtonEvent._last_fade_direction`. +CONTROL_TO_EVENT: Final[dict[str, _ControlEvent]] = { + CMD_ON: _ControlEvent(ButtonEventType.PRESS_END, DIRECTION_UP), + CMD_OFF: _ControlEvent(ButtonEventType.PRESS_END, DIRECTION_DOWN), + CMD_ON_FAST: _ControlEvent(ButtonEventType.MULTI_PRESS_END, DIRECTION_UP, 2), + CMD_OFF_FAST: _ControlEvent(ButtonEventType.MULTI_PRESS_END, DIRECTION_DOWN, 2), + CMD_FADE_UP: _ControlEvent(ButtonEventType.LONG_PRESS_START, DIRECTION_UP), + CMD_FADE_DOWN: _ControlEvent(ButtonEventType.LONG_PRESS_START, DIRECTION_DOWN), +} + +BUTTON_DESCRIPTION: Final[EventEntityDescription] = EventEntityDescription( + key="button", + translation_key="button", + device_class=EventDeviceClass.BUTTON, + event_types=[ + ButtonEventType.PRESS_END, + ButtonEventType.MULTI_PRESS_END, + ButtonEventType.LONG_PRESS_START, + ButtonEventType.LONG_PRESS_END, + ], +) + + +def _sub_button_name(node: Node) -> str: + """Return the sub-button label with the parent device prefix stripped. + + ISY users commonly label KeypadLinc sub-buttons as ``" "`` + (e.g. ``"Hallway Keypad B"``), which would render as ``"Hallway Keypad + Hallway Keypad B"`` under ``has_entity_name=True``. Falls back to the raw + node name when the prefix doesn't match. The label is user-supplied in the + ISY admin console and is not translatable. + """ + parent_name: str = node.parent_node.name + name: str = node.name + if name.startswith(parent_name) and ( + len(name) == len(parent_name) or name[len(parent_name)] in " -_:." + ): + return name[len(parent_name) :].lstrip(" -_:.") or name + return name + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IsyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the ISY event platform.""" + isy_data = entry.runtime_data + device_info = isy_data.devices + async_add_entities( + ISYButtonEvent(node, device_info.get(node.primary_node)) + for node in isy_data.nodes[Platform.EVENT] + ) + + +class ISYButtonEvent(ISYNodeEntity, EventEntity): + """Event entity that emits press/fast/fade events from an ISY node.""" + + entity_description = BUTTON_DESCRIPTION + _attr_has_entity_name = True + + def __init__(self, node: Node, device_info: DeviceInfo | None = None) -> None: + """Initialize the ISY button event entity.""" + super().__init__(node, device_info=device_info) + self._attr_unique_id = ( + f"{node.isy.uuid}_{node.address}{EVENT_BUTTON_UNIQUE_ID_SUFFIX}" + ) + self._last_fade_direction: str | None = None + if node.parent_node is None: + self._attr_name = None + else: + self._attr_name = _sub_button_name(node) + # Disabled by default — a typical KeypadLinc exposes 6-8 of + # these and most users only automate a few. + self._attr_entity_registry_enabled_default = False + + @override + # pylint: disable-next=home-assistant-missing-super-call + async def async_added_to_hass(self) -> None: + """Subscribe to control events and node enabled/disabled changes only. + + Skipping the base class's status_events subscription avoids a state + write on every value update; availability still tracks the node's + enabled flag via a filtered subscription. + """ + self._control_handler = self._node.control_events.subscribe( + self.async_on_control + ) + self.async_on_remove(self._control_handler.unsubscribe) + self._change_handler = self._node.isy.nodes.status_events.subscribe( + self._async_on_availability_change, + event_filter={ + TAG_ADDRESS: self._node.address, + ATTR_ACTION: NC_NODE_ENABLED, + }, + key=self.unique_id, + ) + self.async_on_remove(self._change_handler.unsubscribe) + + @callback + def _async_on_availability_change(self, event: NodeChangedEvent, key: str) -> None: + """Refresh state when the node is enabled or disabled.""" + self.async_write_ha_state() + + @callback + @override + def async_on_control(self, event: NodeProperty) -> None: + """Trigger the entity, bypassing the base class's bus.fire. + + The load entity for the same node still fires `isy994_control` via + the base class, so we don't fire it here to avoid double-emission. + Suppressed while the websocket isn't fully connected -- PyISY + replays the current status of every node on (re)connect before + settling, and without this guard that replay fires stale button + events on every startup, config-entry reload, and reconnect. + """ + websocket = self._node.isy.websocket + if websocket is not None and websocket.status != ES_CONNECTED: + return + if event.control == CMD_FADE_STOP: + # FADE_STOP carries no direction of its own, so it consumes the + # one from the fade it ends; a stop with no preceding start + # reports no direction rather than a stale one. + direction = self._last_fade_direction + self._last_fade_direction = None + self._trigger_event( + ButtonEventType.LONG_PRESS_END, + {ATTR_DIRECTION: direction} if direction is not None else None, + ) + self.async_write_ha_state() + return + control_event = CONTROL_TO_EVENT.get(event.control) + if control_event is None: + return + if control_event.event_type == ButtonEventType.LONG_PRESS_START: + self._last_fade_direction = control_event.direction + event_attributes: dict[str, str | int] = { + ATTR_DIRECTION: control_event.direction + } + if control_event.multi_press_count is not None: + event_attributes[ATTR_MULTI_PRESS_COUNT] = control_event.multi_press_count + self._trigger_event(control_event.event_type, event_attributes) + self.async_write_ha_state() diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index 764f50988e037..a6d7daeb309f4 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -39,6 +39,7 @@ LOGGER, NODE_AUX_FILTERS, NODE_FILTERS, + NODE_PARALLEL_PLATFORMS, NODE_PLATFORMS, PROGRAM_PLATFORMS, SUBNODE_CLIMATE_COOL, @@ -368,6 +369,17 @@ def _categorize_nodes( continue isy_data.aux_properties[Platform.SENSOR].append((node, control)) + # Must run before the sensor_identifier override below -- Platform.EVENT + # is additive, not exclusive with a name/path-forced Platform.SENSOR. + for parallel_platform in NODE_PARALLEL_PLATFORMS: + if _check_for_node_def(isy_data, node, single_platform=parallel_platform): + continue + if _check_for_insteon_type( + isy_data, node, single_platform=parallel_platform + ): + continue + _check_for_zwave_cat(isy_data, node, single_platform=parallel_platform) + if sensor_identifier in path or sensor_identifier in node.name: # User has specified to treat this as a sensor. First we need to # determine if it should be a binary_sensor. diff --git a/homeassistant/components/isy994/models.py b/homeassistant/components/isy994/models.py index df0ab93c62329..4430035788a3a 100644 --- a/homeassistant/components/isy994/models.py +++ b/homeassistant/components/isy994/models.py @@ -17,11 +17,13 @@ from .const import ( CONF_NETWORK, NODE_AUX_PROP_PLATFORMS, + NODE_PARALLEL_PLATFORMS, NODE_PLATFORMS, PROGRAM_PLATFORMS, ROOT_NODE_PLATFORMS, VARIABLE_PLATFORMS, ) +from .event import EVENT_BUTTON_UNIQUE_ID_SUFFIX type IsyConfigEntry = ConfigEntry[IsyData] @@ -41,7 +43,7 @@ class IsyData: def __init__(self) -> None: """Initialize an empty ISY data class.""" - self.nodes = {p: [] for p in NODE_PLATFORMS} + self.nodes = {p: [] for p in (*NODE_PLATFORMS, *NODE_PARALLEL_PLATFORMS)} self.root_nodes = {p: [] for p in ROOT_NODE_PLATFORMS} self.aux_properties = {p: [] for p in NODE_AUX_PROP_PLATFORMS} self.programs = {p: [] for p in PROGRAM_PLATFORMS} @@ -95,4 +97,14 @@ def unique_ids(self) -> set[tuple[Platform, str]]: for node in self.net_resources: current_unique_ids.add((Platform.BUTTON, self.uid_base(node))) + # Separate from the NODE_PLATFORMS loop above: event unique ids carry + # a suffix, since the same node also has a primary-platform entity. + for node in self.nodes[Platform.EVENT]: + current_unique_ids.add( + ( + Platform.EVENT, + f"{self.uid_base(node)}{EVENT_BUTTON_UNIQUE_ID_SUFFIX}", + ) + ) + return current_unique_ids diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index 62abbb1abc64a..febf66861305c 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -35,6 +35,21 @@ } } }, + "entity": { + "event": { + "button": { + "state_attributes": { + "direction": { + "name": "Direction", + "state": { + "down": "Down", + "up": "Up" + } + } + } + } + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/lunatone/manifest.json b/homeassistant/components/lunatone/manifest.json index dbf5106d694f9..01e8f548ce592 100644 --- a/homeassistant/components/lunatone/manifest.json +++ b/homeassistant/components/lunatone/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["lunatone-rest-api-client==0.10.1"], + "requirements": ["lunatone-rest-api-client==0.10.2"], "zeroconf": [ { "properties": { diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 525a99b86c21c..99aae56b18cde 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -208,7 +208,7 @@ class NumberDeviceClass(StrEnum): Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, - `Mcal`, `Gcal` + `Mcal`, `Gcal`, `thm` """ ENERGY_DISTANCE = "energy_distance" @@ -231,7 +231,7 @@ class NumberDeviceClass(StrEnum): Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, - `Mcal`, `Gcal` + `Mcal`, `Gcal`, `thm` """ FREQUENCY = "frequency" diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 9f396e99d4fca..72d306728742d 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -240,7 +240,7 @@ class SensorDeviceClass(StrEnum): electric energy consumption. Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, - `Mcal`, `Gcal` + `Mcal`, `Gcal`, `thm` """ ENERGY_DISTANCE = "energy_distance" @@ -262,7 +262,7 @@ class SensorDeviceClass(StrEnum): Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, - `Mcal`, `Gcal` + `Mcal`, `Gcal`, `thm` """ FREQUENCY = "frequency" diff --git a/homeassistant/components/solarman/manifest.json b/homeassistant/components/solarman/manifest.json index 770bb5d087746..8d1682dbeac06 100644 --- a/homeassistant/components/solarman/manifest.json +++ b/homeassistant/components/solarman/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/solarman", "integration_type": "device", "iot_class": "local_polling", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["solarman-opendata==0.0.3"], "zeroconf": ["_solarman._tcp.local."] } diff --git a/homeassistant/components/solarman/quality_scale.yaml b/homeassistant/components/solarman/quality_scale.yaml index dc0d15ea321b8..ceb9eae787731 100644 --- a/homeassistant/components/solarman/quality_scale.yaml +++ b/homeassistant/components/solarman/quality_scale.yaml @@ -41,16 +41,16 @@ rules: status: exempt comment: | This integration does not have an options flow. - docs-installation-parameters: todo + docs-installation-parameters: done entity-unavailable: done - integration-owner: todo + integration-owner: done log-when-unavailable: done parallel-updates: done reauthentication-flow: status: exempt comment: | No authentication required. - test-coverage: todo + test-coverage: done # Gold devices: done diagnostics: todo diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json index a7f384d602f4a..775d492165c95 100644 --- a/homeassistant/components/subaru/manifest.json +++ b/homeassistant/components/subaru/manifest.json @@ -7,5 +7,6 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["stdiomask", "subarulink"], + "quality_scale": "bronze", "requirements": ["subarulink==0.7.19"] } diff --git a/homeassistant/components/subaru/quality_scale.yaml b/homeassistant/components/subaru/quality_scale.yaml new file mode 100644 index 0000000000000..df716f68a7621 --- /dev/null +++ b/homeassistant/components/subaru/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: done + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: + status: exempt + comment: This integration does not subscribe to events; state comes from the polling coordinator. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: done + discovery: + status: exempt + comment: Cloud account integration; there is nothing on the local network to discover. + discovery-update-info: + status: exempt + comment: Cloud account integration; there is nothing on the local network to discover. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index fdf4ff4dc54c0..d098a2b31698f 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -18,6 +18,9 @@ "data": { "pin": "[%key:common::config_flow::data::pin%]" }, + "data_description": { + "pin": "The 4-digit PIN used for remote commands, shared by all vehicles on your MySubaru account." + }, "description": "Please enter your MySubaru PIN\nNOTE: All vehicles in account must have the same PIN", "title": "[%key:component::subaru::config::step::user::title%]" }, @@ -25,6 +28,9 @@ "data": { "contact_method": "Please select a contact method:" }, + "data_description": { + "contact_method": "Where Subaru should send your two-factor authentication code." + }, "description": "Two-factor authentication required", "title": "[%key:component::subaru::config::step::user::title%]" }, @@ -32,6 +38,9 @@ "data": { "validation_code": "Validation code" }, + "data_description": { + "validation_code": "The 6-digit code sent to your selected contact method." + }, "description": "Please enter validation code received", "title": "[%key:component::subaru::config::step::user::title%]" }, @@ -41,6 +50,11 @@ "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" }, + "data_description": { + "country": "The country where your MySubaru account is registered.", + "password": "The password for your MySubaru account.", + "username": "The email address or username for your MySubaru account." + }, "description": "Please enter your MySubaru credentials\nNOTE: Initial setup may take up to 30 seconds", "title": "MySubaru Connected Services configuration" } @@ -233,6 +247,9 @@ "data": { "update_enabled": "Enable vehicle polling" }, + "data_description": { + "update_enabled": "Sends a remote command to the vehicle every 2 hours to refresh sensor data. Draws power from the 12V battery." + }, "description": "When enabled, vehicle polling will send a remote command to your vehicle every 2 hours to obtain new sensor data. Without vehicle polling, new sensor data is only received when the vehicle automatically pushes data (normally after engine shutdown).", "title": "MySubaru Connected Services options" } diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 324233f464cda..790bc512dcb87 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -157,6 +157,7 @@ SupportedModels.FLOOR_LAMP.value: [Platform.LIGHT, Platform.SENSOR], SupportedModels.STRIP_LIGHT_3.value: [Platform.LIGHT, Platform.SENSOR], SupportedModels.RGBICWW_FLOOR_LAMP.value: [Platform.LIGHT, Platform.SENSOR], + SupportedModels.RGBICWW_LIGHT_BARS.value: [Platform.LIGHT, Platform.SENSOR], SupportedModels.RGBICWW_STRIP_LIGHT.value: [Platform.LIGHT, Platform.SENSOR], SupportedModels.PERMANENT_OUTDOOR_LIGHT.value: [Platform.LIGHT, Platform.SENSOR], SupportedModels.PLUG_MINI_EU.value: [Platform.SWITCH, Platform.SENSOR], @@ -247,6 +248,7 @@ SupportedModels.FLOOR_LAMP.value: switchbot.SwitchbotStripLight3, SupportedModels.STRIP_LIGHT_3.value: switchbot.SwitchbotStripLight3, SupportedModels.RGBICWW_FLOOR_LAMP.value: switchbot.SwitchbotRgbicLight, + SupportedModels.RGBICWW_LIGHT_BARS.value: switchbot.SwitchbotRgbicLight, SupportedModels.RGBICWW_STRIP_LIGHT.value: switchbot.SwitchbotRgbicLight, SupportedModels.PERMANENT_OUTDOOR_LIGHT.value: ( switchbot.SwitchbotPermanentOutdoorLight diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 2d04fa982fbea..e1cab0c89c311 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -57,6 +57,7 @@ class SupportedModels(StrEnum): STRIP_LIGHT_3 = "strip_light_3" RGBICWW_STRIP_LIGHT = "rgbicww_strip_light" RGBICWW_FLOOR_LAMP = "rgbicww_floor_lamp" + RGBICWW_LIGHT_BARS = "rgbicww_light_bars" PERMANENT_OUTDOOR_LIGHT = "permanent_outdoor_light" PLUG_MINI_EU = "plug_mini_eu" RELAY_SWITCH_2PM = "relay_switch_2pm" @@ -113,6 +114,7 @@ class SupportedModels(StrEnum): SwitchbotModel.STRIP_LIGHT_3: SupportedModels.STRIP_LIGHT_3, SwitchbotModel.RGBICWW_STRIP_LIGHT: SupportedModels.RGBICWW_STRIP_LIGHT, SwitchbotModel.RGBICWW_FLOOR_LAMP: SupportedModels.RGBICWW_FLOOR_LAMP, + SwitchbotModel.RGBICWW_LIGHT_BARS: SupportedModels.RGBICWW_LIGHT_BARS, SwitchbotModel.PERMANENT_OUTDOOR_LIGHT: SupportedModels.PERMANENT_OUTDOOR_LIGHT, SwitchbotModel.PLUG_MINI_EU: SupportedModels.PLUG_MINI_EU, SwitchbotModel.RELAY_SWITCH_2PM: SupportedModels.RELAY_SWITCH_2PM, @@ -173,6 +175,7 @@ class SupportedModels(StrEnum): SwitchbotModel.STRIP_LIGHT_3, SwitchbotModel.RGBICWW_STRIP_LIGHT, SwitchbotModel.RGBICWW_FLOOR_LAMP, + SwitchbotModel.RGBICWW_LIGHT_BARS, SwitchbotModel.PERMANENT_OUTDOOR_LIGHT, SwitchbotModel.PLUG_MINI_EU, SwitchbotModel.RELAY_SWITCH_2PM, @@ -208,6 +211,7 @@ class SupportedModels(StrEnum): SwitchbotModel.STRIP_LIGHT_3: switchbot.SwitchbotStripLight3, SwitchbotModel.RGBICWW_STRIP_LIGHT: switchbot.SwitchbotRgbicLight, SwitchbotModel.RGBICWW_FLOOR_LAMP: switchbot.SwitchbotRgbicLight, + SwitchbotModel.RGBICWW_LIGHT_BARS: switchbot.SwitchbotRgbicLight, SwitchbotModel.PERMANENT_OUTDOOR_LIGHT: switchbot.SwitchbotPermanentOutdoorLight, SwitchbotModel.PLUG_MINI_EU: switchbot.SwitchbotRelaySwitch, SwitchbotModel.RELAY_SWITCH_2PM: switchbot.SwitchbotRelaySwitch2PM, diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 3db9ca6f22975..5e4cf01617fe8 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -42,5 +42,5 @@ "iot_class": "local_push", "loggers": ["switchbot"], "quality_scale": "gold", - "requirements": ["PySwitchbot==2.7.0"] + "requirements": ["PySwitchbot==2.9.0"] } diff --git a/homeassistant/components/volkszaehler/__init__.py b/homeassistant/components/volkszaehler/__init__.py index a1a6533e4f9eb..c46c62167e56b 100644 --- a/homeassistant/components/volkszaehler/__init__.py +++ b/homeassistant/components/volkszaehler/__init__.py @@ -1 +1,80 @@ """The volkszaehler component.""" + +from datetime import timedelta +import logging + +from volkszaehler import Volkszaehler +from volkszaehler.exceptions import VolkszaehlerApiConnectionError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_UUID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import Throttle + +from .const import SUBENTRY_TYPE_CHANNEL + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) + + +type VolkszaehlerConfigEntry = ConfigEntry[dict[str, VolkszaehlerData]] + + +class VolkszaehlerData: + """The class for handling the data retrieval from the Volkszaehler API.""" + + def __init__(self, api: Volkszaehler) -> None: + """Initialize the data object.""" + self.api = api + self.available = True + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self) -> None: + """Get the latest data from the Volkszaehler REST API.""" + + try: + await self.api.get_data() + self.available = True + except VolkszaehlerApiConnectionError: + _LOGGER.error("Unable to fetch data from the Volkszaehler API") + self.available = False + + +async def async_setup_entry( + hass: HomeAssistant, entry: VolkszaehlerConfigEntry +) -> bool: + """Set up Volkszaehler from a config entry.""" + runtime_data: dict[str, VolkszaehlerData] = {} + + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_CHANNEL): + vz_data = VolkszaehlerData( + Volkszaehler( + async_get_clientsession(hass), + subentry.data[CONF_UUID], + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + ) + ) + await vz_data.async_update() + if not vz_data.available or vz_data.api.data is None: + raise ConfigEntryNotReady( + "Unable to fetch initial data from the Volkszaehler API" + ) + + runtime_data[subentry.subentry_id] = vz_data + + entry.runtime_data = runtime_data + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: VolkszaehlerConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/volkszaehler/config_flow.py b/homeassistant/components/volkszaehler/config_flow.py new file mode 100644 index 0000000000000..81fc56e87268a --- /dev/null +++ b/homeassistant/components/volkszaehler/config_flow.py @@ -0,0 +1,208 @@ +"""Config flow for Volkszaehler integration.""" + +import logging +from types import MappingProxyType +from typing import Any, override + +import probatio +from volkszaehler import Volkszaehler +from volkszaehler.exceptions import ( + VolkszaehlerApiConnectionError, + VolkszaehlerNoDataAvailable, +) + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentry, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_UUID +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_PORT, DOMAIN, SUBENTRY_TYPE_CHANNEL + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = probatio.Schema( + { + probatio.Required(CONF_HOST): cv.string, + probatio.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + probatio.Required(CONF_UUID): cv.string, + } +) + +STEP_SUBENTRY_DATA_SCHEMA = probatio.Schema({probatio.Required(CONF_UUID): cv.string}) + + +async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: + """Validate the user input allows us to connect.""" + api = Volkszaehler( + session=async_get_clientsession(hass), + uuid=data[CONF_UUID], + host=data[CONF_HOST], + port=data[CONF_PORT], + ) + await api.get_data() + + +async def _async_validate_input_errors( + hass: HomeAssistant, data: dict[str, Any] +) -> str | None: + """Validate input and return a config flow error key if validation fails.""" + try: + await _validate_input(hass, data) + except VolkszaehlerApiConnectionError: + return "cannot_connect" + except VolkszaehlerNoDataAvailable: + return "no_data" + except Exception: + _LOGGER.exception("Unexpected exception") + return "unknown" + return None + + +def _is_uuid_configured(hass: HomeAssistant, uuid: str) -> bool: + """Return if a Volkszaehler channel UUID is already configured.""" + for entry in hass.config_entries.async_entries(DOMAIN): + if any( + subentry.unique_id == uuid + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_CHANNEL) + ): + return True + return False + + +class VolkszaehlerConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Volkszaehler.""" + + @classmethod + @callback + @override + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {SUBENTRY_TYPE_CHANNEL: VolkszaehlerSubentryFlow} + + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: + """Set the config entry up from yaml.""" + if error := await _async_validate_input_errors(self.hass, import_data): + return self.async_abort(reason=error) + + uuid = import_data[CONF_UUID] + if _is_uuid_configured(self.hass, uuid): + return self.async_abort(reason="already_configured") + + channel_subentry = ConfigSubentry( + subentry_type=SUBENTRY_TYPE_CHANNEL, + unique_id=uuid, + title=import_data.get(CONF_NAME, uuid), + data=MappingProxyType({CONF_UUID: uuid}), + ) + + for entry in self.hass.config_entries.async_entries(DOMAIN): + if ( + entry.data.get(CONF_HOST) == import_data[CONF_HOST] + and entry.data.get(CONF_PORT, DEFAULT_PORT) == import_data[CONF_PORT] + ): + self.hass.config_entries.async_add_subentry(entry, channel_subentry) + return self.async_abort(reason="subentry_added") + + return self.async_create_entry( + title=import_data[CONF_HOST], + data={ + CONF_HOST: import_data[CONF_HOST], + CONF_PORT: import_data[CONF_PORT], + }, + subentries=[channel_subentry.as_dict()], + ) + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + { + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + } + ) + if error := await _async_validate_input_errors(self.hass, user_input): + errors["base"] = error + else: + if _is_uuid_configured(self.hass, user_input[CONF_UUID]): + return self.async_abort(reason="already_configured") + return self.async_create_entry( + title=user_input[CONF_HOST], + data={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + }, + subentries=[ + { + "subentry_type": SUBENTRY_TYPE_CHANNEL, + "title": user_input[CONF_UUID], + "data": {CONF_UUID: user_input[CONF_UUID]}, + "unique_id": user_input[CONF_UUID], + } + ], + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ) + if user_input + else STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + +class VolkszaehlerSubentryFlow(ConfigSubentryFlow): + """Handle Volkszaehler channel subentry flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Add a Volkszaehler channel subentry.""" + errors: dict[str, str] = {} + + if user_input is not None: + if _is_uuid_configured(self.hass, user_input[CONF_UUID]): + return self.async_abort(reason="already_configured") + + entry = self._get_entry() + if error := await _async_validate_input_errors( + self.hass, + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_UUID: user_input[CONF_UUID], + }, + ): + errors["base"] = error + else: + return self.async_create_entry( + title=user_input[CONF_UUID], + data={CONF_UUID: user_input[CONF_UUID]}, + unique_id=user_input[CONF_UUID], + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_SUBENTRY_DATA_SCHEMA, user_input + ) + if user_input + else STEP_SUBENTRY_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/volkszaehler/const.py b/homeassistant/components/volkszaehler/const.py new file mode 100644 index 0000000000000..33aba8d866332 --- /dev/null +++ b/homeassistant/components/volkszaehler/const.py @@ -0,0 +1,7 @@ +"""Constants for the Volkszaehler integration.""" + +DOMAIN = "volkszaehler" +SUBENTRY_TYPE_CHANNEL = "channel" +DEFAULT_HOST = "localhost" +DEFAULT_NAME = "Volkszaehler" +DEFAULT_PORT = 80 diff --git a/homeassistant/components/volkszaehler/manifest.json b/homeassistant/components/volkszaehler/manifest.json index 1427f330e77b9..56c5d7a27733a 100644 --- a/homeassistant/components/volkszaehler/manifest.json +++ b/homeassistant/components/volkszaehler/manifest.json @@ -2,7 +2,9 @@ "domain": "volkszaehler", "name": "Volkszaehler", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/volkszaehler", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["volkszaehler"], "quality_scale": "legacy", diff --git a/homeassistant/components/volkszaehler/sensor.py b/homeassistant/components/volkszaehler/sensor.py index c29c92dfb9d35..0943613300e47 100644 --- a/homeassistant/components/volkszaehler/sensor.py +++ b/homeassistant/components/volkszaehler/sensor.py @@ -1,12 +1,8 @@ """Support for consuming values for the Volkszaehler API.""" -from datetime import timedelta -import logging from typing import override import probatio -from volkszaehler import Volkszaehler -from volkszaehler.exceptions import VolkszaehlerApiConnectionError from homeassistant.components.sensor import ( PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, @@ -14,6 +10,7 @@ SensorEntity, SensorEntityDescription, ) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_HOST, CONF_MONITORED_CONDITIONS, @@ -23,21 +20,23 @@ UnitOfEnergy, UnitOfPower, ) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_HOST = "localhost" -DEFAULT_NAME = "Volkszaehler" -DEFAULT_PORT = 80 -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) +from . import VolkszaehlerConfigEntry, VolkszaehlerData +from .const import ( + DEFAULT_HOST, + DEFAULT_NAME, + DEFAULT_PORT, + DOMAIN, + SUBENTRY_TYPE_CHANNEL, +) SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -91,42 +90,96 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Volkszaehler sensors.""" - - host: str = config[CONF_HOST] - name: str = config[CONF_NAME] - port: int = config[CONF_PORT] - uuid: str = config[CONF_UUID] - conditions: list[str] = config[CONF_MONITORED_CONDITIONS] - - session = async_get_clientsession(hass) - vz_api = VolkszaehlerData(Volkszaehler(session, uuid, host=host, port=port)) - - await vz_api.async_update() - - if vz_api.api.data is None: - raise PlatformNotReady - - entities = [ - VolkszaehlerSensor(vz_api, name, description) - for description in SENSOR_TYPES - if description.key in conditions - ] + """Import Volkszaehler sensor YAML config into config flow.""" + validated = PLATFORM_SCHEMA(config) + data = { + CONF_HOST: validated[CONF_HOST], + CONF_NAME: validated[CONF_NAME], + CONF_PORT: validated[CONF_PORT], + CONF_UUID: validated[CONF_UUID], + } + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=data, + ) + if result["type"] is FlowResultType.ABORT and result["reason"] not in ( + "already_configured", + "subentry_added", + ): + ir.async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result['reason']}", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{result['reason']}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": DEFAULT_NAME, + }, + breaks_in_ha_version="2027.3.0", + ) + return + + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": DEFAULT_NAME, + }, + breaks_in_ha_version="2027.3.0", + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: VolkszaehlerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Volkszaehler sensors from a config entry.""" + conditions = SENSOR_KEYS + + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_CHANNEL): + vz_api = entry.runtime_data[subentry.subentry_id] + + entities = [ + VolkszaehlerSensor( + vz_api, + subentry.title, + subentry.data[CONF_UUID], + description, + ) + for description in SENSOR_TYPES + if description.key in conditions + ] - async_add_entities(entities, True) + async_add_entities(entities, False, config_subentry_id=subentry.subentry_id) class VolkszaehlerSensor(SensorEntity): """Implementation of a Volkszaehler sensor.""" def __init__( - self, vz_api: VolkszaehlerData, name: str, description: SensorEntityDescription + self, + vz_api: VolkszaehlerData, + name: str, + uuid: str, + description: SensorEntityDescription, ) -> None: """Initialize the Volkszaehler sensor.""" self.entity_description = description self.vz_api = vz_api self._attr_name = f"{name} {description.name}" + self._attr_unique_id = f"{uuid}_{description.key}" @property @override @@ -142,23 +195,3 @@ async def async_update(self) -> None: self._attr_native_value = round( getattr(self.vz_api.api, self.entity_description.key), 2 ) - - -class VolkszaehlerData: - """The class for handling the data retrieval from the Volkszaehler API.""" - - def __init__(self, api: Volkszaehler) -> None: - """Initialize the data object.""" - self.api = api - self.available = True - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self) -> None: - """Get the latest data from the Volkszaehler REST API.""" - - try: - await self.api.get_data() - self.available = True - except VolkszaehlerApiConnectionError: - _LOGGER.error("Unable to fetch data from the Volkszaehler API") - self.available = False diff --git a/homeassistant/components/volkszaehler/strings.json b/homeassistant/components/volkszaehler/strings.json new file mode 100644 index 0000000000000..766666713de22 --- /dev/null +++ b/homeassistant/components/volkszaehler/strings.json @@ -0,0 +1,62 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_data": "No data available for this channel", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "uuid": "UUID" + }, + "data_description": { + "host": "IP address or hostname of the Volkszaehler server.", + "port": "Port number of the Volkszaehler server.", + "uuid": "UUID of the channel to monitor. In the Volkszaehler Web UI, click the (i) icon next to the channel to find it." + }, + "description": "Enter your Volkszaehler server and first channel details.", + "title": "Set up Volkszaehler" + } + } + }, + "config_subentries": { + "channel": { + "entry_type": "Channel", + "initiate_flow": { + "user": "Add Channel" + }, + "step": { + "user": { + "data": { + "uuid": "UUID" + }, + "data_description": { + "uuid": "UUID of the channel to monitor. In the Volkszaehler Web UI, click the (i) icon next to the channel to find it." + }, + "description": "Add another Volkszaehler channel.", + "title": "Add Volkszaehler channel" + } + } + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "description": "Importing Volkszaehler sensor configuration from YAML failed because the server could not be reached. Remove the YAML configuration and set up the integration through the Home Assistant UI.", + "title": "YAML import failed" + }, + "deprecated_yaml_import_issue_no_data": { + "description": "Importing Volkszaehler sensor configuration from YAML failed because no data was available for the channel. Remove the YAML configuration and set up the integration through the Home Assistant UI.", + "title": "YAML import failed" + }, + "deprecated_yaml_import_issue_unknown": { + "description": "Importing Volkszaehler sensor configuration from YAML is deprecated or failed. Remove the YAML configuration and set up the integration through the Home Assistant UI.", + "title": "YAML import is deprecated" + } + } +} diff --git a/homeassistant/const.py b/homeassistant/const.py index 6379cc6a5a626..17eae8bcb3974 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -533,6 +533,7 @@ class UnitOfEnergy(StrEnum): KILO_CALORIE = "kcal" MEGA_CALORIE = "Mcal" GIGA_CALORIE = "Gcal" + THERM = "thm" # Reactive energy units diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f5a0279fd6604..fc8bc986acd67 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -876,6 +876,7 @@ "vlc_telnet", "vodafone_station", "voip", + "volkszaehler", "volumio", "volvo", "wake_on_lan", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1acbd8dae166c..0dd5dfea3416e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -8088,8 +8088,8 @@ }, "volkszaehler": { "name": "Volkszaehler", - "integration_type": "hub", - "config_flow": false, + "integration_type": "device", + "config_flow": true, "iot_class": "local_polling" }, "volumio": { diff --git a/homeassistant/generated/sensor.json b/homeassistant/generated/sensor.json index 836e8b624f5f6..8b7327e7b917a 100644 --- a/homeassistant/generated/sensor.json +++ b/homeassistant/generated/sensor.json @@ -123,6 +123,7 @@ "MJ", "MWh", "mWh", + "thm", "TWh", "Wh" ], @@ -145,6 +146,7 @@ "MJ", "MWh", "mWh", + "thm", "TWh", "Wh" ], @@ -465,6 +467,7 @@ "MJ", "MWh", "mWh", + "thm", "TWh", "Wh" ], @@ -487,6 +490,7 @@ "MJ", "MWh", "mWh", + "thm", "TWh", "Wh" ], diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 13c342860980a..e4981acda0374 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -72,6 +72,7 @@ # Energy conversion constants _WH_TO_J = 3600 # 1 Wh = 3600 J _WH_TO_CAL = _WH_TO_J / 4.184 # 1 Wh = 860.42065 cal +_WH_TO_THERM_US = _WH_TO_J / 1.054804e8 # 1 therm (U.S.) = 1.054804e8 J (NIST SP 811) # Mass conversion constants _POUND_TO_G = 453.59237 @@ -414,6 +415,7 @@ class EnergyConverter(BaseUnitConverter): UnitOfEnergy.KILO_CALORIE: _WH_TO_CAL, UnitOfEnergy.MEGA_CALORIE: _WH_TO_CAL / 1e3, UnitOfEnergy.GIGA_CALORIE: _WH_TO_CAL / 1e6, + UnitOfEnergy.THERM: _WH_TO_THERM_US * 1e3, } VALID_UNITS = set(UnitOfEnergy) diff --git a/requirements_all.txt b/requirements_all.txt index fd9804e582bc3..9a751b16bb1fc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -86,7 +86,7 @@ PyRMVtransport==0.3.3 PySrDaliGateway==0.21.0 # homeassistant.components.switchbot -PySwitchbot==2.7.0 +PySwitchbot==2.9.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -676,7 +676,7 @@ batinfo==0.4.2 beautifulsoup4==4.13.3 # homeassistant.components.besen -besen==0.4.2 +besen==0.4.5 # homeassistant.components.bitvis bitvis-protobuf==2.0.4 @@ -1336,7 +1336,7 @@ homekit-audio-proxy==1.2.1 homelink-integration-api==0.1.0 # homeassistant.components.homematicip_cloud -homematicip==2.16.0 +homematicip==2.17.0 # homeassistant.components.homevolt homevolt==0.7.0 @@ -1568,7 +1568,7 @@ loqedAPI==2.1.11 luftdaten==0.7.4 # homeassistant.components.lunatone -lunatone-rest-api-client==0.10.1 +lunatone-rest-api-client==0.10.2 # homeassistant.components.lupusec lupupy==0.3.2 diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index cce45425f70bc..055a140f23c5e 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -852,7 +852,6 @@ class Rule: "steamist", "stream", "streamlabswater", - "subaru", "sun", "sunweg", "supervisord", @@ -1803,7 +1802,6 @@ class Rule: "stream", "streamlabswater", "stookwijzer", - "subaru", "sun", "sunweg", "supervisord", diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 47ee426dcd13c..9bc4cd10f671c 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -578,6 +578,17 @@ async def test_cannot_deactive_owner(mock_hass) -> None: await manager.async_deactivate_user(owner) +async def test_cannot_remove_owner(mock_hass: HomeAssistant) -> None: + """Test that we cannot remove the owner.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + owner = MockUser(is_owner=True).add_to_auth_manager(manager) + + with pytest.raises(ValueError): + await manager.async_remove_user(owner) + + assert await manager.async_get_user(owner.id) is owner + + async def test_deactivate_user_removes_refresh_tokens(hass: HomeAssistant) -> None: """Test that deactivating a user removes their refresh tokens.""" manager = await auth.auth_manager_from_config(hass, [], []) diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py index 201c909199f35..8e1d0d7772753 100644 --- a/tests/components/config/test_auth.py +++ b/tests/components/config/test_auth.py @@ -161,6 +161,36 @@ async def test_delete_unknown_user( assert result["error"]["code"] == "not_found" +async def test_delete_owner( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test that the owner cannot be deleted.""" + owner = MockUser(id="abc", name="Test Owner", is_owner=True).add_to_hass(hass) + + client = await hass_ws_client(hass) + await client.send_json({"id": 5, "type": "config/auth/delete", "user_id": owner.id}) + + result = await client.receive_json() + assert not result["success"], result + assert result["error"]["code"] == "cannot_delete_owner" + assert await hass.auth.async_get_user(owner.id) is owner + + +async def test_delete_system_generated( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test that system generated users cannot be deleted.""" + user = MockUser(id="abc", name="System", system_generated=True).add_to_hass(hass) + + client = await hass_ws_client(hass) + await client.send_json({"id": 5, "type": "config/auth/delete", "user_id": user.id}) + + result = await client.receive_json() + assert not result["success"], result + assert result["error"]["code"] == "cannot_modify_system_generated" + assert await hass.auth.async_get_user(user.id) is user + + async def test_delete( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_access_token: str ) -> None: diff --git a/tests/components/config/test_auth_provider_homeassistant.py b/tests/components/config/test_auth_provider_homeassistant.py index 6b58001396864..56fa85859d01d 100644 --- a/tests/components/config/test_auth_provider_homeassistant.py +++ b/tests/components/config/test_auth_provider_homeassistant.py @@ -221,34 +221,66 @@ async def test_delete_removes_just_auth( async def test_delete_removes_credential( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - hass_storage: dict[str, Any], + auth_provider: prov_ha.HassAuthProvider, ) -> None: """Test deleting auth that is connected to a user.""" client = await hass_ws_client(hass) user = MockUser().add_to_hass(hass) - hass_storage[prov_ha.STORAGE_KEY] = { - "version": 1, - "data": {"users": [{"username": "test-user"}]}, - } - - user.credentials.append( - await hass.auth.auth_providers[0].async_get_or_create_credentials( - {"username": "test-user"} - ) + await hass.async_add_executor_job( + auth_provider.data.add_auth, "other-user", "other-pass" + ) + credential = await auth_provider.async_get_or_create_credentials( + {"username": "other-user"} ) + await hass.auth.async_link_user(user, credential) await client.send_json( { "id": 5, "type": "config/auth_provider/homeassistant/delete", - "username": "test-user", + "username": "other-user", } ) result = await client.receive_json() assert result["success"], result - assert len(hass_storage[prov_ha.STORAGE_KEY]["data"]["users"]) == 0 + assert user.credentials == [] + assert not any( + entry["username"] == "other-user" for entry in auth_provider.data.users + ) + + +async def test_delete_owner_credentials( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + auth_provider: prov_ha.HassAuthProvider, +) -> None: + """Test deleting auth that is connected to the owner is refused.""" + client = await hass_ws_client(hass) + + owner = MockUser(is_owner=True).add_to_hass(hass) + await hass.async_add_executor_job( + auth_provider.data.add_auth, "owner-user", "owner-pass" + ) + credential = await auth_provider.async_get_or_create_credentials( + {"username": "owner-user"} + ) + await hass.auth.async_link_user(owner, credential) + + await client.send_json( + { + "id": 5, + "type": "config/auth_provider/homeassistant/delete", + "username": "owner-user", + } + ) + + result = await client.receive_json() + assert not result["success"], result + assert result["error"]["code"] == "cannot_delete_owner_credentials" + assert owner.credentials == [credential] + assert any(entry["username"] == "owner-user" for entry in auth_provider.data.users) async def test_delete_requires_admin( diff --git a/tests/components/fints/test_sensor.py b/tests/components/fints/test_sensor.py index 26cbce6aef01d..72720fbf3201e 100644 --- a/tests/components/fints/test_sensor.py +++ b/tests/components/fints/test_sensor.py @@ -96,7 +96,7 @@ async def test_setup_platform( async def test_account_identifiers_are_not_logged( hass: HomeAssistant, mock_bank: MagicMock, caplog: pytest.LogCaptureFixture ) -> None: - """No IBAN or account number ends up in the log.""" + """No IBAN or account number ends up in the log of this integration.""" caplog.set_level(logging.DEBUG) with patch( @@ -106,5 +106,14 @@ async def test_account_identifiers_are_not_logged( assert await async_setup_component(hass, "sensor", CONFIG) await hass.async_block_till_done() + # The identifiers are state attributes, which the event bus dumps as part of + # the state changed events it logs when debug logging is on. Only the log of + # this integration is of interest here. + logged = "\n".join( + record.getMessage() + for record in caplog.records + if record.name.startswith("homeassistant.components.fints") + ) + for identifier in ACCOUNT_TYPES: - assert identifier not in caplog.text + assert identifier not in logged diff --git a/tests/components/glances/test_init.py b/tests/components/glances/test_init.py index 16d4d9d371bb1..517904055d778 100644 --- a/tests/components/glances/test_init.py +++ b/tests/components/glances/test_init.py @@ -1,7 +1,8 @@ """Tests for Glances integration.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory from glances_api.exceptions import ( GlancesApiAuthorizationError, GlancesApiConnectionError, @@ -9,13 +10,18 @@ ) import pytest -from homeassistant.components.glances.const import DOMAIN +from homeassistant.components.glances.const import ( + DEFAULT_SCAN_INTERVAL, + DEFAULT_TIMEOUT, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from . import MOCK_USER_INPUT -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_successful_config_entry(hass: HomeAssistant) -> None: @@ -53,6 +59,49 @@ async def test_setup_error( assert entry.state is entry_state +async def test_entity_unavailable_on_update_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_api: MagicMock, +) -> None: + """Test that entities become unavailable when a data update fails.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED + assert hass.states.get("sensor.0_0_0_0_ssl_disk_used").state != STATE_UNAVAILABLE + + mock_api.return_value.get_ha_sensor_data.side_effect = GlancesApiConnectionError( + "Connection to http://localhost:61209/api/4/all failed" + ) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.0_0_0_0_ssl_disk_used").state == STATE_UNAVAILABLE + + +async def test_dedicated_httpx_client_uses_timeout( + hass: HomeAssistant, +) -> None: + """The integration's dedicated httpx client uses DEFAULT_TIMEOUT.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.glances.create_async_httpx_client" + ) as mock_create: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED + + kwargs = mock_create.call_args.kwargs + assert kwargs["timeout"] == DEFAULT_TIMEOUT + assert kwargs["verify_ssl"] == MOCK_USER_INPUT["verify_ssl"] + + async def test_unload_entry(hass: HomeAssistant) -> None: """Test removing Glances.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) diff --git a/tests/components/ipp/fixtures/printer.json b/tests/components/ipp/fixtures/printer.json index 6c3f9cd054594..2e2f511a2f1d1 100644 --- a/tests/components/ipp/fixtures/printer.json +++ b/tests/components/ipp/fixtures/printer.json @@ -32,5 +32,12 @@ "marker-colors": ["#000000", "#000000", "#00FFFF", "#FFFF00", "#FF00FF"], "marker-levels": [58, 98, 91, 95, 73], "marker-low-levels": [10, 10, 10, 10, 10], - "marker-high-levels": [100, 100, 100, 100, 100] + "marker-high-levels": [100, 100, 100, 100, 100], + "printer-impressions-completed": 2468, + "printer-impressions-completed-col": { + "monochrome": 1500, + "full-color": 968 + }, + "printer-media-sheets-completed": 1234, + "printer-pages-completed": 1234 } diff --git a/tests/components/ipp/snapshots/test_diagnostics.ambr b/tests/components/ipp/snapshots/test_diagnostics.ambr index 29966d46fabdf..ef778411c88ab 100644 --- a/tests/components/ipp/snapshots/test_diagnostics.ambr +++ b/tests/components/ipp/snapshots/test_diagnostics.ambr @@ -4,12 +4,17 @@ 'data': dict({ 'booted_at': '2019-11-11T09:10:02+00:00', 'counters': dict({ - 'impressions_completed': None, + 'impressions_completed': 2468, 'impressions_completed_col': dict({ + 'full-color': 968, + 'monochrome': 1500, }), - 'media_sheets_completed': None, - 'pages_completed': None, + 'media_sheets_completed': 1234, + 'pages_completed': 1234, 'supported': list([ + 'impressions_completed', + 'pages_completed', + 'media_sheets_completed', ]), }), 'info': dict({ diff --git a/tests/components/ipp/snapshots/test_sensor.ambr b/tests/components/ipp/snapshots/test_sensor.ambr index 717164343b8d5..454d9fb0f0407 100644 --- a/tests/components/ipp/snapshots/test_sensor.ambr +++ b/tests/components/ipp/snapshots/test_sensor.ambr @@ -125,6 +125,60 @@ 'state': '58', }) # --- +# name: test_sensors[sensor.test_ha_1000_series_color_impressions_completed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_ha_1000_series_color_impressions_completed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Color impressions completed', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Color impressions completed', + 'platform': 'ipp', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'impressions_completed_full_color', + 'unique_id': 'cfe92100-67c4-11d4-a45f-f8d027761251_impressions_completed_full_color', + 'unit_of_measurement': 'impressions', + }) +# --- +# name: test_sensors[sensor.test_ha_1000_series_color_impressions_completed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test HA-1000 Series Color impressions completed', + : , + : 'impressions', + }), + 'context': , + 'entity_id': 'sensor.test_ha_1000_series_color_impressions_completed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '968', + }) +# --- # name: test_sensors[sensor.test_ha_1000_series_cyan_ink-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -182,6 +236,60 @@ 'state': '91', }) # --- +# name: test_sensors[sensor.test_ha_1000_series_impressions_completed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_ha_1000_series_impressions_completed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Impressions completed', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Impressions completed', + 'platform': 'ipp', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'impressions_completed', + 'unique_id': 'cfe92100-67c4-11d4-a45f-f8d027761251_impressions_completed', + 'unit_of_measurement': 'impressions', + }) +# --- +# name: test_sensors[sensor.test_ha_1000_series_impressions_completed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test HA-1000 Series Impressions completed', + : , + : 'impressions', + }), + 'context': , + 'entity_id': 'sensor.test_ha_1000_series_impressions_completed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2468', + }) +# --- # name: test_sensors[sensor.test_ha_1000_series_magenta_ink-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -239,6 +347,168 @@ 'state': '73', }) # --- +# name: test_sensors[sensor.test_ha_1000_series_media_sheets_completed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_ha_1000_series_media_sheets_completed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Media sheets completed', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Media sheets completed', + 'platform': 'ipp', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'media_sheets_completed', + 'unique_id': 'cfe92100-67c4-11d4-a45f-f8d027761251_media_sheets_completed', + 'unit_of_measurement': 'sheets', + }) +# --- +# name: test_sensors[sensor.test_ha_1000_series_media_sheets_completed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test HA-1000 Series Media sheets completed', + : , + : 'sheets', + }), + 'context': , + 'entity_id': 'sensor.test_ha_1000_series_media_sheets_completed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1234', + }) +# --- +# name: test_sensors[sensor.test_ha_1000_series_monochrome_impressions_completed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_ha_1000_series_monochrome_impressions_completed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Monochrome impressions completed', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Monochrome impressions completed', + 'platform': 'ipp', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'impressions_completed_monochrome', + 'unique_id': 'cfe92100-67c4-11d4-a45f-f8d027761251_impressions_completed_monochrome', + 'unit_of_measurement': 'impressions', + }) +# --- +# name: test_sensors[sensor.test_ha_1000_series_monochrome_impressions_completed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test HA-1000 Series Monochrome impressions completed', + : , + : 'impressions', + }), + 'context': , + 'entity_id': 'sensor.test_ha_1000_series_monochrome_impressions_completed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1500', + }) +# --- +# name: test_sensors[sensor.test_ha_1000_series_pages_completed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_ha_1000_series_pages_completed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pages completed', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pages completed', + 'platform': 'ipp', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pages_completed', + 'unique_id': 'cfe92100-67c4-11d4-a45f-f8d027761251_pages_completed', + 'unit_of_measurement': 'pages', + }) +# --- +# name: test_sensors[sensor.test_ha_1000_series_pages_completed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test HA-1000 Series Pages completed', + : , + : 'pages', + }), + 'context': , + 'entity_id': 'sensor.test_ha_1000_series_pages_completed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1234', + }) +# --- # name: test_sensors[sensor.test_ha_1000_series_photo_black_ink-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/ipp/test_sensor.py b/tests/components/ipp/test_sensor.py index bdbb9a88d3559..4e8e90da14407 100644 --- a/tests/components/ipp/test_sensor.py +++ b/tests/components/ipp/test_sensor.py @@ -1,14 +1,19 @@ """Tests for the IPP sensor platform.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock +from freezegun.api import FrozenDateTimeFactory +from pyipp import Counters, Printer import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.ipp.coordinator import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.freeze_time("2019-11-11 09:10:32+00:00") @@ -29,13 +34,18 @@ async def test_disabled_by_default_sensors( init_integration: MockConfigEntry, ) -> None: """Test the disabled by default IPP sensors.""" - state = hass.states.get("sensor.test_ha_1000_series_uptime") + entity_id = entity_registry.async_get_entity_id( + "sensor", "ipp", f"{init_integration.unique_id}_uptime" + ) + assert entity_id is not None + + state = hass.states.get(entity_id) assert state is None - entry = entity_registry.async_get("sensor.test_ha_1000_series_uptime") - assert entry - assert entry.disabled - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION async def test_missing_entry_unique_id( @@ -54,3 +64,102 @@ async def test_missing_entry_unique_id( entity = entity_registry.async_get("sensor.test_ha_1000_series") assert entity assert entity.unique_id == f"{mock_config_entry.entry_id}_printer" + + +async def test_no_page_count_sensors_when_unsupported( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_printer: Printer, + mock_ipp: MagicMock, +) -> None: + """Test that page count sensors are not created when printer doesn't support them.""" + mock_printer.counters = Counters( + impressions_completed=None, + impressions_completed_col={}, + pages_completed=None, + media_sheets_completed=None, + supported=(), + ) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + unique_id = mock_config_entry.unique_id + for key in ( + "pages_completed", + "impressions_completed", + "media_sheets_completed", + "impressions_completed_monochrome", + "impressions_completed_full_color", + ): + assert not entity_registry.async_get_entity_id( + "sensor", "ipp", f"{unique_id}_{key}" + ) + + +async def test_page_count_sensors_with_partial_counters( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_printer: Printer, + mock_ipp: MagicMock, +) -> None: + """Test sensors follow which counters the printer supports. + + A supported counter with an unknown value gets a sensor in the unknown + state; a counter the printer does not report gets no sensor. + """ + mock_printer.counters = Counters( + impressions_completed=None, + impressions_completed_col={"monochrome": 1500, "full-color": None}, + pages_completed=None, + media_sheets_completed=None, + supported=("impressions_completed", "pages_completed"), + ) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not entity_registry.async_get_entity_id( + "sensor", "ipp", f"{mock_config_entry.unique_id}_media_sheets_completed" + ) + + state = hass.states.get( + "sensor.test_ha_1000_series_monochrome_impressions_completed" + ) + assert state + assert state.state == "1500" + + for entity_id in ( + "sensor.test_ha_1000_series_pages_completed", + "sensor.test_ha_1000_series_impressions_completed", + "sensor.test_ha_1000_series_color_impressions_completed", + ): + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + + +async def test_page_count_unknown_when_counter_missing( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + init_integration: MockConfigEntry, + mock_printer: Printer, +) -> None: + """Test a page count sensor becomes unknown when the printer stops reporting it.""" + assert hass.states.get("sensor.test_ha_1000_series_pages_completed").state == "1234" + + mock_printer.counters.pages_completed = None + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.test_ha_1000_series_pages_completed").state + == STATE_UNKNOWN + ) diff --git a/tests/components/isy994/conftest.py b/tests/components/isy994/conftest.py index 3cf24445914e4..d260ba7561632 100644 --- a/tests/components/isy994/conftest.py +++ b/tests/components/isy994/conftest.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, patch +from pyisy.constants import ES_CONNECTED from pyisy.nodes import Node import pytest @@ -42,6 +43,7 @@ def mock_isy(): mock.networking.nobjs = [] mock.clock = MagicMock() mock.websocket = MagicMock() + mock.websocket.status = ES_CONNECTED mock.conf = { "name": "Skynet ISY", "model": "IoX", diff --git a/tests/components/isy994/snapshots/test_event.ambr b/tests/components/isy994/snapshots/test_event.ambr new file mode 100644 index 0000000000000..93fceb977f401 --- /dev/null +++ b/tests/components/isy994/snapshots/test_event.ambr @@ -0,0 +1,261 @@ +# serializer version: 1 +# name: test_event_entity_snapshot[event.garage_relay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.garage_relay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00:00:00:00:00:00_22 22 22 1_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_event_entity_snapshot[event.garage_relay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'button', + : None, + : list([ + , + , + , + , + ]), + : 'Garage Relay', + }), + 'context': , + 'entity_id': 'event.garage_relay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_event_entity_snapshot[event.hallway_keypad-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.hallway_keypad', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00:00:00:00:00:00_33 33 33 1_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_event_entity_snapshot[event.hallway_keypad-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'button', + : None, + : list([ + , + , + , + , + ]), + : 'Hallway Keypad', + }), + 'context': , + 'entity_id': 'event.hallway_keypad', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_event_entity_snapshot[event.hallway_keypad_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.hallway_keypad_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'B', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'B', + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00:00:00:00:00:00_33 33 33 2_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_event_entity_snapshot[event.hallway_keypad_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'button', + : None, + : list([ + , + , + , + , + ]), + : 'Hallway Keypad B', + }), + 'context': , + 'entity_id': 'event.hallway_keypad_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_event_entity_snapshot[event.living_room_switch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.living_room_switch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'isy994', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00:00:00:00:00:00_11 11 11 1_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_event_entity_snapshot[event.living_room_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'button', + : None, + : list([ + , + , + , + , + ]), + : 'Living Room Switch', + }), + 'context': , + 'entity_id': 'event.living_room_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/isy994/test_event.py b/tests/components/isy994/test_event.py new file mode 100644 index 0000000000000..cadb0f772ae80 --- /dev/null +++ b/tests/components/isy994/test_event.py @@ -0,0 +1,387 @@ +"""Test the ISY994 event platform.""" + +from collections.abc import Callable, Generator +from typing import Any +from unittest.mock import MagicMock, patch + +from pyisy.constants import ( + CMD_FADE_DOWN, + CMD_FADE_STOP, + CMD_FADE_UP, + CMD_OFF, + CMD_OFF_FAST, + CMD_ON, + CMD_ON_FAST, + ES_SYNCING, +) +from pyisy.helpers import NodeProperty +from pyisy.nodes import NodeChangedEvent +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.isy994.const import EVENT_ISY994_CONTROL +from homeassistant.components.isy994.event import _sub_button_name +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_capture_events, snapshot_platform + +SWITCH_ENTITY_ID = "switch.garage_relay" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Return the platforms to set up, overridden by parametrization.""" + return [Platform.EVENT] + + +@pytest.fixture(autouse=True) +def mock_event_platform(platforms: list[Platform]) -> Generator[None]: + """Mock the platforms that are set up.""" + with patch("homeassistant.components.isy994.PLATFORMS", platforms): + yield + + +def _make_button_nodes( + mock_isy: MagicMock, mock_node: Callable[..., Any] +) -> list[tuple[str, MagicMock]]: + """Build a representative set of button-emitting Insteon nodes.""" + nodes: list[tuple[str, MagicMock]] = [] + + # Primary loads — enabled by default + primary = mock_node( + mock_isy, "11 11 11 1", "Living Room Switch", "DimmerLampSwitch_ADV" + ) + nodes.append(("Living Room Switch", primary)) + + relay = mock_node(mock_isy, "22 22 22 1", "Garage Relay", "RelayLampSwitch_ADV") + nodes.append(("Garage Relay", relay)) + + keypad_load = mock_node( + mock_isy, "33 33 33 1", "Hallway Keypad", "KeypadDimmer_ADV" + ) + nodes.append(("Hallway Keypad", keypad_load)) + + # Secondary keypad button — disabled by default + sub_button = mock_node( + mock_isy, "33 33 33 2", "Hallway Keypad B", "KeypadButton_ADV" + ) + sub_button.parent_node = keypad_load + sub_button.primary_node = "33 33 33 1" + nodes.append(("Hallway Keypad B", sub_button)) + + return nodes + + +async def test_event_entity_snapshot( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_isy: MagicMock, + mock_node: Callable[..., Any], +) -> None: + """Snapshot the event entities created for supported Insteon nodes.""" + mock_config_entry.add_to_hass(hass) + mock_isy.nodes.__iter__.return_value = _make_button_nodes(mock_isy, mock_node) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert any(entry.disabled_by is not None for entry in entries) + + for entry in entries: + if entry.disabled_by: + entity_registry.async_update_entity(entry.entity_id, disabled_by=None) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("parent_name", "node_name", "expected"), + [ + ("Hallway Keypad", "Hallway Keypad B", "B"), + ("Hall", "Hallway B", "Hallway B"), # prefix with no separator: unchanged + ], +) +def test_sub_button_name_requires_separator( + parent_name: str, node_name: str, expected: str +) -> None: + """A parent name that is a bare prefix (no separator) must not be stripped. + + "Hall" is a prefix of "Hallway B" with no separator between them, so the + sub-button label must stay "Hallway B" rather than being corrupted to + "way B". + """ + node = MagicMock() + node.parent_node.name = parent_name + node.name = node_name + assert _sub_button_name(node) == expected + + +@pytest.mark.parametrize( + ("control", "expected_event_type", "expected_direction", "expected_count"), + [ + (CMD_ON, "press_end", "up", None), + (CMD_OFF, "press_end", "down", None), + (CMD_ON_FAST, "multi_press_end", "up", 2), + (CMD_OFF_FAST, "multi_press_end", "down", 2), + (CMD_FADE_UP, "long_press_start", "up", None), + (CMD_FADE_DOWN, "long_press_start", "down", None), + ], +) +async def test_control_event_triggers_entity( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_isy: MagicMock, + mock_node: Callable[..., Any], + control: str, + expected_event_type: str, + expected_direction: str, + expected_count: int | None, +) -> None: + """Control events from pyisy translate into the standard button event types.""" + mock_config_entry.add_to_hass(hass) + node = mock_node(mock_isy, "11 11 11 1", "Test Switch", "DimmerLampSwitch_ADV") + mock_isy.nodes.__iter__.return_value = [("Test Switch", node)] + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + handler = node.control_events.subscribe.call_args.args[0] + handler(MagicMock(spec=NodeProperty, control=control)) + await hass.async_block_till_done() + + entity_ids = hass.states.async_entity_ids("event") + assert len(entity_ids) == 1 + state = hass.states.get(entity_ids[0]) + assert state is not None + assert state.attributes["event_type"] == expected_event_type + assert state.attributes.get("direction") == expected_direction + assert state.attributes.get("multi_press_count") == expected_count + + +@pytest.mark.parametrize( + ("controls", "expected_direction"), + [ + pytest.param([CMD_FADE_DOWN, CMD_FADE_STOP], "down", id="paired_stop"), + pytest.param([CMD_FADE_STOP], None, id="orphan_stop"), + pytest.param( + [CMD_FADE_DOWN, CMD_FADE_STOP, CMD_FADE_STOP], None, id="duplicate_stop" + ), + ], +) +async def test_fade_stop_direction( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_isy: MagicMock, + mock_node: Callable[..., Any], + controls: list[str], + expected_direction: str | None, +) -> None: + """CMD_FADE_STOP reports the direction of the fade it ends, once. + + The remembered direction is consumed by the stop that uses it, so a stop + with no preceding fade start reports no direction instead of a stale one. + """ + mock_config_entry.add_to_hass(hass) + node = mock_node(mock_isy, "11 11 11 1", "Test Switch", "DimmerLampSwitch_ADV") + mock_isy.nodes.__iter__.return_value = [("Test Switch", node)] + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + handler = node.control_events.subscribe.call_args.args[0] + for control in controls: + handler(MagicMock(spec=NodeProperty, control=control)) + await hass.async_block_till_done() + + entity_ids = hass.states.async_entity_ids("event") + state = hass.states.get(entity_ids[0]) + assert state is not None + assert state.attributes["event_type"] == "long_press_end" + assert state.attributes.get("direction") == expected_direction + # An unknown direction omits the attribute entirely rather than + # publishing it as null. + assert ("direction" in state.attributes) is (expected_direction is not None) + + +async def test_disabling_node_marks_entity_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_isy: MagicMock, + mock_node: Callable[..., Any], +) -> None: + """Availability tracks the node's enabled flag. + + The entity skips the base class's status_events subscription, so only the + filtered enabled/disabled subscription keeps `available` current. + """ + mock_config_entry.add_to_hass(hass) + node = mock_node(mock_isy, "11 11 11 1", "Test Switch", "DimmerLampSwitch_ADV") + mock_isy.nodes.__iter__.return_value = [("Test Switch", node)] + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = hass.states.async_entity_ids("event")[0] + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + node.enabled = False + handler = mock_isy.nodes.status_events.subscribe.call_args.args[0] + handler(MagicMock(spec=NodeChangedEvent), "key") + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_control_event_suppressed_while_websocket_syncing( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_isy: MagicMock, + mock_node: Callable[..., Any], +) -> None: + """Control events are dropped while the websocket replays status on connect. + + Without this guard, PyISY's post-connect status replay fires stale + button events on every startup, config-entry reload, and reconnect. + """ + mock_config_entry.add_to_hass(hass) + node = mock_node(mock_isy, "11 11 11 1", "Test Switch", "DimmerLampSwitch_ADV") + mock_isy.nodes.__iter__.return_value = [("Test Switch", node)] + mock_isy.websocket.status = ES_SYNCING + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + handler = node.control_events.subscribe.call_args.args[0] + handler(MagicMock(spec=NodeProperty, control=CMD_ON)) + await hass.async_block_till_done() + + entity_ids = hass.states.async_entity_ids("event") + state = hass.states.get(entity_ids[0]) + assert state is not None + assert state.attributes.get("event_type") is None + + +@pytest.mark.parametrize("platforms", [[Platform.EVENT, Platform.SWITCH]]) +@pytest.mark.parametrize( + "control", [CMD_ON, CMD_OFF, CMD_ON_FAST, CMD_OFF_FAST, CMD_FADE_UP, CMD_FADE_STOP] +) +async def test_legacy_control_bus_event_not_duplicated( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_isy: MagicMock, + mock_node: Callable[..., Any], + control: str, +) -> None: + """The legacy isy994_control bus event still fires exactly once per control. + + The node's primary (switch) entity keeps firing the bus event from the + base class, while the event entity overrides async_on_control and must + not fire a second one for the same control. + """ + mock_config_entry.add_to_hass(hass) + node = mock_node(mock_isy, "22 22 22 1", "Garage Relay", "RelayLampSwitch_ADV") + mock_isy.nodes.__iter__.return_value = [("Garage Relay", node)] + events = async_capture_events(hass, EVENT_ISY994_CONTROL) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(SWITCH_ENTITY_ID) is not None + assert len(hass.states.async_entity_ids("event")) == 1 + + for call in node.control_events.subscribe.call_args_list: + call.args[0](MagicMock(spec=NodeProperty, control=control)) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data["entity_id"] == SWITCH_ENTITY_ID + assert events[0].data["control"] == control + + +async def test_unsupported_control_is_ignored( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_isy: MagicMock, + mock_node: Callable[..., Any], +) -> None: + """Control events not in the mapping must not trigger the entity.""" + mock_config_entry.add_to_hass(hass) + node = mock_node(mock_isy, "11 11 11 1", "Test Switch", "DimmerLampSwitch_ADV") + mock_isy.nodes.__iter__.return_value = [("Test Switch", node)] + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + handler = node.control_events.subscribe.call_args.args[0] + handler(MagicMock(spec=NodeProperty, control="ST")) + await hass.async_block_till_done() + + entity_ids = hass.states.async_entity_ids("event") + assert len(entity_ids) == 1 + state = hass.states.get(entity_ids[0]) + assert state is not None + assert state.attributes.get("event_type") is None + + +@pytest.mark.parametrize( + ("node_type", "expect_event_entity"), + [ + ("1.14.1", True), # SwitchLinc prefix from FILTER_INSTEON_TYPE + ("2.44.1", True), # KeypadLinc dimmer prefix from FILTER_INSTEON_TYPE + ("3.32.1", True), # BallastLinc prefix from FILTER_INSTEON_TYPE + ("1.20.1", False), # Not in FILTER_INSTEON_TYPE + ], +) +async def test_legacy_insteon_type_fallback( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_isy: MagicMock, + mock_node: Callable[..., Any], + node_type: str, + expect_event_entity: bool, +) -> None: + """Pre-5.0-firmware nodes with no node_def_id fall back to type-prefix matching.""" + mock_config_entry.add_to_hass(hass) + node = mock_node(mock_isy, "11 11 11 1", "Legacy Switch", None, node_type=node_type) + mock_isy.nodes.__iter__.return_value = [("Legacy Switch", node)] + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_ids = hass.states.async_entity_ids("event") + assert (len(entity_ids) == 1) is expect_event_entity + + +async def test_event_entity_created_despite_sensor_string_override( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_isy: MagicMock, + mock_node: Callable[..., Any], +) -> None: + """A node forced into Platform.SENSOR by the sensor_string option still gets its event entity. + + Platform.EVENT is a parallel classification, not exclusive with the + user's sensor_string override -- a SwitchLinc/KeypadLinc whose name + happens to contain the (default "sensor") override string must still be + matched against NODE_PARALLEL_PLATFORMS. + """ + mock_config_entry.add_to_hass(hass) + node = mock_node( + mock_isy, "11 11 11 1", "Garage sensor Switch", "DimmerLampSwitch_ADV" + ) + mock_isy.nodes.__iter__.return_value = [("Garage sensor Switch", node)] + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids("event")) == 1 diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index 07c5794ba60cc..f5edfa9f0cf6c 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -1940,6 +1940,7 @@ 'psi', 's', 'st', + 'thm', 'var', 'varh', 'yd', @@ -2318,6 +2319,7 @@ 'psi', 's', 'st', + 'thm', 'var', 'varh', 'yd', diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 9f836a265f852..01dd3b7278c59 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -1232,6 +1232,35 @@ def make_advertisement( ) +RGBICWW_LIGHT_BARS_SERVICE_INFO = BluetoothServiceInfoBleak( + name="RGBICWW Light Bars", + manufacturer_data={ + 2409: b'(7/L\x94\xb2\x0c\x9e"\x00\x11:\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x11\xbe\xf8" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="RGBICWW Light Bars", + manufacturer_data={ + 2409: b'(7/L\x94\xb2\x0c\x9e"\x00\x11:\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x11\xbe\xf8" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "RGBICWW Light Bars"), + time=0, + connectable=True, + tx_power=-127, +) + + RGBICWW_FLOOR_LAMP_SERVICE_INFO = BluetoothServiceInfoBleak( name="RGBICWW Floor Lamp", manufacturer_data={ diff --git a/tests/components/switchbot/test_light.py b/tests/components/switchbot/test_light.py index cd97b615d841f..e62a990cab709 100644 --- a/tests/components/switchbot/test_light.py +++ b/tests/components/switchbot/test_light.py @@ -33,6 +33,7 @@ PERMANENT_OUTDOOR_LIGHT_SERVICE_INFO, RGBIC_NEON_LIGHT_SERVICE_INFO, RGBICWW_FLOOR_LAMP_SERVICE_INFO, + RGBICWW_LIGHT_BARS_SERVICE_INFO, RGBICWW_STRIP_LIGHT_SERVICE_INFO, STRIP_LIGHT_3_SERVICE_INFO, WOSTRIP_SERVICE_INFO, @@ -407,6 +408,7 @@ async def test_strip_light_services_exception( "SwitchbotRgbicLight", ), ("rgbicww_floor_lamp", RGBICWW_FLOOR_LAMP_SERVICE_INFO, "SwitchbotRgbicLight"), + ("rgbicww_light_bars", RGBICWW_LIGHT_BARS_SERVICE_INFO, "SwitchbotRgbicLight"), ( "permanent_outdoor_light", PERMANENT_OUTDOOR_LIGHT_SERVICE_INFO, diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 5024ad9ec8331..5991a94ef8c72 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -1256,7 +1256,7 @@ async def test_config_flow_preview( "'None' is not a valid unit for device class 'energy'; " "expected one of 'cal', 'Gcal', 'GJ', 'GWh', 'J'," " 'kcal', 'kJ', 'kWh', 'Mcal', 'MJ', 'MWh'," - " 'mWh', 'TWh', 'Wh'" + " 'mWh', 'thm', 'TWh', 'Wh'" ), }, ), diff --git a/tests/components/volkszaehler/__init__.py b/tests/components/volkszaehler/__init__.py new file mode 100644 index 0000000000000..22e58871dcdc0 --- /dev/null +++ b/tests/components/volkszaehler/__init__.py @@ -0,0 +1 @@ +"""Tests for the Volkszaehler component.""" diff --git a/tests/components/volkszaehler/conftest.py b/tests/components/volkszaehler/conftest.py new file mode 100644 index 0000000000000..219b08ea9efd5 --- /dev/null +++ b/tests/components/volkszaehler/conftest.py @@ -0,0 +1,63 @@ +"""Test fixtures for volkszaehler.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.components.volkszaehler.const import DOMAIN, SUBENTRY_TYPE_CHANNEL +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_UUID + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.volkszaehler.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="mock_api") +def mock_client_api() -> Generator[Mock]: + """Set up fake Volkszaehler API responses.""" + with ( + patch( + "homeassistant.components.volkszaehler.Volkszaehler", + autospec=True, + ) as mock_api, + patch( + "homeassistant.components.volkszaehler.config_flow.Volkszaehler", + new=mock_api, + ), + ): + api = mock_api.return_value + api.get_data = AsyncMock(return_value=None) + + yield api + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Fixture for a config entry with one existing channel subentry.""" + return MockConfigEntry( + domain=DOMAIN, + title="localhost", + data={ + CONF_HOST: "localhost", + CONF_PORT: 80, + }, + subentries_data=[ + ConfigSubentryData( + subentry_type=SUBENTRY_TYPE_CHANNEL, + title="existing-uuid", + data={CONF_UUID: "existing-uuid"}, + unique_id="existing-uuid", + subentry_id="existing-subentry-id", + ) + ], + ) diff --git a/tests/components/volkszaehler/test_config_flow.py b/tests/components/volkszaehler/test_config_flow.py new file mode 100644 index 0000000000000..e7bb61298cabd --- /dev/null +++ b/tests/components/volkszaehler/test_config_flow.py @@ -0,0 +1,353 @@ +"""Test config flow for Volkszaehler integration.""" + +from unittest.mock import AsyncMock + +import pytest +from volkszaehler.exceptions import ( + VolkszaehlerApiConnectionError, + VolkszaehlerNoDataAvailable, +) + +from homeassistant.components.volkszaehler.const import DOMAIN, SUBENTRY_TYPE_CHANNEL +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ( + CONF_HOST, + CONF_MONITORED_CONDITIONS, + CONF_NAME, + CONF_PLATFORM, + CONF_PORT, + CONF_UUID, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_create_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_api: AsyncMock, +) -> None: + """Test that the config flow creates an entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_UUID: "test-uuid", + CONF_HOST: "localhost", + CONF_PORT: 80, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "localhost" + assert result["data"] == { + CONF_HOST: "localhost", + CONF_PORT: 80, + } + + entry = result["result"] + assert len(entry.subentries) == 1 + subentry = next(iter(entry.subentries.values())) + assert subentry.subentry_type == SUBENTRY_TYPE_CHANNEL + assert subentry.title == "test-uuid" + assert subentry.unique_id == "test-uuid" + assert subentry.data == {CONF_UUID: "test-uuid"} + + assert mock_api.get_data.call_count == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (VolkszaehlerApiConnectionError, "cannot_connect"), + (VolkszaehlerNoDataAvailable, "no_data"), + (Exception, "unknown"), + ], +) +async def test_user_errors( + hass: HomeAssistant, + mock_api: AsyncMock, + side_effect: type[Exception], + expected_error: str, +) -> None: + """Test error handling in the config flow user step.""" + user_input = { + CONF_UUID: "test-uuid", + CONF_HOST: "localhost", + CONF_PORT: 80, + } + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + mock_api.get_data.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == expected_error + + mock_api.get_data.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "localhost" + assert result["data"] == { + CONF_HOST: "localhost", + CONF_PORT: 80, + } + + +async def test_create_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: AsyncMock, +) -> None: + """Test that the subentry flow creates an additional channel.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_CHANNEL), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {CONF_UUID: "new-uuid"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "new-uuid" + assert result["data"] == {CONF_UUID: "new-uuid"} + assert len(mock_config_entry.subentries) == 2 + + assert mock_api.get_data.call_count == 1 + + +async def test_import(hass: HomeAssistant, mock_api: AsyncMock) -> None: + """Test that we can import a config entry.""" + import_data = { + CONF_UUID: "import-uuid", + CONF_HOST: "importhost", + CONF_NAME: "2.8.0", + CONF_PLATFORM: "volkszaehler", + CONF_MONITORED_CONDITIONS: ["consumption"], + } + await async_setup_component(hass, "sensor", {"sensor": import_data}) + + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.data == { + CONF_HOST: "importhost", + CONF_PORT: 80, + } + assert entry.title == "importhost" + assert len(entry.subentries) == 1 + subentry = next(iter(entry.subentries.values())) + assert subentry.subentry_type is SUBENTRY_TYPE_CHANNEL + assert subentry.title == "2.8.0" + assert subentry.data == {CONF_UUID: "import-uuid"} + + assert mock_api.get_data.call_count == 2 + + +async def test_import_once( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: AsyncMock, +) -> None: + """Test that we import a config entry only once.""" + mock_config_entry.add_to_hass(hass) + + import_data = { + CONF_UUID: "existing-uuid", + CONF_HOST: "localhost", + CONF_PORT: 80, + CONF_PLATFORM: "volkszaehler", + } + await async_setup_component(hass, "sensor", {"sensor": import_data}) + + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].entry_id == mock_config_entry.entry_id + assert len(entries[0].subentries) == 1 + + assert mock_api.get_data.call_count == 1 + + +async def test_import_add_second_subentry_same_host( + hass: HomeAssistant, mock_api: AsyncMock +) -> None: + """Test that import adds a second channel to the existing entry on same host.""" + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + CONF_UUID: "import-uuid-1", + CONF_HOST: "importhost", + CONF_PORT: 8080, + CONF_PLATFORM: "volkszaehler", + }, + { + CONF_UUID: "import-uuid-2", + CONF_HOST: "importhost", + CONF_PORT: 8080, + CONF_PLATFORM: "volkszaehler", + }, + ] + }, + ) + + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert len(entries[0].subentries) == 2 + + assert mock_api.get_data.call_count == 3 + + +async def test_import_validation_error( + hass: HomeAssistant, mock_api: AsyncMock +) -> None: + """Test that import aborts when input validation fails.""" + mock_api.get_data.side_effect = VolkszaehlerApiConnectionError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "import"}, + data={ + CONF_UUID: "import-uuid", + CONF_HOST: "importhost", + CONF_PORT: 80, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_user_duplicate_uuid_from_entry_unique_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: AsyncMock, +) -> None: + """Test user flow duplicate UUID detection from an entry unique_id.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_UUID: "existing-uuid", + CONF_HOST: "new-host", + CONF_PORT: 80, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_api.get_data.call_count == 1 + + +async def test_import_same_host_different_port_creates_new_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: AsyncMock, +) -> None: + """Test import with same host and different port creates a new entry.""" + mock_config_entry.add_to_hass(hass) + + import_data = { + CONF_UUID: "import-uuid-new-port", + CONF_HOST: "localhost", + CONF_PORT: 8080, + CONF_PLATFORM: "volkszaehler", + } + await async_setup_component(hass, "sensor", {"sensor": import_data}) + + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + assert any( + entry.data == {CONF_HOST: "localhost", CONF_PORT: 8080} for entry in entries + ) + + assert mock_api.get_data.call_count == 3 + + +async def test_subentry_duplicate_uuid( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that subentry flow aborts for duplicate UUID.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_CHANNEL), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_UUID: "existing-uuid"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_subentry_validation_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: AsyncMock, +) -> None: + """Test that subentry flow returns form error on validation failure.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_CHANNEL), + context={"source": SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + + mock_api.get_data.side_effect = VolkszaehlerApiConnectionError + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_UUID: "new-uuid"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" + + mock_api.get_data.side_effect = None + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_UUID: "new-uuid"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "new-uuid" + assert result["data"] == {CONF_UUID: "new-uuid"} diff --git a/tests/components/volkszaehler/test_init.py b/tests/components/volkszaehler/test_init.py new file mode 100644 index 0000000000000..6c7f2475ba44b --- /dev/null +++ b/tests/components/volkszaehler/test_init.py @@ -0,0 +1,61 @@ +"""Tests for the Volkszaehler integration setup.""" + +from unittest.mock import AsyncMock + +from volkszaehler.exceptions import VolkszaehlerApiConnectionError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: AsyncMock, +) -> None: + """Test successful setup and unload of a config entry.""" + mock_api.data = {"rows": []} + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert "existing-subentry-id" in mock_config_entry.runtime_data + + assert 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 + + +async def test_setup_entry_connection_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: AsyncMock, +) -> None: + """Test setup retry when the API cannot be reached.""" + mock_api.get_data.side_effect = VolkszaehlerApiConnectionError() + + mock_config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_no_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: AsyncMock, +) -> None: + """Test setup retry when the API returns no data.""" + mock_api.data = None + + mock_config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 09c85a208f95a..662b3e72262c0 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -692,6 +692,8 @@ (10, UnitOfEnergy.MEGA_CALORIE, 0.011622222, UnitOfEnergy.MEGA_WATT_HOUR), (10, UnitOfEnergy.GIGA_CALORIE, 10000, UnitOfEnergy.MEGA_CALORIE), (10, UnitOfEnergy.GIGA_CALORIE, 11.622222, UnitOfEnergy.MEGA_WATT_HOUR), + (10, UnitOfEnergy.KILO_WATT_HOUR, 0.3412956, UnitOfEnergy.THERM), + (10, UnitOfEnergy.THERM, 293.001111, UnitOfEnergy.KILO_WATT_HOUR), ], EnergyDistanceConverter: [ (