1
0
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:
epdevlab
2025-10-23 16:55:29 +02:00
committed by GitHub
parent 6e49911e1c
commit 2fce7db132
23 changed files with 1280 additions and 0 deletions

View File

@@ -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
View File

@@ -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

View 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)

View 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
)

View 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__)

View 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

View 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"
}
}
}
}

View 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
}

View 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

View 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}"
}
}
}
}

View 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)

View File

@@ -304,6 +304,7 @@ FLOWS = {
"immich",
"improv_ble",
"incomfort",
"inels",
"inkbird",
"insteon",
"intellifire",

View File

@@ -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",

View File

@@ -16,6 +16,9 @@ MQTT = {
"fully_kiosk": [
"fully/deviceInfo/+",
],
"inels": [
"inels/status/#",
],
"pglab": [
"pglab/discovery/#",
],

10
mypy.ini generated
View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,3 @@
"""Tests for the iNELS integration."""
HA_INELS_PATH = "homeassistant.components.inels"

View 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

View 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

View 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"

View 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()

View 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