mirror of
https://github.com/home-assistant/core.git
synced 2025-12-20 02:48:57 +00:00
Add iNELS integration (#125595)
This commit is contained in:
@@ -278,6 +278,7 @@ homeassistant.components.imap.*
|
||||
homeassistant.components.imgw_pib.*
|
||||
homeassistant.components.immich.*
|
||||
homeassistant.components.incomfort.*
|
||||
homeassistant.components.inels.*
|
||||
homeassistant.components.input_button.*
|
||||
homeassistant.components.input_select.*
|
||||
homeassistant.components.input_text.*
|
||||
|
||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -741,6 +741,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/improv_ble/ @emontnemery
|
||||
/homeassistant/components/incomfort/ @jbouwh
|
||||
/tests/components/incomfort/ @jbouwh
|
||||
/homeassistant/components/inels/ @epdevlab
|
||||
/tests/components/inels/ @epdevlab
|
||||
/homeassistant/components/influxdb/ @mdegat01
|
||||
/tests/components/influxdb/ @mdegat01
|
||||
/homeassistant/components/inkbird/ @bdraco
|
||||
|
||||
95
homeassistant/components/inels/__init__.py
Normal file
95
homeassistant/components/inels/__init__.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""The iNELS integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from inelsmqtt import InelsMqtt
|
||||
from inelsmqtt.devices import Device
|
||||
from inelsmqtt.discovery import InelsDiscovery
|
||||
|
||||
from homeassistant.components import mqtt as ha_mqtt
|
||||
from homeassistant.components.mqtt import (
|
||||
ReceiveMessage,
|
||||
async_prepare_subscribe_topics,
|
||||
async_subscribe_topics,
|
||||
async_unsubscribe_topics,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import LOGGER, PLATFORMS
|
||||
|
||||
type InelsConfigEntry = ConfigEntry[InelsData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class InelsData:
|
||||
"""Represents the data structure for INELS runtime data."""
|
||||
|
||||
mqtt: InelsMqtt
|
||||
devices: list[Device]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: InelsConfigEntry) -> bool:
|
||||
"""Set up iNELS from a config entry."""
|
||||
|
||||
async def mqtt_publish(topic: str, payload: str, qos: int, retain: bool) -> None:
|
||||
"""Publish an MQTT message using the Home Assistant MQTT client."""
|
||||
await ha_mqtt.async_publish(hass, topic, payload, qos, retain)
|
||||
|
||||
async def mqtt_subscribe(
|
||||
sub_state: dict[str, Any] | None,
|
||||
topic: str,
|
||||
callback_func: Callable[[str, str], None],
|
||||
) -> dict[str, Any]:
|
||||
"""Subscribe to MQTT topics using the Home Assistant MQTT client."""
|
||||
|
||||
@callback
|
||||
def mqtt_message_received(msg: ReceiveMessage) -> None:
|
||||
"""Handle iNELS mqtt messages."""
|
||||
# Payload is always str at runtime since we don't set encoding=None
|
||||
# HA uses UTF-8 by default
|
||||
callback_func(msg.topic, msg.payload) # type: ignore[arg-type]
|
||||
|
||||
topics = {
|
||||
"inels_subscribe_topic": {
|
||||
"topic": topic,
|
||||
"msg_callback": mqtt_message_received,
|
||||
}
|
||||
}
|
||||
|
||||
sub_state = async_prepare_subscribe_topics(hass, sub_state, topics)
|
||||
await async_subscribe_topics(hass, sub_state)
|
||||
return sub_state
|
||||
|
||||
async def mqtt_unsubscribe(sub_state: dict[str, Any]) -> None:
|
||||
async_unsubscribe_topics(hass, sub_state)
|
||||
|
||||
if not await ha_mqtt.async_wait_for_mqtt_client(hass):
|
||||
LOGGER.error("MQTT integration not available")
|
||||
raise ConfigEntryNotReady("MQTT integration not available")
|
||||
|
||||
inels_mqtt = InelsMqtt(mqtt_publish, mqtt_subscribe, mqtt_unsubscribe)
|
||||
devices: list[Device] = await InelsDiscovery(inels_mqtt).start()
|
||||
|
||||
# If no devices are discovered, continue with the setup
|
||||
if not devices:
|
||||
LOGGER.info("No devices discovered")
|
||||
|
||||
entry.runtime_data = InelsData(mqtt=inels_mqtt, devices=devices)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: InelsConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
await entry.runtime_data.mqtt.unsubscribe_topics()
|
||||
entry.runtime_data.mqtt.unsubscribe_listeners()
|
||||
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
73
homeassistant/components/inels/config_flow.py
Normal file
73
homeassistant/components/inels/config_flow.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Config flow for iNELS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components import mqtt
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
|
||||
|
||||
from .const import DOMAIN, TITLE
|
||||
|
||||
|
||||
class INelsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle of iNELS config flow."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_mqtt(
|
||||
self, discovery_info: MqttServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by MQTT discovery."""
|
||||
if self._async_in_progress():
|
||||
return self.async_abort(reason="already_in_progress")
|
||||
|
||||
# Validate the message, abort if it fails.
|
||||
if not discovery_info.topic.endswith("/gw"):
|
||||
# Not an iNELS discovery message.
|
||||
return self.async_abort(reason="invalid_discovery_info")
|
||||
if not discovery_info.payload:
|
||||
# Empty payload, unexpected payload.
|
||||
return self.async_abort(reason="invalid_discovery_info")
|
||||
|
||||
return await self.async_step_confirm_from_mqtt()
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by the user."""
|
||||
try:
|
||||
if not mqtt.is_connected(self.hass):
|
||||
return self.async_abort(reason="mqtt_not_connected")
|
||||
except KeyError:
|
||||
return self.async_abort(reason="mqtt_not_configured")
|
||||
|
||||
return await self.async_step_confirm_from_user()
|
||||
|
||||
async def step_confirm(
|
||||
self, step_id: str, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm the setup."""
|
||||
|
||||
if user_input is not None:
|
||||
await self.async_set_unique_id(DOMAIN)
|
||||
return self.async_create_entry(title=TITLE, data={})
|
||||
|
||||
return self.async_show_form(step_id=step_id)
|
||||
|
||||
async def async_step_confirm_from_mqtt(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm the setup from MQTT discovered."""
|
||||
return await self.step_confirm(
|
||||
step_id="confirm_from_mqtt", user_input=user_input
|
||||
)
|
||||
|
||||
async def async_step_confirm_from_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm the setup from user add integration."""
|
||||
return await self.step_confirm(
|
||||
step_id="confirm_from_user", user_input=user_input
|
||||
)
|
||||
14
homeassistant/components/inels/const.py
Normal file
14
homeassistant/components/inels/const.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Constants for the iNELS integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "inels"
|
||||
TITLE = "iNELS"
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
61
homeassistant/components/inels/entity.py
Normal file
61
homeassistant/components/inels/entity.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Base class for iNELS components."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from inelsmqtt.devices import Device
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class InelsBaseEntity(Entity):
|
||||
"""Base iNELS entity."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: Device,
|
||||
key: str,
|
||||
index: int,
|
||||
) -> None:
|
||||
"""Init base entity."""
|
||||
self._device = device
|
||||
self._device_id = device.unique_id
|
||||
self._attr_unique_id = self._device_id
|
||||
|
||||
# The referenced variable to read from
|
||||
self._key = key
|
||||
# The index of the variable list to read from. '-1' for no index
|
||||
self._index = index
|
||||
|
||||
info = device.info()
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.unique_id)},
|
||||
manufacturer=info.manufacturer,
|
||||
model=info.model_number,
|
||||
name=device.title,
|
||||
sw_version=info.sw_version,
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Add subscription of the data listener."""
|
||||
# Register the HA callback
|
||||
self._device.add_ha_callback(self._key, self._index, self._callback)
|
||||
# Subscribe to MQTT updates
|
||||
self._device.mqtt.subscribe_listener(
|
||||
self._device.state_topic, self._device.unique_id, self._device.callback
|
||||
)
|
||||
|
||||
def _callback(self) -> None:
|
||||
"""Get data from broker into the HA."""
|
||||
if hasattr(self, "hass"):
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return self._device.is_available
|
||||
15
homeassistant/components/inels/icons.json
Normal file
15
homeassistant/components/inels/icons.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"entity": {
|
||||
"switch": {
|
||||
"bit": {
|
||||
"default": "mdi:power-socket-eu"
|
||||
},
|
||||
"simple_relay": {
|
||||
"default": "mdi:power-socket-eu"
|
||||
},
|
||||
"relay": {
|
||||
"default": "mdi:power-socket-eu"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
homeassistant/components/inels/manifest.json
Normal file
13
homeassistant/components/inels/manifest.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"domain": "inels",
|
||||
"name": "iNELS",
|
||||
"codeowners": ["@epdevlab"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["mqtt"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/inels",
|
||||
"iot_class": "local_push",
|
||||
"mqtt": ["inels/status/#"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["elkoep-aio-mqtt==0.1.0b4"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
118
homeassistant/components/inels/quality_scale.yaml
Normal file
118
homeassistant/components/inels/quality_scale.yaml
Normal file
@@ -0,0 +1,118 @@
|
||||
rules:
|
||||
# Bronze
|
||||
config-flow: done
|
||||
test-before-configure: done
|
||||
unique-config-entry: done
|
||||
config-flow-test-coverage: done
|
||||
runtime-data: done
|
||||
test-before-setup:
|
||||
status: done
|
||||
comment: >
|
||||
Raise "Invalid authentication" and "MQTT Broker is offline or
|
||||
cannot be reached" otherwise, async_setup_entry returns False
|
||||
appropriate-polling:
|
||||
status: done
|
||||
comment: |
|
||||
Integration uses local_push.
|
||||
entity-unique-id:
|
||||
status: done
|
||||
comment: |
|
||||
{MAC}_{DEVICE_ID} is used, for example, 0e97f8b7d30_02E8.
|
||||
has-entity-name:
|
||||
status: done
|
||||
comment: >
|
||||
Almost all devices are multi-functional, which means that all functions
|
||||
are equally important -> keep the descriptive name (not setting _attr_name to None).
|
||||
entity-event-setup:
|
||||
status: done
|
||||
comment: |
|
||||
Subscribe in async_added_to_hass & unsubscribe from async_unload_entry.
|
||||
dependency-transparency: done
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
common-modules: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions:
|
||||
status: done
|
||||
comment: |
|
||||
A link to the wiki is provided.
|
||||
docs-removal-instructions: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
brands: done
|
||||
# Silver
|
||||
config-entry-unloading: done
|
||||
log-when-unavailable: todo
|
||||
entity-unavailable:
|
||||
status: done
|
||||
comment: |
|
||||
available property.
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
reauthentication-flow: todo
|
||||
parallel-updates:
|
||||
status: todo
|
||||
comment: |
|
||||
For all platforms, add a constant PARALLEL_UPDATES = 0.
|
||||
test-coverage: done
|
||||
integration-owner: done
|
||||
docs-installation-parameters:
|
||||
status: done
|
||||
comment: |
|
||||
A link to the wiki is provided.
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: >
|
||||
There is the same options flow in the integration as there is in the
|
||||
configuration.
|
||||
|
||||
# Gold
|
||||
entity-translations: done
|
||||
entity-device-class: todo
|
||||
devices: done
|
||||
entity-category: todo
|
||||
entity-disabled-by-default: todo
|
||||
discovery:
|
||||
status: todo
|
||||
comment: |
|
||||
Currently blocked by a hw limitation.
|
||||
stale-devices:
|
||||
status: todo
|
||||
comment: >
|
||||
Same as discovery. The async_remove_config_entry_device function should be
|
||||
implemented at a minimum.
|
||||
diagnostics: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
dynamic-devices: todo
|
||||
discovery-update-info:
|
||||
status: todo
|
||||
comment: |
|
||||
Same as discovery.
|
||||
repair-issues: todo
|
||||
docs-use-cases: todo
|
||||
docs-supported-devices:
|
||||
status: todo
|
||||
comment: >
|
||||
In regards to this and below doc requirements, I am not sure whether the
|
||||
wiki link is acceptable.
|
||||
docs-supported-functions: todo
|
||||
docs-data-update: todo
|
||||
docs-known-limitations: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-examples: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration is not making any HTTP requests.
|
||||
strict-typing: todo
|
||||
30
homeassistant/components/inels/strings.json
Normal file
30
homeassistant/components/inels/strings.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"confirm_from_user": {
|
||||
"description": "iNELS devices must be connected to the same broker as the Home Assistant MQTT integration client. Continue setup?"
|
||||
},
|
||||
"confirm_from_mqtt": {
|
||||
"description": "Do you want to set up iNELS?"
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"mqtt_not_connected": "Home Assistant MQTT integration not connected to MQTT broker.",
|
||||
"mqtt_not_configured": "Home Assistant MQTT integration not configured.",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"switch": {
|
||||
"bit": {
|
||||
"name": "Bit{addr}"
|
||||
},
|
||||
"simple_relay": {
|
||||
"name": "Simple relay{index}"
|
||||
},
|
||||
"relay": {
|
||||
"name": "Relay{index}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
137
homeassistant/components/inels/switch.py
Normal file
137
homeassistant/components/inels/switch.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""iNELS switch entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from inelsmqtt.devices import Device
|
||||
from inelsmqtt.utils.common import Bit, Relay, SimpleRelay
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import InelsConfigEntry
|
||||
from .entity import InelsBaseEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class InelsSwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Class describing iNELS switch entities."""
|
||||
|
||||
get_state_fn: Callable[[Device, int], Bit | SimpleRelay | Relay]
|
||||
alerts: list[str] | None = None
|
||||
placeholder_fn: Callable[[Device, int, bool], dict[str, str]]
|
||||
|
||||
|
||||
SWITCH_TYPES = [
|
||||
InelsSwitchEntityDescription(
|
||||
key="bit",
|
||||
translation_key="bit",
|
||||
get_state_fn=lambda device, index: device.state.bit[index],
|
||||
placeholder_fn=lambda device, index, indexed: {
|
||||
"addr": f" {device.state.bit[index].addr}"
|
||||
},
|
||||
),
|
||||
InelsSwitchEntityDescription(
|
||||
key="simple_relay",
|
||||
translation_key="simple_relay",
|
||||
get_state_fn=lambda device, index: device.state.simple_relay[index],
|
||||
placeholder_fn=lambda device, index, indexed: {
|
||||
"index": f" {index + 1}" if indexed else ""
|
||||
},
|
||||
),
|
||||
InelsSwitchEntityDescription(
|
||||
key="relay",
|
||||
translation_key="relay",
|
||||
get_state_fn=lambda device, index: device.state.relay[index],
|
||||
alerts=["overflow"],
|
||||
placeholder_fn=lambda device, index, indexed: {
|
||||
"index": f" {index + 1}" if indexed else ""
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: InelsConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Load iNELS switch."""
|
||||
entities: list[InelsSwitch] = []
|
||||
|
||||
for device in entry.runtime_data.devices:
|
||||
for description in SWITCH_TYPES:
|
||||
if hasattr(device.state, description.key):
|
||||
switch_count = len(getattr(device.state, description.key))
|
||||
entities.extend(
|
||||
InelsSwitch(
|
||||
device=device,
|
||||
description=description,
|
||||
index=idx,
|
||||
switch_count=switch_count,
|
||||
)
|
||||
for idx in range(switch_count)
|
||||
)
|
||||
|
||||
async_add_entities(entities, False)
|
||||
|
||||
|
||||
class InelsSwitch(InelsBaseEntity, SwitchEntity):
|
||||
"""The platform class required by Home Assistant."""
|
||||
|
||||
entity_description: InelsSwitchEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: Device,
|
||||
description: InelsSwitchEntityDescription,
|
||||
index: int = 0,
|
||||
switch_count: int = 1,
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(device=device, key=description.key, index=index)
|
||||
self.entity_description = description
|
||||
self._switch_count = switch_count
|
||||
|
||||
# Include index in unique_id for devices with multiple switches
|
||||
unique_key = f"{description.key}{index}" if index else description.key
|
||||
|
||||
self._attr_unique_id = f"{self._attr_unique_id}_{unique_key}".lower()
|
||||
|
||||
# Set translation placeholders
|
||||
self._attr_translation_placeholders = self.entity_description.placeholder_fn(
|
||||
self._device, self._index, self._switch_count > 1
|
||||
)
|
||||
|
||||
def _check_alerts(self, current_state: Bit | SimpleRelay | Relay) -> None:
|
||||
"""Check if there are active alerts and raise ServiceValidationError if found."""
|
||||
if self.entity_description.alerts and any(
|
||||
getattr(current_state, alert_key, None)
|
||||
for alert_key in self.entity_description.alerts
|
||||
):
|
||||
raise ServiceValidationError("Cannot operate switch with active alerts")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return if switch is on."""
|
||||
current_state = self.entity_description.get_state_fn(self._device, self._index)
|
||||
return current_state.is_on
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Instruct the switch to turn off."""
|
||||
current_state = self.entity_description.get_state_fn(self._device, self._index)
|
||||
self._check_alerts(current_state)
|
||||
current_state.is_on = False
|
||||
await self._device.set_ha_value(self._device.state)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Instruct the switch to turn on."""
|
||||
current_state = self.entity_description.get_state_fn(self._device, self._index)
|
||||
self._check_alerts(current_state)
|
||||
current_state.is_on = True
|
||||
await self._device.set_ha_value(self._device.state)
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -304,6 +304,7 @@ FLOWS = {
|
||||
"immich",
|
||||
"improv_ble",
|
||||
"incomfort",
|
||||
"inels",
|
||||
"inkbird",
|
||||
"insteon",
|
||||
"intellifire",
|
||||
|
||||
@@ -3029,6 +3029,13 @@
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "opower"
|
||||
},
|
||||
"inels": {
|
||||
"name": "iNELS",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push",
|
||||
"single_config_entry": true
|
||||
},
|
||||
"influxdb": {
|
||||
"name": "InfluxDB",
|
||||
"integration_type": "hub",
|
||||
|
||||
3
homeassistant/generated/mqtt.py
generated
3
homeassistant/generated/mqtt.py
generated
@@ -16,6 +16,9 @@ MQTT = {
|
||||
"fully_kiosk": [
|
||||
"fully/deviceInfo/+",
|
||||
],
|
||||
"inels": [
|
||||
"inels/status/#",
|
||||
],
|
||||
"pglab": [
|
||||
"pglab/discovery/#",
|
||||
],
|
||||
|
||||
10
mypy.ini
generated
10
mypy.ini
generated
@@ -2536,6 +2536,16 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.inels.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.input_button.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
3
requirements_all.txt
generated
3
requirements_all.txt
generated
@@ -876,6 +876,9 @@ eliqonline==1.2.2
|
||||
# homeassistant.components.elkm1
|
||||
elkm1-lib==2.2.11
|
||||
|
||||
# homeassistant.components.inels
|
||||
elkoep-aio-mqtt==0.1.0b4
|
||||
|
||||
# homeassistant.components.elmax
|
||||
elmax-api==0.0.6.4rc0
|
||||
|
||||
|
||||
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@@ -764,6 +764,9 @@ elgato==5.1.2
|
||||
# homeassistant.components.elkm1
|
||||
elkm1-lib==2.2.11
|
||||
|
||||
# homeassistant.components.inels
|
||||
elkoep-aio-mqtt==0.1.0b4
|
||||
|
||||
# homeassistant.components.elmax
|
||||
elmax-api==0.0.6.4rc0
|
||||
|
||||
|
||||
3
tests/components/inels/__init__.py
Normal file
3
tests/components/inels/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Tests for the iNELS integration."""
|
||||
|
||||
HA_INELS_PATH = "homeassistant.components.inels"
|
||||
133
tests/components/inels/common.py
Normal file
133
tests/components/inels/common.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Common methods used across tests."""
|
||||
|
||||
from homeassistant.components import inels
|
||||
from homeassistant.components.inels.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
__all__ = [
|
||||
"MockConfigEntry",
|
||||
"get_entity_id",
|
||||
"get_entity_state",
|
||||
"inels",
|
||||
"old_entity_and_device_removal",
|
||||
"set_mock_mqtt",
|
||||
]
|
||||
|
||||
MAC_ADDRESS = "001122334455"
|
||||
UNIQUE_ID = "C0FFEE"
|
||||
CONNECTED_INELS_VALUE = b"on\n"
|
||||
DISCONNECTED_INELS_VALUE = b"off\n"
|
||||
|
||||
|
||||
def get_entity_id(entity_config: dict, index: int) -> str:
|
||||
"""Construct the entity_id based on the entity_config."""
|
||||
unique_id = entity_config["unique_id"].lower()
|
||||
base_id = f"{entity_config['entity_type']}.{MAC_ADDRESS}_{unique_id}_{entity_config['device_type']}"
|
||||
return f"{base_id}{f'_{index:03}'}" if index is not None else base_id
|
||||
|
||||
|
||||
def get_entity_state(
|
||||
hass: HomeAssistant, entity_config: dict, index: int
|
||||
) -> State | None:
|
||||
"""Return the state of the entity from the state machine."""
|
||||
entity_id = get_entity_id(entity_config, index)
|
||||
return hass.states.get(entity_id)
|
||||
|
||||
|
||||
def set_mock_mqtt(
|
||||
mqtt,
|
||||
config: dict,
|
||||
status_value: bytes,
|
||||
device_available: bool = True,
|
||||
gw_available: bool = True,
|
||||
last_value=None,
|
||||
):
|
||||
"""Set mock mqtt communication."""
|
||||
gw_connected_value = '{"status":true}' if gw_available else '{"status":false}'
|
||||
device_connected_value = (
|
||||
CONNECTED_INELS_VALUE if device_available else DISCONNECTED_INELS_VALUE
|
||||
)
|
||||
|
||||
mqtt.mock_messages = {
|
||||
config["gw_connected_topic"]: gw_connected_value,
|
||||
config["connected_topic"]: device_connected_value,
|
||||
config["status_topic"]: status_value,
|
||||
}
|
||||
mqtt.mock_discovery_all = {config["base_topic"]: status_value}
|
||||
|
||||
if last_value is not None:
|
||||
mqtt.mock_last_value = {config["status_topic"]: last_value}
|
||||
else:
|
||||
mqtt.mock_last_value = {}
|
||||
|
||||
|
||||
async def old_entity_and_device_removal(
|
||||
hass: HomeAssistant, mock_mqtt, platform, entity_config, value_key, index
|
||||
):
|
||||
"""Test that old entities are correctly identified and removed across different platforms."""
|
||||
|
||||
set_mock_mqtt(
|
||||
mock_mqtt,
|
||||
config=entity_config,
|
||||
status_value=entity_config[value_key],
|
||||
gw_available=True,
|
||||
device_available=True,
|
||||
)
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
data={},
|
||||
domain=DOMAIN,
|
||||
title="iNELS",
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
# Create an old entity
|
||||
entity_registry = er.async_get(hass)
|
||||
old_entity = entity_registry.async_get_or_create(
|
||||
domain=platform,
|
||||
platform=DOMAIN,
|
||||
unique_id=f"old_{entity_config['unique_id']}",
|
||||
suggested_object_id=f"old_inels_{platform}_{entity_config['device_type']}",
|
||||
config_entry=config_entry,
|
||||
)
|
||||
|
||||
# Create a device and associate it with the old entity
|
||||
device_registry = dr.async_get(hass)
|
||||
device = device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry.entry_id,
|
||||
identifiers={(DOMAIN, f"old_{entity_config['unique_id']}")},
|
||||
name=f"iNELS {platform.capitalize()} {entity_config['device_type']}",
|
||||
manufacturer="iNELS",
|
||||
model=entity_config["device_type"],
|
||||
)
|
||||
|
||||
# Associate the old entity with the device
|
||||
entity_registry.async_update_entity(old_entity.entity_id, device_id=device.id)
|
||||
|
||||
assert (
|
||||
device_registry.async_get_device({(DOMAIN, old_entity.unique_id)}) is not None
|
||||
)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The device was discovered, and at this point, the async_remove_old_entities function was called
|
||||
assert config_entry.runtime_data.devices
|
||||
assert old_entity.entity_id not in config_entry.runtime_data.old_entities[platform]
|
||||
|
||||
# Get the new entity
|
||||
new_entity = entity_registry.async_get(get_entity_id(entity_config, index).lower())
|
||||
|
||||
assert new_entity is not None
|
||||
|
||||
# Verify that the new entity is in the registry
|
||||
assert entity_registry.async_get(new_entity.entity_id) is not None
|
||||
|
||||
# Verify that the old entity is no longer in the registry
|
||||
assert entity_registry.async_get(old_entity.entity_id) is None
|
||||
|
||||
# Verify that the device no longer exists in the registry
|
||||
assert device_registry.async_get_device({(DOMAIN, old_entity.unique_id)}) is None
|
||||
119
tests/components/inels/conftest.py
Normal file
119
tests/components/inels/conftest.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Test fixtures."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import HA_INELS_PATH
|
||||
from .common import DOMAIN, MockConfigEntry, get_entity_state, set_mock_mqtt
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_mqtt")
|
||||
def mock_inelsmqtt_fixture():
|
||||
"""Mock inels mqtt lib."""
|
||||
|
||||
def messages():
|
||||
"""Return mocked messages."""
|
||||
return mqtt.mock_messages
|
||||
|
||||
def last_value(topic):
|
||||
"""Mock last_value to return None if mock_last_value is empty or topic doesn't exist."""
|
||||
return mqtt.mock_last_value.get(topic) if mqtt.mock_last_value else None
|
||||
|
||||
async def discovery_all():
|
||||
"""Return mocked discovered devices."""
|
||||
return mqtt.mock_discovery_all
|
||||
|
||||
async def subscribe(topic, qos=0, options=None, properties=None):
|
||||
"""Mock subscribe fnc."""
|
||||
if isinstance(topic, list):
|
||||
return {t: mqtt.mock_messages.get(t) for t in topic}
|
||||
return mqtt.mock_messages.get(topic)
|
||||
|
||||
async def publish(topic, payload, qos=0, retain=True, properties=None):
|
||||
"""Mock publish to change value of the device."""
|
||||
mqtt.mock_messages[topic] = payload
|
||||
|
||||
unsubscribe_topics = AsyncMock()
|
||||
unsubscribe_listeners = Mock()
|
||||
|
||||
mqtt = Mock(
|
||||
messages=messages,
|
||||
subscribe=subscribe,
|
||||
publish=publish,
|
||||
last_value=last_value,
|
||||
discovery_all=discovery_all,
|
||||
unsubscribe_topics=unsubscribe_topics,
|
||||
unsubscribe_listeners=unsubscribe_listeners,
|
||||
mock_last_value=dict[str, Any](),
|
||||
mock_messages=dict[str, Any](),
|
||||
mock_discovery_all=dict[str, Any](),
|
||||
)
|
||||
|
||||
with (
|
||||
patch(f"{HA_INELS_PATH}.InelsMqtt", return_value=mqtt),
|
||||
):
|
||||
yield mqtt
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reload_entry():
|
||||
"""Mock the async_reload_entry function."""
|
||||
with patch(f"{HA_INELS_PATH}.async_reload_entry") as mock_reload:
|
||||
yield mock_reload
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_entity(hass: HomeAssistant, mock_mqtt):
|
||||
"""Set up an entity for testing with specified configuration and status."""
|
||||
|
||||
async def _setup(
|
||||
entity_config,
|
||||
status_value: bytes,
|
||||
device_available: bool = True,
|
||||
gw_available: bool = True,
|
||||
last_value=None,
|
||||
index: int | None = None,
|
||||
):
|
||||
set_mock_mqtt(
|
||||
mock_mqtt,
|
||||
config=entity_config,
|
||||
status_value=status_value,
|
||||
gw_available=gw_available,
|
||||
device_available=device_available,
|
||||
last_value=last_value,
|
||||
)
|
||||
await setup_inels_test_integration(hass)
|
||||
return get_entity_state(hass, entity_config, index)
|
||||
|
||||
return _setup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def entity_config(request: pytest.FixtureRequest):
|
||||
"""Fixture to provide parameterized entity configuration."""
|
||||
# This fixture will be parameterized in each test file
|
||||
return request.param
|
||||
|
||||
|
||||
async def setup_inels_test_integration(hass: HomeAssistant):
|
||||
"""Load inels integration with mocked mqtt broker."""
|
||||
hass.config.components.add(DOMAIN)
|
||||
|
||||
entry = MockConfigEntry(
|
||||
data={},
|
||||
domain=DOMAIN,
|
||||
title="iNELS",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with (
|
||||
patch(f"{HA_INELS_PATH}.ha_mqtt.async_wait_for_mqtt_client", return_value=True),
|
||||
):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert DOMAIN in hass.config.components
|
||||
170
tests/components/inels/test_config_flow.py
Normal file
170
tests/components/inels/test_config_flow.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Test the iNELS config flow."""
|
||||
|
||||
from homeassistant.components.inels.const import DOMAIN, TITLE
|
||||
from homeassistant.components.mqtt import MQTT_CONNECTION_STATE
|
||||
from homeassistant.config_entries import SOURCE_MQTT, SOURCE_USER
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.typing import MqttMockHAClient
|
||||
|
||||
|
||||
async def test_mqtt_config_single_instance(
|
||||
hass: HomeAssistant, mqtt_mock: MqttMockHAClient
|
||||
) -> None:
|
||||
"""The MQTT test flow is aborted if an entry already exists."""
|
||||
|
||||
MockConfigEntry(domain=DOMAIN).add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_MQTT}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "single_instance_allowed"
|
||||
|
||||
|
||||
async def test_mqtt_setup(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None:
|
||||
"""When an MQTT message is received on the discovery topic, it triggers a config flow."""
|
||||
discovery_info = MqttServiceInfo(
|
||||
topic="inels/status/MAC_ADDRESS/gw",
|
||||
payload='{"CUType":"CU3-08M","Status":"Runfast","FW":"02.97.18"}',
|
||||
qos=0,
|
||||
retain=False,
|
||||
subscribed_topic="inels/status/#",
|
||||
timestamp=None,
|
||||
)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == TITLE
|
||||
assert result["result"].data == {}
|
||||
|
||||
|
||||
async def test_mqtt_abort_invalid_topic(
|
||||
hass: HomeAssistant, mqtt_mock: MqttMockHAClient
|
||||
) -> None:
|
||||
"""Check MQTT flow aborts if discovery topic is invalid."""
|
||||
discovery_info = MqttServiceInfo(
|
||||
topic="inels/status/MAC_ADDRESS/wrong_topic",
|
||||
payload='{"CUType":"CU3-08M","Status":"Runfast","FW":"02.97.18"}',
|
||||
qos=0,
|
||||
retain=False,
|
||||
subscribed_topic="inels/status/#",
|
||||
timestamp=None,
|
||||
)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "invalid_discovery_info"
|
||||
|
||||
|
||||
async def test_mqtt_abort_empty_payload(
|
||||
hass: HomeAssistant, mqtt_mock: MqttMockHAClient
|
||||
) -> None:
|
||||
"""Check MQTT flow aborts if discovery payload is empty."""
|
||||
discovery_info = MqttServiceInfo(
|
||||
topic="inels/status/MAC_ADDRESS/gw",
|
||||
payload="",
|
||||
qos=0,
|
||||
retain=False,
|
||||
subscribed_topic="inels/status/#",
|
||||
timestamp=None,
|
||||
)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "invalid_discovery_info"
|
||||
|
||||
|
||||
async def test_mqtt_abort_already_in_progress(
|
||||
hass: HomeAssistant, mqtt_mock: MqttMockHAClient
|
||||
) -> None:
|
||||
"""Test that a second MQTT flow is aborted when one is already in progress."""
|
||||
discovery_info = MqttServiceInfo(
|
||||
topic="inels/status/MAC_ADDRESS/gw",
|
||||
payload='{"CUType":"CU3-08M","Status":"Runfast","FW":"02.97.18"}',
|
||||
qos=0,
|
||||
retain=False,
|
||||
subscribed_topic="inels/status/#",
|
||||
timestamp=None,
|
||||
)
|
||||
|
||||
# Start first MQTT flow
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
|
||||
# Try to start second MQTT flow while first is in progress
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_in_progress"
|
||||
|
||||
|
||||
async def test_user_setup(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None:
|
||||
"""Test if the user can finish a config flow."""
|
||||
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"], {})
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == TITLE
|
||||
assert result["result"].data == {}
|
||||
|
||||
|
||||
async def test_user_config_single_instance(
|
||||
hass: HomeAssistant, mqtt_mock: MqttMockHAClient
|
||||
) -> None:
|
||||
"""The user test flow is aborted if an entry already exists."""
|
||||
MockConfigEntry(domain=DOMAIN).add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "single_instance_allowed"
|
||||
|
||||
|
||||
async def test_user_setup_mqtt_not_connected(
|
||||
hass: HomeAssistant, mqtt_mock: MqttMockHAClient
|
||||
) -> None:
|
||||
"""The user setup test flow is aborted when MQTT is not connected."""
|
||||
|
||||
mqtt_mock.connected = False
|
||||
async_dispatcher_send(hass, MQTT_CONNECTION_STATE, False)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "mqtt_not_connected"
|
||||
|
||||
|
||||
async def test_user_setup_mqtt_not_configured(hass: HomeAssistant) -> None:
|
||||
"""The user setup test flow is aborted when MQTT is not configured."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "mqtt_not_configured"
|
||||
102
tests/components/inels/test_init.py
Normal file
102
tests/components/inels/test_init.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Tests for iNELS integration."""
|
||||
|
||||
from unittest.mock import ANY, AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from . import HA_INELS_PATH
|
||||
from .common import DOMAIN, inels
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.typing import MqttMockHAClient
|
||||
|
||||
|
||||
async def test_ha_mqtt_publish(
|
||||
hass: HomeAssistant, mqtt_mock: MqttMockHAClient
|
||||
) -> None:
|
||||
"""Test that MQTT publish function works correctly."""
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data={})
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with (
|
||||
patch(f"{HA_INELS_PATH}.InelsDiscovery") as mock_discovery_class,
|
||||
patch(
|
||||
"homeassistant.config_entries.ConfigEntries.async_forward_entry_setups",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
mock_discovery = AsyncMock()
|
||||
mock_discovery.start.return_value = []
|
||||
mock_discovery_class.return_value = mock_discovery
|
||||
|
||||
await inels.async_setup_entry(hass, config_entry)
|
||||
|
||||
topic, payload, qos, retain = "test/topic", "test_payload", 1, True
|
||||
|
||||
await config_entry.runtime_data.mqtt.publish(topic, payload, qos, retain)
|
||||
mqtt_mock.async_publish.assert_called_once_with(topic, payload, qos, retain)
|
||||
|
||||
|
||||
async def test_ha_mqtt_subscribe(
|
||||
hass: HomeAssistant, mqtt_mock: MqttMockHAClient
|
||||
) -> None:
|
||||
"""Test that MQTT subscribe function works correctly."""
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data={})
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with (
|
||||
patch(f"{HA_INELS_PATH}.InelsDiscovery") as mock_discovery_class,
|
||||
patch(
|
||||
"homeassistant.config_entries.ConfigEntries.async_forward_entry_setups",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
mock_discovery = AsyncMock()
|
||||
mock_discovery.start.return_value = []
|
||||
mock_discovery_class.return_value = mock_discovery
|
||||
|
||||
await inels.async_setup_entry(hass, config_entry)
|
||||
|
||||
topic = "test/topic"
|
||||
|
||||
await config_entry.runtime_data.mqtt.subscribe(topic)
|
||||
mqtt_mock.async_subscribe.assert_any_call(topic, ANY, 0, "utf-8", None)
|
||||
|
||||
|
||||
async def test_ha_mqtt_not_available(
|
||||
hass: HomeAssistant, mqtt_mock: MqttMockHAClient
|
||||
) -> None:
|
||||
"""Test that ConfigEntryNotReady is raised when MQTT is not available."""
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data={})
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.mqtt.async_wait_for_mqtt_client",
|
||||
return_value=False,
|
||||
),
|
||||
pytest.raises(ConfigEntryNotReady, match="MQTT integration not available"),
|
||||
):
|
||||
await inels.async_setup_entry(hass, config_entry)
|
||||
|
||||
|
||||
async def test_unload_entry(hass: HomeAssistant, mock_mqtt) -> None:
|
||||
"""Test unload entry."""
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data={})
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
config_entry.runtime_data = inels.InelsData(mqtt=mock_mqtt, devices=[])
|
||||
|
||||
with patch(
|
||||
"homeassistant.config_entries.ConfigEntries.async_unload_platforms",
|
||||
return_value=True,
|
||||
) as mock_unload_platforms:
|
||||
result = await inels.async_unload_entry(hass, config_entry)
|
||||
|
||||
assert result is True
|
||||
mock_mqtt.unsubscribe_topics.assert_called_once()
|
||||
mock_mqtt.unsubscribe_listeners.assert_called_once()
|
||||
mock_unload_platforms.assert_called_once()
|
||||
167
tests/components/inels/test_switch.py
Normal file
167
tests/components/inels/test_switch.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""iNELS switch platform testing."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.switch import (
|
||||
DOMAIN as SWITCH_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .common import MAC_ADDRESS, UNIQUE_ID, get_entity_id
|
||||
|
||||
DT_07 = "07"
|
||||
DT_100 = "100"
|
||||
DT_BITS = "bits"
|
||||
|
||||
|
||||
@pytest.fixture(params=["simple_relay", "relay", "bit"])
|
||||
def entity_config(request: pytest.FixtureRequest):
|
||||
"""Fixture to provide parameterized entity configuration for switch tests."""
|
||||
configs = {
|
||||
"simple_relay": {
|
||||
"entity_type": "switch",
|
||||
"device_type": "simple_relay",
|
||||
"dev_type": DT_07,
|
||||
"unique_id": UNIQUE_ID,
|
||||
"gw_connected_topic": f"inels/connected/{MAC_ADDRESS}/gw",
|
||||
"connected_topic": f"inels/connected/{MAC_ADDRESS}/{DT_07}/{UNIQUE_ID}",
|
||||
"status_topic": f"inels/status/{MAC_ADDRESS}/{DT_07}/{UNIQUE_ID}",
|
||||
"base_topic": f"{MAC_ADDRESS}/{DT_07}/{UNIQUE_ID}",
|
||||
"switch_on_value": "07\n01\n92\n09\n",
|
||||
"switch_off_value": "07\n00\n92\n09\n",
|
||||
},
|
||||
"relay": {
|
||||
"entity_type": "switch",
|
||||
"device_type": "relay",
|
||||
"dev_type": DT_100,
|
||||
"unique_id": UNIQUE_ID,
|
||||
"gw_connected_topic": f"inels/connected/{MAC_ADDRESS}/gw",
|
||||
"connected_topic": f"inels/connected/{MAC_ADDRESS}/{DT_100}/{UNIQUE_ID}",
|
||||
"status_topic": f"inels/status/{MAC_ADDRESS}/{DT_100}/{UNIQUE_ID}",
|
||||
"base_topic": f"{MAC_ADDRESS}/{DT_100}/{UNIQUE_ID}",
|
||||
"switch_on_value": "07\n00\n0A\n28\n00\n",
|
||||
"switch_off_value": "06\n00\n0A\n28\n00\n",
|
||||
"alerts": {
|
||||
"overflow": "06\n00\n0A\n28\n01\n",
|
||||
},
|
||||
},
|
||||
"bit": {
|
||||
"entity_type": "switch",
|
||||
"device_type": "bit",
|
||||
"dev_type": DT_BITS,
|
||||
"unique_id": UNIQUE_ID,
|
||||
"gw_connected_topic": f"inels/connected/{MAC_ADDRESS}/gw",
|
||||
"connected_topic": f"inels/connected/{MAC_ADDRESS}/{DT_BITS}/{UNIQUE_ID}",
|
||||
"status_topic": f"inels/status/{MAC_ADDRESS}/{DT_BITS}/{UNIQUE_ID}",
|
||||
"base_topic": f"{MAC_ADDRESS}/{DT_BITS}/{UNIQUE_ID}",
|
||||
"switch_on_value": b'{"state":{"000":1,"001":1}}',
|
||||
"switch_off_value": b'{"state":{"000":0,"001":0}}',
|
||||
},
|
||||
}
|
||||
return configs[request.param]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entity_config", ["simple_relay", "relay", "bit"], indirect=True
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("gw_available", "device_available", "expected_state"),
|
||||
[
|
||||
(True, False, STATE_UNAVAILABLE),
|
||||
(False, True, STATE_UNAVAILABLE),
|
||||
(True, True, STATE_ON),
|
||||
],
|
||||
)
|
||||
async def test_switch_availability(
|
||||
hass: HomeAssistant,
|
||||
setup_entity,
|
||||
entity_config,
|
||||
gw_available,
|
||||
device_available,
|
||||
expected_state,
|
||||
) -> None:
|
||||
"""Test switch availability and state under different gateway and device availability conditions."""
|
||||
|
||||
switch_state = await setup_entity(
|
||||
entity_config,
|
||||
status_value=entity_config["switch_on_value"],
|
||||
gw_available=gw_available,
|
||||
device_available=device_available,
|
||||
index=0 if entity_config["device_type"] == "bit" else None,
|
||||
)
|
||||
|
||||
assert switch_state is not None
|
||||
assert switch_state.state == expected_state
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_config", "index"),
|
||||
[
|
||||
("simple_relay", None),
|
||||
("relay", None),
|
||||
("bit", 0),
|
||||
],
|
||||
indirect=["entity_config"],
|
||||
)
|
||||
async def test_switch_turn_on(
|
||||
hass: HomeAssistant, setup_entity, entity_config, index
|
||||
) -> None:
|
||||
"""Test turning on a switch."""
|
||||
switch_state = await setup_entity(
|
||||
entity_config, status_value=entity_config["switch_off_value"], index=index
|
||||
)
|
||||
|
||||
assert switch_state is not None
|
||||
assert switch_state.state == STATE_OFF
|
||||
|
||||
with patch("inelsmqtt.devices.Device.set_ha_value") as mock_set_state:
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: get_entity_id(entity_config, index)},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
mock_set_state.assert_called_once()
|
||||
|
||||
ha_value = mock_set_state.call_args.args[0]
|
||||
assert getattr(ha_value, entity_config["device_type"])[0].is_on is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_config", "index"),
|
||||
[
|
||||
("simple_relay", None),
|
||||
("relay", None),
|
||||
("bit", 0),
|
||||
],
|
||||
indirect=["entity_config"],
|
||||
)
|
||||
async def test_switch_turn_off(
|
||||
hass: HomeAssistant, setup_entity, entity_config, index
|
||||
) -> None:
|
||||
"""Test turning off a switch."""
|
||||
switch_state = await setup_entity(
|
||||
entity_config, status_value=entity_config["switch_on_value"], index=index
|
||||
)
|
||||
|
||||
assert switch_state is not None
|
||||
assert switch_state.state == STATE_ON
|
||||
|
||||
with patch("inelsmqtt.devices.Device.set_ha_value") as mock_set_state:
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: get_entity_id(entity_config, index)},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
mock_set_state.assert_called_once()
|
||||
|
||||
ha_value = mock_set_state.call_args.args[0]
|
||||
assert getattr(ha_value, entity_config["device_type"])[0].is_on is False
|
||||
Reference in New Issue
Block a user