Add new MyNeomitis integration (#151377)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Leo Periou
2026-02-23 17:14:30 +01:00
committed by GitHub
parent 6fba886edb
commit 733d381a7c
19 changed files with 1265 additions and 0 deletions

2
CODEOWNERS generated
View File

@@ -1082,6 +1082,8 @@ build.json @home-assistant/supervisor
/tests/components/mutesync/ @currentoor
/homeassistant/components/my/ @home-assistant/core
/tests/components/my/ @home-assistant/core
/homeassistant/components/myneomitis/ @l-pr
/tests/components/myneomitis/ @l-pr
/homeassistant/components/mysensors/ @MartinHjelmare @functionpointer
/tests/components/mysensors/ @MartinHjelmare @functionpointer
/homeassistant/components/mystrom/ @fabaff

View File

@@ -0,0 +1,130 @@
"""Integration for MyNeomitis."""
from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import Any
import aiohttp
import pyaxencoapi
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_EMAIL,
CONF_PASSWORD,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.SELECT]
@dataclass
class MyNeomitisRuntimeData:
"""Runtime data for MyNeomitis integration."""
api: pyaxencoapi.PyAxencoAPI
devices: list[dict[str, Any]]
type MyNeomitisConfigEntry = ConfigEntry[MyNeomitisRuntimeData]
async def async_setup_entry(hass: HomeAssistant, entry: MyNeomitisConfigEntry) -> bool:
"""Set up MyNeomitis from a config entry."""
session = async_get_clientsession(hass)
email: str = entry.data[CONF_EMAIL]
password: str = entry.data[CONF_PASSWORD]
api = pyaxencoapi.PyAxencoAPI(session)
connected = False
try:
await api.login(email, password)
await api.connect_websocket()
connected = True
_LOGGER.debug("Successfully connected to Login/WebSocket")
# Retrieve the user's devices
devices: list[dict[str, Any]] = await api.get_devices()
except aiohttp.ClientResponseError as err:
if connected:
try:
await api.disconnect_websocket()
except (
TimeoutError,
ConnectionError,
aiohttp.ClientError,
) as disconnect_err:
_LOGGER.error(
"Error while disconnecting WebSocket for %s: %s",
entry.entry_id,
disconnect_err,
)
if err.status == 401:
raise ConfigEntryAuthFailed(
"Authentication failed, please update your credentials"
) from err
raise ConfigEntryNotReady(f"Error connecting to API: {err}") from err
except (TimeoutError, ConnectionError, aiohttp.ClientError) as err:
if connected:
try:
await api.disconnect_websocket()
except (
TimeoutError,
ConnectionError,
aiohttp.ClientError,
) as disconnect_err:
_LOGGER.error(
"Error while disconnecting WebSocket for %s: %s",
entry.entry_id,
disconnect_err,
)
raise ConfigEntryNotReady(f"Error connecting to API/WebSocket: {err}") from err
entry.runtime_data = MyNeomitisRuntimeData(api=api, devices=devices)
async def _async_disconnect_websocket(_event: Event) -> None:
"""Disconnect WebSocket on Home Assistant shutdown."""
try:
await api.disconnect_websocket()
except (TimeoutError, ConnectionError, aiohttp.ClientError) as err:
_LOGGER.error(
"Error while disconnecting WebSocket for %s: %s",
entry.entry_id,
err,
)
entry.async_on_unload(
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, _async_disconnect_websocket
)
)
# Load platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: MyNeomitisConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
try:
await entry.runtime_data.api.disconnect_websocket()
except (TimeoutError, ConnectionError) as err:
_LOGGER.error(
"Error while disconnecting WebSocket for %s: %s",
entry.entry_id,
err,
)
return unload_ok

View File

@@ -0,0 +1,78 @@
"""Config flow for MyNeomitis integration."""
import logging
from typing import Any
import aiohttp
from pyaxencoapi import PyAxencoAPI
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_USER_ID, DOMAIN
_LOGGER = logging.getLogger(__name__)
class MyNeoConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the configuration flow for the MyNeomitis integration."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step of the configuration flow."""
errors: dict[str, str] = {}
if user_input is not None:
email: str = user_input[CONF_EMAIL]
password: str = user_input[CONF_PASSWORD]
session = async_get_clientsession(self.hass)
api = PyAxencoAPI(session)
try:
await api.login(email, password)
except aiohttp.ClientResponseError as e:
if e.status == 401:
errors["base"] = "invalid_auth"
elif e.status >= 500:
errors["base"] = "cannot_connect"
else:
errors["base"] = "unknown"
except aiohttp.ClientConnectionError:
errors["base"] = "cannot_connect"
except aiohttp.ClientError:
errors["base"] = "unknown"
except Exception:
_LOGGER.exception("Unexpected error during login")
errors["base"] = "unknown"
if not errors:
# Prevent duplicate configuration with the same user ID
await self.async_set_unique_id(api.user_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"MyNeomitis ({email})",
data={
CONF_EMAIL: email,
CONF_PASSWORD: password,
CONF_USER_ID: api.user_id,
},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): str,
}
),
errors=errors,
)

View File

@@ -0,0 +1,4 @@
"""Constants for the MyNeomitis integration."""
DOMAIN = "myneomitis"
CONF_USER_ID = "user_id"

View File

@@ -0,0 +1,31 @@
{
"entity": {
"select": {
"pilote": {
"state": {
"antifrost": "mdi:snowflake",
"auto": "mdi:refresh-auto",
"boost": "mdi:rocket-launch",
"comfort": "mdi:fire",
"eco": "mdi:leaf",
"eco_1": "mdi:leaf",
"eco_2": "mdi:leaf",
"standby": "mdi:toggle-switch-off-outline"
}
},
"relais": {
"state": {
"auto": "mdi:refresh-auto",
"off": "mdi:toggle-switch-off-outline",
"on": "mdi:toggle-switch"
}
},
"ufh": {
"state": {
"cooling": "mdi:snowflake",
"heating": "mdi:fire"
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
{
"domain": "myneomitis",
"name": "MyNeomitis",
"codeowners": ["@l-pr"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/myneomitis",
"integration_type": "hub",
"iot_class": "cloud_push",
"quality_scale": "bronze",
"requirements": ["pyaxencoapi==1.0.6"]
}

View File

@@ -0,0 +1,76 @@
rules:
# Bronze tier rules
action-setup:
status: exempt
comment: Integration does not register service actions.
appropriate-polling:
status: exempt
comment: Integration uses WebSocket push updates, not polling.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not provide service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver tier rules
action-exceptions:
status: exempt
comment: Integration does not provide service actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration has no configuration parameters beyond initial setup.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates:
status: exempt
comment: Integration uses WebSocket callbacks to push updates directly to entities, not coordinator-based polling.
reauthentication-flow: todo
test-coverage: done
# Gold tier rules
devices: todo
diagnostics: todo
discovery-update-info:
status: exempt
comment: Integration is cloud-based and does not use local discovery.
discovery:
status: exempt
comment: Integration requires manual authentication via cloud service.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum tier rules
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,208 @@
"""Select entities for MyNeomitis integration.
This module defines and sets up the select entities for the MyNeomitis integration.
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import Any
from pyaxencoapi import PyAxencoAPI
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MyNeomitisConfigEntry
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
SUPPORTED_MODELS: frozenset[str] = frozenset({"EWS"})
SUPPORTED_SUB_MODELS: frozenset[str] = frozenset({"UFH"})
PRESET_MODE_MAP = {
"comfort": 1,
"eco": 2,
"antifrost": 3,
"standby": 4,
"boost": 6,
"setpoint": 8,
"comfort_plus": 20,
"eco_1": 40,
"eco_2": 41,
"auto": 60,
}
PRESET_MODE_MAP_RELAIS = {
"on": 1,
"off": 2,
"auto": 60,
}
PRESET_MODE_MAP_UFH = {
"heating": 0,
"cooling": 1,
}
REVERSE_PRESET_MODE_MAP = {v: k for k, v in PRESET_MODE_MAP.items()}
REVERSE_PRESET_MODE_MAP_RELAIS = {v: k for k, v in PRESET_MODE_MAP_RELAIS.items()}
REVERSE_PRESET_MODE_MAP_UFH = {v: k for k, v in PRESET_MODE_MAP_UFH.items()}
@dataclass(frozen=True, kw_only=True)
class MyNeoSelectEntityDescription(SelectEntityDescription):
"""Describe MyNeomitis select entity."""
preset_mode_map: dict[str, int]
reverse_preset_mode_map: dict[int, str]
state_key: str
SELECT_TYPES: dict[str, MyNeoSelectEntityDescription] = {
"relais": MyNeoSelectEntityDescription(
key="relais",
translation_key="relais",
options=list(PRESET_MODE_MAP_RELAIS),
preset_mode_map=PRESET_MODE_MAP_RELAIS,
reverse_preset_mode_map=REVERSE_PRESET_MODE_MAP_RELAIS,
state_key="targetMode",
),
"pilote": MyNeoSelectEntityDescription(
key="pilote",
translation_key="pilote",
options=list(PRESET_MODE_MAP),
preset_mode_map=PRESET_MODE_MAP,
reverse_preset_mode_map=REVERSE_PRESET_MODE_MAP,
state_key="targetMode",
),
"ufh": MyNeoSelectEntityDescription(
key="ufh",
translation_key="ufh",
options=list(PRESET_MODE_MAP_UFH),
preset_mode_map=PRESET_MODE_MAP_UFH,
reverse_preset_mode_map=REVERSE_PRESET_MODE_MAP_UFH,
state_key="changeOverUser",
),
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MyNeomitisConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Select entities from a config entry."""
api = config_entry.runtime_data.api
devices = config_entry.runtime_data.devices
def _create_entity(device: dict) -> MyNeoSelect:
"""Create a select entity for a device."""
if device["model"] == "EWS":
# According to the MyNeomitis API, EWS "relais" devices expose a "relayMode"
# field in their state, while "pilote" devices do not. We therefore use the
# presence of "relayMode" as an explicit heuristic to distinguish relais
# from pilote devices. If the upstream API changes this behavior, this
# detection logic must be revisited.
if "relayMode" in device.get("state", {}):
description = SELECT_TYPES["relais"]
else:
description = SELECT_TYPES["pilote"]
else: # UFH
description = SELECT_TYPES["ufh"]
return MyNeoSelect(api, device, description)
select_entities = [
_create_entity(device)
for device in devices
if device["model"] in SUPPORTED_MODELS | SUPPORTED_SUB_MODELS
]
async_add_entities(select_entities)
class MyNeoSelect(SelectEntity):
"""Select entity for MyNeomitis devices."""
entity_description: MyNeoSelectEntityDescription
_attr_has_entity_name = True
_attr_name = None # Entity represents the device itself
_attr_should_poll = False
def __init__(
self,
api: PyAxencoAPI,
device: dict[str, Any],
description: MyNeoSelectEntityDescription,
) -> None:
"""Initialize the MyNeoSelect entity."""
self.entity_description = description
self._api = api
self._device = device
self._attr_unique_id = device["_id"]
self._attr_available = device["connected"]
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, device["_id"])},
name=device["name"],
manufacturer="Axenco",
model=device["model"],
)
# Set current option based on device state
current_mode = device.get("state", {}).get(description.state_key)
self._attr_current_option = description.reverse_preset_mode_map.get(
current_mode
)
self._unavailable_logged: bool = False
async def async_added_to_hass(self) -> None:
"""Register listener when entity is added to hass."""
await super().async_added_to_hass()
if unsubscribe := self._api.register_listener(
self._device["_id"], self.handle_ws_update
):
self.async_on_remove(unsubscribe)
@callback
def handle_ws_update(self, new_state: dict[str, Any]) -> None:
"""Handle WebSocket updates for the device."""
if not new_state:
return
if "connected" in new_state:
self._attr_available = new_state["connected"]
if not self._attr_available:
if not self._unavailable_logged:
_LOGGER.info("The entity %s is unavailable", self.entity_id)
self._unavailable_logged = True
elif self._unavailable_logged:
_LOGGER.info("The entity %s is back online", self.entity_id)
self._unavailable_logged = False
# Check for state updates using the description's state_key
state_key = self.entity_description.state_key
if state_key in new_state:
mode = new_state.get(state_key)
if mode is not None:
self._attr_current_option = (
self.entity_description.reverse_preset_mode_map.get(mode)
)
self.async_write_ha_state()
async def async_select_option(self, option: str) -> None:
"""Send the new mode via the API."""
mode_code = self.entity_description.preset_mode_map.get(option)
if mode_code is None:
_LOGGER.warning("Unknown mode selected: %s", option)
return
await self._api.set_device_mode(self._device["_id"], mode_code)
self._attr_current_option = option
self.async_write_ha_state()

View File

@@ -0,0 +1,57 @@
{
"config": {
"abort": {
"already_configured": "This integration is already configured."
},
"error": {
"cannot_connect": "Could not connect to the MyNeomitis service. Please try again later.",
"invalid_auth": "Authentication failed. Please check your email address and password.",
"unknown": "An unexpected error occurred. Please try again."
},
"step": {
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "Your email address used for your MyNeomitis account",
"password": "Your MyNeomitis account password"
},
"description": "Enter your MyNeomitis account credentials.",
"title": "Connect to MyNeomitis"
}
}
},
"entity": {
"select": {
"pilote": {
"state": {
"antifrost": "Frost protection",
"auto": "[%key:common::state::auto%]",
"boost": "Boost",
"comfort": "Comfort",
"comfort_plus": "Comfort +",
"eco": "Eco",
"eco_1": "Eco -1",
"eco_2": "Eco -2",
"setpoint": "Setpoint",
"standby": "[%key:common::state::standby%]"
}
},
"relais": {
"state": {
"auto": "[%key:common::state::auto%]",
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]"
}
},
"ufh": {
"state": {
"cooling": "Cooling",
"heating": "Heating"
}
}
}
}
}

View File

@@ -451,6 +451,7 @@ FLOWS = {
"mullvad",
"music_assistant",
"mutesync",
"myneomitis",
"mysensors",
"mystrom",
"myuplink",

View File

@@ -4415,6 +4415,12 @@
"config_flow": false,
"iot_class": "local_push"
},
"myneomitis": {
"name": "MyNeomitis",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_push"
},
"mysensors": {
"name": "MySensors",
"integration_type": "hub",

3
requirements_all.txt generated
View File

@@ -1955,6 +1955,9 @@ pyatv==0.17.0
# homeassistant.components.aussie_broadband
pyaussiebb==0.1.5
# homeassistant.components.myneomitis
pyaxencoapi==1.0.6
# homeassistant.components.balboa
pybalboa==1.1.3

View File

@@ -1686,6 +1686,9 @@ pyatv==0.17.0
# homeassistant.components.aussie_broadband
pyaussiebb==0.1.5
# homeassistant.components.myneomitis
pyaxencoapi==1.0.6
# homeassistant.components.balboa
pybalboa==1.1.3

View File

@@ -0,0 +1 @@
"""Tests for the MyNeomitis integration."""

View File

@@ -0,0 +1,63 @@
"""conftest.py for myneomitis integration tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, Mock, patch
import pytest
from homeassistant.components.myneomitis.const import CONF_USER_ID, DOMAIN
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from tests.common import MockConfigEntry
@pytest.fixture
def mock_pyaxenco_client() -> Generator[AsyncMock]:
"""Mock the PyAxencoAPI client across the integration."""
with (
patch(
"pyaxencoapi.PyAxencoAPI",
autospec=True,
) as mock_client,
patch(
"homeassistant.components.myneomitis.config_flow.PyAxencoAPI",
new=mock_client,
),
):
client = mock_client.return_value
client.login = AsyncMock()
client.connect_websocket = AsyncMock()
client.get_devices = AsyncMock(return_value=[])
client.disconnect_websocket = AsyncMock()
client.set_device_mode = AsyncMock()
client.register_listener = Mock(return_value=Mock())
client.user_id = "user-123"
client.token = "tok"
client.refresh_token = "rtok"
yield client
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return a mocked config entry for the MyNeoMitis integration."""
return MockConfigEntry(
title="MyNeomitis (test@example.com)",
domain=DOMAIN,
data={
CONF_EMAIL: "test@example.com",
CONF_PASSWORD: "password123",
CONF_USER_ID: "user-123",
},
unique_id="user-123",
)
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Prevent running the real integration setup during tests."""
with patch(
"homeassistant.components.myneomitis.async_setup_entry",
return_value=True,
) as mock_setup:
yield mock_setup

View File

@@ -0,0 +1,193 @@
# serializer version: 1
# name: test_entities[select.pilote_device-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'comfort',
'eco',
'antifrost',
'standby',
'boost',
'setpoint',
'comfort_plus',
'eco_1',
'eco_2',
'auto',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': None,
'entity_id': 'select.pilote_device',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'myneomitis',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'pilote',
'unique_id': 'pilote1',
'unit_of_measurement': None,
})
# ---
# name: test_entities[select.pilote_device-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Pilote Device',
'options': list([
'comfort',
'eco',
'antifrost',
'standby',
'boost',
'setpoint',
'comfort_plus',
'eco_1',
'eco_2',
'auto',
]),
}),
'context': <ANY>,
'entity_id': 'select.pilote_device',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'comfort',
})
# ---
# name: test_entities[select.relais_device-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'on',
'off',
'auto',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': None,
'entity_id': 'select.relais_device',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'myneomitis',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'relais',
'unique_id': 'relais1',
'unit_of_measurement': None,
})
# ---
# name: test_entities[select.relais_device-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Relais Device',
'options': list([
'on',
'off',
'auto',
]),
}),
'context': <ANY>,
'entity_id': 'select.relais_device',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_entities[select.ufh_device-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'heating',
'cooling',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': None,
'entity_id': 'select.ufh_device',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'myneomitis',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'ufh',
'unique_id': 'ufh1',
'unit_of_measurement': None,
})
# ---
# name: test_entities[select.ufh_device-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'UFH Device',
'options': list([
'heating',
'cooling',
]),
}),
'context': <ANY>,
'entity_id': 'select.ufh_device',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heating',
})
# ---

View File

@@ -0,0 +1,126 @@
"""Test the configuration flow for MyNeomitis integration."""
from unittest.mock import AsyncMock
from aiohttp import ClientConnectionError, ClientError, ClientResponseError, RequestInfo
import pytest
from yarl import URL
from homeassistant.components.myneomitis.const import CONF_USER_ID, DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
TEST_EMAIL = "test@example.com"
TEST_PASSWORD = "password123"
def make_client_response_error(status: int) -> ClientResponseError:
"""Create a mock ClientResponseError with the given status code."""
request_info = RequestInfo(
url=URL("https://api.fake"),
method="POST",
headers={},
real_url=URL("https://api.fake"),
)
return ClientResponseError(
request_info=request_info,
history=(),
status=status,
message="error",
headers=None,
)
async def test_user_flow_success(
hass: HomeAssistant,
mock_pyaxenco_client: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test successful user flow for MyNeomitis integration."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"MyNeomitis ({TEST_EMAIL})"
assert result["data"] == {
CONF_EMAIL: TEST_EMAIL,
CONF_PASSWORD: TEST_PASSWORD,
CONF_USER_ID: "user-123",
}
assert result["result"].unique_id == "user-123"
@pytest.mark.parametrize(
("side_effect", "expected_error"),
[
(ClientConnectionError(), "cannot_connect"),
(make_client_response_error(401), "invalid_auth"),
(make_client_response_error(403), "unknown"),
(make_client_response_error(500), "cannot_connect"),
(ClientError("Network error"), "unknown"),
(RuntimeError("boom"), "unknown"),
],
)
async def test_flow_errors(
hass: HomeAssistant,
mock_pyaxenco_client: AsyncMock,
mock_setup_entry: AsyncMock,
side_effect: Exception,
expected_error: str,
) -> None:
"""Test flow errors and recovery to CREATE_ENTRY."""
mock_pyaxenco_client.login.side_effect = side_effect
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"]["base"] == expected_error
mock_pyaxenco_client.login.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_abort_if_already_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_pyaxenco_client: AsyncMock,
) -> None:
"""Test abort when an entry for the same user_id already exists."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"

View File

@@ -0,0 +1,126 @@
"""Tests for the MyNeomitis integration."""
from unittest.mock import AsyncMock
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_minimal_setup(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_pyaxenco_client: AsyncMock,
) -> None:
"""Test the minimal setup of the MyNeomitis integration."""
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
async def test_setup_entry_raises_on_login_fail(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_pyaxenco_client: AsyncMock,
) -> None:
"""Test that async_setup_entry sets entry to retry if login fails."""
mock_pyaxenco_client.login.side_effect = TimeoutError("fail-login")
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_unload_entry_success(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_pyaxenco_client: AsyncMock,
) -> None:
"""Test that unloading via hass.config_entries.async_unload disconnects cleanly."""
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
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
mock_pyaxenco_client.disconnect_websocket.assert_awaited_once()
async def test_unload_entry_logs_on_disconnect_error(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
mock_config_entry: MockConfigEntry,
mock_pyaxenco_client: AsyncMock,
) -> None:
"""When disconnecting the websocket fails, an error is logged."""
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
mock_pyaxenco_client.disconnect_websocket.side_effect = TimeoutError("to")
caplog.set_level("ERROR")
result = await hass.config_entries.async_unload(mock_config_entry.entry_id)
assert result is True
assert "Error while disconnecting WebSocket" in caplog.text
async def test_homeassistant_stop_disconnects_websocket(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_pyaxenco_client: AsyncMock,
) -> None:
"""Test that WebSocket is disconnected on Home Assistant stop event."""
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
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()
mock_pyaxenco_client.disconnect_websocket.assert_awaited_once()
async def test_homeassistant_stop_logs_on_disconnect_error(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
mock_config_entry: MockConfigEntry,
mock_pyaxenco_client: AsyncMock,
) -> None:
"""Test that WebSocket disconnect errors are logged on HA stop."""
mock_pyaxenco_client.disconnect_websocket.side_effect = TimeoutError(
"disconnect failed"
)
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
caplog.set_level("ERROR")
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()
assert "Error while disconnecting WebSocket" in caplog.text

View File

@@ -0,0 +1,146 @@
"""Tests for the MyNeomitis select component."""
from unittest.mock import AsyncMock
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
RELAIS_DEVICE = {
"_id": "relais1",
"name": "Relais Device",
"model": "EWS",
"state": {"relayMode": 1, "targetMode": 2},
"connected": True,
"program": {"data": {}},
}
PILOTE_DEVICE = {
"_id": "pilote1",
"name": "Pilote Device",
"model": "EWS",
"state": {"targetMode": 1},
"connected": True,
"program": {"data": {}},
}
UFH_DEVICE = {
"_id": "ufh1",
"name": "UFH Device",
"model": "UFH",
"state": {"changeOverUser": 0},
"connected": True,
"program": {"data": {}},
}
async def test_entities(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_pyaxenco_client: AsyncMock,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all select entities are created for supported devices."""
mock_pyaxenco_client.get_devices.return_value = [
RELAIS_DEVICE,
PILOTE_DEVICE,
UFH_DEVICE,
{
"_id": "unsupported",
"name": "Unsupported Device",
"model": "UNKNOWN",
"state": {},
"connected": True,
"program": {"data": {}},
},
]
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_select_option(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_pyaxenco_client: AsyncMock,
) -> None:
"""Test that selecting an option propagates to the library correctly."""
mock_pyaxenco_client.get_devices.return_value = [RELAIS_DEVICE]
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await hass.services.async_call(
"select",
"select_option",
{ATTR_ENTITY_ID: "select.relais_device", "option": "on"},
blocking=True,
)
mock_pyaxenco_client.set_device_mode.assert_awaited_once_with("relais1", 1)
state = hass.states.get("select.relais_device")
assert state is not None
assert state.state == "on"
async def test_websocket_state_update(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_pyaxenco_client: AsyncMock,
) -> None:
"""Test that entity updates when source data changes via WebSocket."""
mock_pyaxenco_client.get_devices.return_value = [RELAIS_DEVICE]
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("select.relais_device")
assert state is not None
assert state.state == "off"
mock_pyaxenco_client.register_listener.assert_called_once()
callback = mock_pyaxenco_client.register_listener.call_args[0][1]
callback({"targetMode": 1})
await hass.async_block_till_done()
state = hass.states.get("select.relais_device")
assert state is not None
assert state.state == "on"
async def test_device_becomes_unavailable(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_pyaxenco_client: AsyncMock,
) -> None:
"""Test that entity becomes unavailable when device connection is lost."""
mock_pyaxenco_client.get_devices.return_value = [RELAIS_DEVICE]
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("select.relais_device")
assert state is not None
assert state.state == "off"
callback = mock_pyaxenco_client.register_listener.call_args[0][1]
callback({"connected": False})
await hass.async_block_till_done()
state = hass.states.get("select.relais_device")
assert state is not None
assert state.state == "unavailable"