1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-19 18:38:58 +00:00

Add Lunatone gateway integration (#149182)

Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
MoonDevLT
2025-10-02 13:55:17 +02:00
committed by GitHub
parent f5f6b22af1
commit 8dde94f421
21 changed files with 1180 additions and 0 deletions

View File

@@ -326,6 +326,7 @@ homeassistant.components.london_underground.*
homeassistant.components.lookin.*
homeassistant.components.lovelace.*
homeassistant.components.luftdaten.*
homeassistant.components.lunatone.*
homeassistant.components.madvr.*
homeassistant.components.manual.*
homeassistant.components.mastodon.*

2
CODEOWNERS generated
View File

@@ -910,6 +910,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/luci/ @mzdrale
/homeassistant/components/luftdaten/ @fabaff @frenck
/tests/components/luftdaten/ @fabaff @frenck
/homeassistant/components/lunatone/ @MoonDevLT
/tests/components/lunatone/ @MoonDevLT
/homeassistant/components/lupusec/ @majuss @suaveolent
/tests/components/lupusec/ @majuss @suaveolent
/homeassistant/components/lutron/ @cdheiser @wilburCForce

View File

@@ -0,0 +1,64 @@
"""The Lunatone integration."""
from typing import Final
from lunatone_rest_api_client import Auth, Devices, Info
from homeassistant.const import CONF_URL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .coordinator import (
LunatoneConfigEntry,
LunatoneData,
LunatoneDevicesDataUpdateCoordinator,
LunatoneInfoDataUpdateCoordinator,
)
PLATFORMS: Final[list[Platform]] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool:
"""Set up Lunatone from a config entry."""
auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL])
info_api = Info(auth_api)
devices_api = Devices(auth_api)
coordinator_info = LunatoneInfoDataUpdateCoordinator(hass, entry, info_api)
await coordinator_info.async_config_entry_first_refresh()
if info_api.serial_number is None:
raise ConfigEntryError(
translation_domain=DOMAIN, translation_key="missing_device_info"
)
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, str(info_api.serial_number))},
name=info_api.name,
manufacturer="Lunatone",
sw_version=info_api.version,
hw_version=info_api.data.device.pcb,
configuration_url=entry.data[CONF_URL],
serial_number=str(info_api.serial_number),
model_id=(
f"{info_api.data.device.article_number}{info_api.data.device.article_info}"
),
)
coordinator_devices = LunatoneDevicesDataUpdateCoordinator(hass, entry, devices_api)
await coordinator_devices.async_config_entry_first_refresh()
entry.runtime_data = LunatoneData(coordinator_info, coordinator_devices)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,83 @@
"""Config flow for Lunatone."""
from typing import Any, Final
import aiohttp
from lunatone_rest_api_client import Auth, Info
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_URL
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
DATA_SCHEMA: Final[vol.Schema] = vol.Schema(
{vol.Required(CONF_URL, default="http://"): cv.string},
)
def compose_title(name: str | None, serial_number: int) -> str:
"""Compose a title string from a given name and serial number."""
return f"{name or 'DALI Gateway'} {serial_number}"
class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN):
"""Lunatone config flow."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
errors: dict[str, str] = {}
if user_input is not None:
url = user_input[CONF_URL]
data = {CONF_URL: url}
self._async_abort_entries_match(data)
auth_api = Auth(
session=async_get_clientsession(self.hass),
base_url=url,
)
info_api = Info(auth_api)
try:
await info_api.async_update()
except aiohttp.InvalidUrlClientError:
errors["base"] = "invalid_url"
except aiohttp.ClientConnectionError:
errors["base"] = "cannot_connect"
else:
if info_api.data is None or info_api.serial_number is None:
errors["base"] = "missing_device_info"
else:
await self.async_set_unique_id(str(info_api.serial_number))
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data_updates=data,
title=compose_title(info_api.name, info_api.serial_number),
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=compose_title(info_api.name, info_api.serial_number),
data={CONF_URL: url},
)
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a reconfiguration flow initialized by the user."""
return await self.async_step_user(user_input)

View File

@@ -0,0 +1,5 @@
"""Constants for the Lunatone integration."""
from typing import Final
DOMAIN: Final = "lunatone"

View File

@@ -0,0 +1,101 @@
"""Coordinator for handling data fetching and updates."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import logging
import aiohttp
from lunatone_rest_api_client import Device, Devices, Info
from lunatone_rest_api_client.models import InfoData
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
DEFAULT_DEVICES_SCAN_INTERVAL = timedelta(seconds=10)
@dataclass
class LunatoneData:
"""Data for Lunatone integration."""
coordinator_info: LunatoneInfoDataUpdateCoordinator
coordinator_devices: LunatoneDevicesDataUpdateCoordinator
type LunatoneConfigEntry = ConfigEntry[LunatoneData]
class LunatoneInfoDataUpdateCoordinator(DataUpdateCoordinator[InfoData]):
"""Data update coordinator for Lunatone info."""
config_entry: LunatoneConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: LunatoneConfigEntry, info_api: Info
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}-info",
always_update=False,
)
self.info_api = info_api
async def _async_update_data(self) -> InfoData:
"""Update info data."""
try:
await self.info_api.async_update()
except aiohttp.ClientConnectionError as ex:
raise UpdateFailed(
"Unable to retrieve info data from Lunatone REST API"
) from ex
if self.info_api.data is None:
raise UpdateFailed("Did not receive info data from Lunatone REST API")
return self.info_api.data
class LunatoneDevicesDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Device]]):
"""Data update coordinator for Lunatone devices."""
config_entry: LunatoneConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: LunatoneConfigEntry,
devices_api: Devices,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}-devices",
always_update=False,
update_interval=DEFAULT_DEVICES_SCAN_INTERVAL,
)
self.devices_api = devices_api
async def _async_update_data(self) -> dict[int, Device]:
"""Update devices data."""
try:
await self.devices_api.async_update()
except aiohttp.ClientConnectionError as ex:
raise UpdateFailed(
"Unable to retrieve devices data from Lunatone REST API"
) from ex
if self.devices_api.data is None:
raise UpdateFailed("Did not receive devices data from Lunatone REST API")
return {device.id: device for device in self.devices_api.devices}

View File

@@ -0,0 +1,103 @@
"""Platform for Lunatone light integration."""
from __future__ import annotations
import asyncio
from typing import Any
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import LunatoneConfigEntry, LunatoneDevicesDataUpdateCoordinator
PARALLEL_UPDATES = 0
STATUS_UPDATE_DELAY = 0.04
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LunatoneConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Lunatone Light platform."""
coordinator_info = config_entry.runtime_data.coordinator_info
coordinator_devices = config_entry.runtime_data.coordinator_devices
async_add_entities(
[
LunatoneLight(
coordinator_devices, device_id, coordinator_info.data.device.serial
)
for device_id in coordinator_devices.data
]
)
class LunatoneLight(
CoordinatorEntity[LunatoneDevicesDataUpdateCoordinator], LightEntity
):
"""Representation of a Lunatone light."""
_attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF}
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
def __init__(
self,
coordinator: LunatoneDevicesDataUpdateCoordinator,
device_id: int,
interface_serial_number: int,
) -> None:
"""Initialize a LunatoneLight."""
super().__init__(coordinator=coordinator)
self._device_id = device_id
self._interface_serial_number = interface_serial_number
self._device = self.coordinator.data.get(self._device_id)
self._attr_unique_id = f"{interface_serial_number}-device{device_id}"
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
assert self.unique_id
name = self._device.name if self._device is not None else None
return DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
name=name,
via_device=(DOMAIN, str(self._interface_serial_number)),
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self._device is not None
@property
def is_on(self) -> bool:
"""Return True if light is on."""
return self._device is not None and self._device.is_on
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._device = self.coordinator.data.get(self._device_id)
self.async_write_ha_state()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
assert self._device
await self._device.switch_on()
await asyncio.sleep(STATUS_UPDATE_DELAY)
await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
assert self._device
await self._device.switch_off()
await asyncio.sleep(STATUS_UPDATE_DELAY)
await self.coordinator.async_refresh()

View File

@@ -0,0 +1,11 @@
{
"domain": "lunatone",
"name": "Lunatone",
"codeowners": ["@MoonDevLT"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/lunatone",
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["lunatone-rest-api-client==0.4.8"]
}

View File

@@ -0,0 +1,82 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules:
status: exempt
comment: |
This integration has only one platform which uses a coordinator.
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities of this integration does not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: no actions
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No options to configure
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
This integration does not require authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: todo
comment: Discovery not yet supported
discovery:
status: todo
comment: Discovery not yet supported
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: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: done
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,36 @@
{
"config": {
"step": {
"confirm": {
"description": "[%key:common::config_flow::description::confirm_setup%]"
},
"user": {
"description": "Connect to the API of your Lunatone DALI IoT Gateway.",
"data": {
"url": "[%key:common::config_flow::data::url%]"
},
"data_description": {
"url": "The URL of the Lunatone gateway device."
}
},
"reconfigure": {
"description": "Update the URL.",
"data": {
"url": "[%key:common::config_flow::data::url%]"
},
"data_description": {
"url": "[%key:component::lunatone::config::step::user::data_description::url%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_url": "Failed to connect. Check the URL and if the device is connected to power",
"missing_device_info": "Failed to read device information. Check the network connection of the device"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
}
}

View File

@@ -370,6 +370,7 @@ FLOWS = {
"lookin",
"loqed",
"luftdaten",
"lunatone",
"lupusec",
"lutron",
"lutron_caseta",

View File

@@ -3727,6 +3727,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"lunatone": {
"name": "Lunatone",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling"
},
"lupusec": {
"name": "Lupus Electronics LUPUSEC",
"integration_type": "hub",

10
mypy.ini generated
View File

@@ -3016,6 +3016,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.lunatone.*]
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.madvr.*]
check_untyped_defs = true
disallow_incomplete_defs = true

3
requirements_all.txt generated
View File

@@ -1399,6 +1399,9 @@ loqedAPI==2.1.10
# homeassistant.components.luftdaten
luftdaten==0.7.4
# homeassistant.components.lunatone
lunatone-rest-api-client==0.4.8
# homeassistant.components.lupusec
lupupy==0.3.2

View File

@@ -1200,6 +1200,9 @@ loqedAPI==2.1.10
# homeassistant.components.luftdaten
luftdaten==0.7.4
# homeassistant.components.lunatone
lunatone-rest-api-client==0.4.8
# homeassistant.components.lupusec
lupupy==0.3.2

View File

@@ -0,0 +1,76 @@
"""Tests for the Lunatone integration."""
from typing import Final
from lunatone_rest_api_client.models import (
DeviceData,
DeviceInfoData,
DevicesData,
FeaturesStatus,
InfoData,
)
from lunatone_rest_api_client.models.common import ColorRGBData, ColorWAFData, Status
from lunatone_rest_api_client.models.devices import DeviceStatus
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
BASE_URL: Final = "http://10.0.0.131"
SERIAL_NUMBER: Final = 12345
VERSION: Final = "v1.14.1/1.4.3"
DEVICE_DATA_LIST: Final[list[DeviceData]] = [
DeviceData(
id=1,
name="Device 1",
available=True,
status=DeviceStatus(),
features=FeaturesStatus(
switchable=Status[bool](status=False),
dimmable=Status[float](status=0.0),
colorKelvin=Status[int](status=1000),
colorRGB=Status[ColorRGBData](status=ColorRGBData(r=0, g=0, b=0)),
colorWAF=Status[ColorWAFData](status=ColorWAFData(w=0, a=0, f=0)),
),
address=0,
line=0,
),
DeviceData(
id=2,
name="Device 2",
available=True,
status=DeviceStatus(),
features=FeaturesStatus(
switchable=Status[bool](status=False),
dimmable=Status[float](status=0.0),
colorKelvin=Status[int](status=1000),
colorRGB=Status[ColorRGBData](status=ColorRGBData(r=0, g=0, b=0)),
colorWAF=Status[ColorWAFData](status=ColorWAFData(w=0, a=0, f=0)),
),
address=1,
line=0,
),
]
DEVICES_DATA: Final[DevicesData] = DevicesData(devices=DEVICE_DATA_LIST)
INFO_DATA: Final[InfoData] = InfoData(
name="Test",
version=VERSION,
device=DeviceInfoData(
serial=SERIAL_NUMBER,
gtin=192837465,
pcb="2a",
articleNumber=87654321,
productionYear=20,
productionWeek=1,
),
)
async def setup_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Set up the Lunatone integration for testing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()

View File

@@ -0,0 +1,82 @@
"""Fixtures for Lunatone tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, PropertyMock, patch
from lunatone_rest_api_client import Device, Devices
import pytest
from homeassistant.components.lunatone.const import DOMAIN
from homeassistant.const import CONF_URL
from . import BASE_URL, DEVICES_DATA, INFO_DATA, SERIAL_NUMBER
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.lunatone.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_lunatone_devices() -> Generator[AsyncMock]:
"""Mock a Lunatone devices object."""
def build_devices_mock(devices: Devices):
device_list = []
for device_data in devices.data.devices:
device = AsyncMock(spec=Device)
device.data = device_data
device.id = device.data.id
device.name = device.data.name
device.is_on = device.data.features.switchable.status
device_list.append(device)
return device_list
with patch(
"homeassistant.components.lunatone.Devices", autospec=True
) as mock_devices:
devices = mock_devices.return_value
devices.data = DEVICES_DATA
type(devices).devices = PropertyMock(
side_effect=lambda d=devices: build_devices_mock(d)
)
yield devices
@pytest.fixture
def mock_lunatone_info() -> Generator[AsyncMock]:
"""Mock a Lunatone info object."""
with (
patch(
"homeassistant.components.lunatone.Info",
autospec=True,
) as mock_info,
patch(
"homeassistant.components.lunatone.config_flow.Info",
new=mock_info,
),
):
info = mock_info.return_value
info.data = INFO_DATA
info.name = info.data.name
info.version = info.data.version
info.serial_number = info.data.device.serial
yield info
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title=f"Lunatone {SERIAL_NUMBER}",
domain=DOMAIN,
data={CONF_URL: BASE_URL},
unique_id=str(SERIAL_NUMBER),
)

View File

@@ -0,0 +1,115 @@
# serializer version: 1
# name: test_setup[light.device_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'supported_color_modes': list([
<ColorMode.ONOFF: 'onoff'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': None,
'entity_id': 'light.device_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'lunatone',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '12345-device1',
'unit_of_measurement': None,
})
# ---
# name: test_setup[light.device_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'color_mode': None,
'friendly_name': 'Device 1',
'supported_color_modes': list([
<ColorMode.ONOFF: 'onoff'>,
]),
'supported_features': <LightEntityFeature: 0>,
}),
'context': <ANY>,
'entity_id': 'light.device_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_setup[light.device_2-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'supported_color_modes': list([
<ColorMode.ONOFF: 'onoff'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': None,
'entity_id': 'light.device_2',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'lunatone',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '12345-device2',
'unit_of_measurement': None,
})
# ---
# name: test_setup[light.device_2-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'color_mode': None,
'friendly_name': 'Device 2',
'supported_color_modes': list([
<ColorMode.ONOFF: 'onoff'>,
]),
'supported_features': <LightEntityFeature: 0>,
}),
'context': <ANY>,
'entity_id': 'light.device_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@@ -0,0 +1,184 @@
"""Define tests for the Lunatone config flow."""
from unittest.mock import AsyncMock
import aiohttp
import pytest
from homeassistant.components.lunatone.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import BASE_URL, SERIAL_NUMBER
from tests.common import MockConfigEntry
async def test_full_flow(
hass: HomeAssistant, mock_lunatone_info: AsyncMock, mock_setup_entry: AsyncMock
) -> None:
"""Test full user flow."""
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"],
{CONF_URL: BASE_URL},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"Test {SERIAL_NUMBER}"
assert result["data"] == {CONF_URL: BASE_URL}
async def test_full_flow_fail_because_of_missing_device_infos(
hass: HomeAssistant,
mock_lunatone_info: AsyncMock,
) -> None:
"""Test full flow."""
mock_lunatone_info.data = None
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"],
{CONF_URL: BASE_URL},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "missing_device_info"}
async def test_device_already_configured(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test that the flow is aborted when the device is already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "user"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_URL: BASE_URL},
)
assert result2.get("type") is FlowResultType.ABORT
assert result2.get("reason") == "already_configured"
@pytest.mark.parametrize(
("exception", "expected_error"),
[
(aiohttp.InvalidUrlClientError(BASE_URL), "invalid_url"),
(aiohttp.ClientConnectionError(), "cannot_connect"),
],
)
async def test_user_step_fail_with_error(
hass: HomeAssistant,
mock_lunatone_info: AsyncMock,
exception: Exception,
expected_error: str,
) -> None:
"""Test user step with an error."""
mock_lunatone_info.async_update.side_effect = exception
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"],
{CONF_URL: BASE_URL},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": expected_error}
mock_lunatone_info.async_update.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_URL: BASE_URL},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"Test {SERIAL_NUMBER}"
assert result["data"] == {CONF_URL: BASE_URL}
async def test_reconfigure(
hass: HomeAssistant,
mock_lunatone_info: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfigure flow."""
url = "http://10.0.0.100"
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_URL: url}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data == {CONF_URL: url}
@pytest.mark.parametrize(
("exception", "expected_error"),
[
(aiohttp.InvalidUrlClientError(BASE_URL), "invalid_url"),
(aiohttp.ClientConnectionError(), "cannot_connect"),
],
)
async def test_reconfigure_fail_with_error(
hass: HomeAssistant,
mock_lunatone_info: AsyncMock,
mock_config_entry: MockConfigEntry,
exception: Exception,
expected_error: str,
) -> None:
"""Test reconfigure flow with an error."""
url = "http://10.0.0.100"
mock_lunatone_info.async_update.side_effect = exception
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_URL: url}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": expected_error}
mock_lunatone_info.async_update.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_URL: url}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data == {CONF_URL: url}

View File

@@ -0,0 +1,133 @@
"""Tests for the Lunatone integration."""
from unittest.mock import AsyncMock
import aiohttp
from homeassistant.components.lunatone.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import BASE_URL, VERSION, setup_integration
from tests.common import MockConfigEntry
async def test_load_unload_config_entry(
hass: HomeAssistant,
mock_lunatone_devices: AsyncMock,
mock_lunatone_info: AsyncMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test the Lunatone configuration entry loading/unloading."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, mock_config_entry.unique_id)}
)
assert device_entry is not None
assert device_entry.manufacturer == "Lunatone"
assert device_entry.sw_version == VERSION
assert device_entry.configuration_url == BASE_URL
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert not hass.data.get(DOMAIN)
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_config_entry_not_ready_info_api_fail(
hass: HomeAssistant,
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the Lunatone configuration entry not ready due to a failure in the info API."""
mock_lunatone_info.async_update.side_effect = aiohttp.ClientConnectionError()
await setup_integration(hass, mock_config_entry)
mock_lunatone_info.async_update.assert_called_once()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
mock_lunatone_info.async_update.side_effect = None
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
mock_lunatone_info.async_update.assert_called()
assert mock_config_entry.state is ConfigEntryState.LOADED
async def test_config_entry_not_ready_devices_api_fail(
hass: HomeAssistant,
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the Lunatone configuration entry not ready due to a failure in the devices API."""
mock_lunatone_devices.async_update.side_effect = aiohttp.ClientConnectionError()
await setup_integration(hass, mock_config_entry)
mock_lunatone_info.async_update.assert_called_once()
mock_lunatone_devices.async_update.assert_called_once()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
mock_lunatone_devices.async_update.side_effect = None
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
mock_lunatone_info.async_update.assert_called()
mock_lunatone_devices.async_update.assert_called()
assert mock_config_entry.state is ConfigEntryState.LOADED
async def test_config_entry_not_ready_no_info_data(
hass: HomeAssistant,
mock_lunatone_info: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the Lunatone configuration entry not ready due to missing info data."""
mock_lunatone_info.data = None
await setup_integration(hass, mock_config_entry)
mock_lunatone_info.async_update.assert_called_once()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_config_entry_not_ready_no_devices_data(
hass: HomeAssistant,
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the Lunatone configuration entry not ready due to missing devices data."""
mock_lunatone_devices.data = None
await setup_integration(hass, mock_config_entry)
mock_lunatone_info.async_update.assert_called_once()
mock_lunatone_devices.async_update.assert_called_once()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_config_entry_not_ready_no_serial_number(
hass: HomeAssistant,
mock_lunatone_info: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the Lunatone configuration entry not ready due to a missing serial number."""
mock_lunatone_info.serial_number = None
await setup_integration(hass, mock_config_entry)
mock_lunatone_info.async_update.assert_called_once()
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR

View File

@@ -0,0 +1,79 @@
"""Tests for the Lunatone integration."""
from unittest.mock import AsyncMock
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry
TEST_ENTITY_ID = "light.device_1"
async def test_setup(
hass: HomeAssistant,
mock_lunatone_devices: AsyncMock,
mock_lunatone_info: AsyncMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the Lunatone configuration entry loading/unloading."""
await setup_integration(hass, mock_config_entry)
entities = hass.states.async_all(Platform.LIGHT)
for entity_state in entities:
entity_entry = entity_registry.async_get(entity_state.entity_id)
assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry")
assert entity_state == snapshot(name=f"{entity_entry.entity_id}-state")
async def test_turn_on_off(
hass: HomeAssistant,
mock_lunatone_devices: AsyncMock,
mock_lunatone_info: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test the light can be turned on and off."""
await setup_integration(hass, mock_config_entry)
async def fake_update():
device = mock_lunatone_devices.data.devices[0]
device.features.switchable.status = not device.features.switchable.status
mock_lunatone_devices.async_update.side_effect = fake_update
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
blocking=True,
)
state = hass.states.get(TEST_ENTITY_ID)
assert state.state == STATE_ON
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
blocking=True,
)
state = hass.states.get(TEST_ENTITY_ID)
assert state.state == STATE_OFF