Skip to content

Commit 8dde94f

Browse files
MoonDevLTabmantisCopilot
authored
Add Lunatone gateway integration (home-assistant#149182)
Co-authored-by: Abílio Costa <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent f5f6b22 commit 8dde94f

21 files changed

+1180
-0
lines changed

.strict-typing

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ homeassistant.components.london_underground.*
326326
homeassistant.components.lookin.*
327327
homeassistant.components.lovelace.*
328328
homeassistant.components.luftdaten.*
329+
homeassistant.components.lunatone.*
329330
homeassistant.components.madvr.*
330331
homeassistant.components.manual.*
331332
homeassistant.components.mastodon.*

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""The Lunatone integration."""
2+
3+
from typing import Final
4+
5+
from lunatone_rest_api_client import Auth, Devices, Info
6+
7+
from homeassistant.const import CONF_URL, Platform
8+
from homeassistant.core import HomeAssistant
9+
from homeassistant.exceptions import ConfigEntryError
10+
from homeassistant.helpers import device_registry as dr
11+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
12+
13+
from .const import DOMAIN
14+
from .coordinator import (
15+
LunatoneConfigEntry,
16+
LunatoneData,
17+
LunatoneDevicesDataUpdateCoordinator,
18+
LunatoneInfoDataUpdateCoordinator,
19+
)
20+
21+
PLATFORMS: Final[list[Platform]] = [Platform.LIGHT]
22+
23+
24+
async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool:
25+
"""Set up Lunatone from a config entry."""
26+
auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL])
27+
info_api = Info(auth_api)
28+
devices_api = Devices(auth_api)
29+
30+
coordinator_info = LunatoneInfoDataUpdateCoordinator(hass, entry, info_api)
31+
await coordinator_info.async_config_entry_first_refresh()
32+
33+
if info_api.serial_number is None:
34+
raise ConfigEntryError(
35+
translation_domain=DOMAIN, translation_key="missing_device_info"
36+
)
37+
38+
device_registry = dr.async_get(hass)
39+
device_registry.async_get_or_create(
40+
config_entry_id=entry.entry_id,
41+
identifiers={(DOMAIN, str(info_api.serial_number))},
42+
name=info_api.name,
43+
manufacturer="Lunatone",
44+
sw_version=info_api.version,
45+
hw_version=info_api.data.device.pcb,
46+
configuration_url=entry.data[CONF_URL],
47+
serial_number=str(info_api.serial_number),
48+
model_id=(
49+
f"{info_api.data.device.article_number}{info_api.data.device.article_info}"
50+
),
51+
)
52+
53+
coordinator_devices = LunatoneDevicesDataUpdateCoordinator(hass, entry, devices_api)
54+
await coordinator_devices.async_config_entry_first_refresh()
55+
56+
entry.runtime_data = LunatoneData(coordinator_info, coordinator_devices)
57+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
58+
59+
return True
60+
61+
62+
async def async_unload_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool:
63+
"""Unload a config entry."""
64+
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Config flow for Lunatone."""
2+
3+
from typing import Any, Final
4+
5+
import aiohttp
6+
from lunatone_rest_api_client import Auth, Info
7+
import voluptuous as vol
8+
9+
from homeassistant.config_entries import (
10+
SOURCE_RECONFIGURE,
11+
ConfigFlow,
12+
ConfigFlowResult,
13+
)
14+
from homeassistant.const import CONF_URL
15+
from homeassistant.helpers import config_validation as cv
16+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
17+
18+
from .const import DOMAIN
19+
20+
DATA_SCHEMA: Final[vol.Schema] = vol.Schema(
21+
{vol.Required(CONF_URL, default="http://"): cv.string},
22+
)
23+
24+
25+
def compose_title(name: str | None, serial_number: int) -> str:
26+
"""Compose a title string from a given name and serial number."""
27+
return f"{name or 'DALI Gateway'} {serial_number}"
28+
29+
30+
class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN):
31+
"""Lunatone config flow."""
32+
33+
VERSION = 1
34+
MINOR_VERSION = 1
35+
36+
async def async_step_user(
37+
self, user_input: dict[str, Any] | None = None
38+
) -> ConfigFlowResult:
39+
"""Handle a flow initialized by the user."""
40+
errors: dict[str, str] = {}
41+
if user_input is not None:
42+
url = user_input[CONF_URL]
43+
data = {CONF_URL: url}
44+
self._async_abort_entries_match(data)
45+
auth_api = Auth(
46+
session=async_get_clientsession(self.hass),
47+
base_url=url,
48+
)
49+
info_api = Info(auth_api)
50+
try:
51+
await info_api.async_update()
52+
except aiohttp.InvalidUrlClientError:
53+
errors["base"] = "invalid_url"
54+
except aiohttp.ClientConnectionError:
55+
errors["base"] = "cannot_connect"
56+
else:
57+
if info_api.data is None or info_api.serial_number is None:
58+
errors["base"] = "missing_device_info"
59+
else:
60+
await self.async_set_unique_id(str(info_api.serial_number))
61+
if self.source == SOURCE_RECONFIGURE:
62+
self._abort_if_unique_id_mismatch()
63+
return self.async_update_reload_and_abort(
64+
self._get_reconfigure_entry(),
65+
data_updates=data,
66+
title=compose_title(info_api.name, info_api.serial_number),
67+
)
68+
self._abort_if_unique_id_configured()
69+
return self.async_create_entry(
70+
title=compose_title(info_api.name, info_api.serial_number),
71+
data={CONF_URL: url},
72+
)
73+
return self.async_show_form(
74+
step_id="user",
75+
data_schema=DATA_SCHEMA,
76+
errors=errors,
77+
)
78+
79+
async def async_step_reconfigure(
80+
self, user_input: dict[str, Any] | None = None
81+
) -> ConfigFlowResult:
82+
"""Handle a reconfiguration flow initialized by the user."""
83+
return await self.async_step_user(user_input)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Constants for the Lunatone integration."""
2+
3+
from typing import Final
4+
5+
DOMAIN: Final = "lunatone"
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Coordinator for handling data fetching and updates."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from datetime import timedelta
7+
import logging
8+
9+
import aiohttp
10+
from lunatone_rest_api_client import Device, Devices, Info
11+
from lunatone_rest_api_client.models import InfoData
12+
13+
from homeassistant.config_entries import ConfigEntry
14+
from homeassistant.core import HomeAssistant
15+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
16+
17+
from .const import DOMAIN
18+
19+
_LOGGER = logging.getLogger(__name__)
20+
21+
DEFAULT_DEVICES_SCAN_INTERVAL = timedelta(seconds=10)
22+
23+
24+
@dataclass
25+
class LunatoneData:
26+
"""Data for Lunatone integration."""
27+
28+
coordinator_info: LunatoneInfoDataUpdateCoordinator
29+
coordinator_devices: LunatoneDevicesDataUpdateCoordinator
30+
31+
32+
type LunatoneConfigEntry = ConfigEntry[LunatoneData]
33+
34+
35+
class LunatoneInfoDataUpdateCoordinator(DataUpdateCoordinator[InfoData]):
36+
"""Data update coordinator for Lunatone info."""
37+
38+
config_entry: LunatoneConfigEntry
39+
40+
def __init__(
41+
self, hass: HomeAssistant, config_entry: LunatoneConfigEntry, info_api: Info
42+
) -> None:
43+
"""Initialize the coordinator."""
44+
super().__init__(
45+
hass,
46+
_LOGGER,
47+
config_entry=config_entry,
48+
name=f"{DOMAIN}-info",
49+
always_update=False,
50+
)
51+
self.info_api = info_api
52+
53+
async def _async_update_data(self) -> InfoData:
54+
"""Update info data."""
55+
try:
56+
await self.info_api.async_update()
57+
except aiohttp.ClientConnectionError as ex:
58+
raise UpdateFailed(
59+
"Unable to retrieve info data from Lunatone REST API"
60+
) from ex
61+
62+
if self.info_api.data is None:
63+
raise UpdateFailed("Did not receive info data from Lunatone REST API")
64+
return self.info_api.data
65+
66+
67+
class LunatoneDevicesDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Device]]):
68+
"""Data update coordinator for Lunatone devices."""
69+
70+
config_entry: LunatoneConfigEntry
71+
72+
def __init__(
73+
self,
74+
hass: HomeAssistant,
75+
config_entry: LunatoneConfigEntry,
76+
devices_api: Devices,
77+
) -> None:
78+
"""Initialize the coordinator."""
79+
super().__init__(
80+
hass,
81+
_LOGGER,
82+
config_entry=config_entry,
83+
name=f"{DOMAIN}-devices",
84+
always_update=False,
85+
update_interval=DEFAULT_DEVICES_SCAN_INTERVAL,
86+
)
87+
self.devices_api = devices_api
88+
89+
async def _async_update_data(self) -> dict[int, Device]:
90+
"""Update devices data."""
91+
try:
92+
await self.devices_api.async_update()
93+
except aiohttp.ClientConnectionError as ex:
94+
raise UpdateFailed(
95+
"Unable to retrieve devices data from Lunatone REST API"
96+
) from ex
97+
98+
if self.devices_api.data is None:
99+
raise UpdateFailed("Did not receive devices data from Lunatone REST API")
100+
101+
return {device.id: device for device in self.devices_api.devices}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Platform for Lunatone light integration."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
from typing import Any
7+
8+
from homeassistant.components.light import ColorMode, LightEntity
9+
from homeassistant.core import HomeAssistant, callback
10+
from homeassistant.helpers.device_registry import DeviceInfo
11+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
12+
from homeassistant.helpers.update_coordinator import CoordinatorEntity
13+
14+
from .const import DOMAIN
15+
from .coordinator import LunatoneConfigEntry, LunatoneDevicesDataUpdateCoordinator
16+
17+
PARALLEL_UPDATES = 0
18+
STATUS_UPDATE_DELAY = 0.04
19+
20+
21+
async def async_setup_entry(
22+
hass: HomeAssistant,
23+
config_entry: LunatoneConfigEntry,
24+
async_add_entities: AddConfigEntryEntitiesCallback,
25+
) -> None:
26+
"""Set up the Lunatone Light platform."""
27+
coordinator_info = config_entry.runtime_data.coordinator_info
28+
coordinator_devices = config_entry.runtime_data.coordinator_devices
29+
30+
async_add_entities(
31+
[
32+
LunatoneLight(
33+
coordinator_devices, device_id, coordinator_info.data.device.serial
34+
)
35+
for device_id in coordinator_devices.data
36+
]
37+
)
38+
39+
40+
class LunatoneLight(
41+
CoordinatorEntity[LunatoneDevicesDataUpdateCoordinator], LightEntity
42+
):
43+
"""Representation of a Lunatone light."""
44+
45+
_attr_color_mode = ColorMode.ONOFF
46+
_attr_supported_color_modes = {ColorMode.ONOFF}
47+
_attr_has_entity_name = True
48+
_attr_name = None
49+
_attr_should_poll = False
50+
51+
def __init__(
52+
self,
53+
coordinator: LunatoneDevicesDataUpdateCoordinator,
54+
device_id: int,
55+
interface_serial_number: int,
56+
) -> None:
57+
"""Initialize a LunatoneLight."""
58+
super().__init__(coordinator=coordinator)
59+
self._device_id = device_id
60+
self._interface_serial_number = interface_serial_number
61+
self._device = self.coordinator.data.get(self._device_id)
62+
self._attr_unique_id = f"{interface_serial_number}-device{device_id}"
63+
64+
@property
65+
def device_info(self) -> DeviceInfo:
66+
"""Return the device info."""
67+
assert self.unique_id
68+
name = self._device.name if self._device is not None else None
69+
return DeviceInfo(
70+
identifiers={(DOMAIN, self.unique_id)},
71+
name=name,
72+
via_device=(DOMAIN, str(self._interface_serial_number)),
73+
)
74+
75+
@property
76+
def available(self) -> bool:
77+
"""Return True if entity is available."""
78+
return super().available and self._device is not None
79+
80+
@property
81+
def is_on(self) -> bool:
82+
"""Return True if light is on."""
83+
return self._device is not None and self._device.is_on
84+
85+
@callback
86+
def _handle_coordinator_update(self) -> None:
87+
"""Handle updated data from the coordinator."""
88+
self._device = self.coordinator.data.get(self._device_id)
89+
self.async_write_ha_state()
90+
91+
async def async_turn_on(self, **kwargs: Any) -> None:
92+
"""Instruct the light to turn on."""
93+
assert self._device
94+
await self._device.switch_on()
95+
await asyncio.sleep(STATUS_UPDATE_DELAY)
96+
await self.coordinator.async_refresh()
97+
98+
async def async_turn_off(self, **kwargs: Any) -> None:
99+
"""Instruct the light to turn off."""
100+
assert self._device
101+
await self._device.switch_off()
102+
await asyncio.sleep(STATUS_UPDATE_DELAY)
103+
await self.coordinator.async_refresh()
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"domain": "lunatone",
3+
"name": "Lunatone",
4+
"codeowners": ["@MoonDevLT"],
5+
"config_flow": true,
6+
"documentation": "https://www.home-assistant.io/integrations/lunatone",
7+
"integration_type": "hub",
8+
"iot_class": "local_polling",
9+
"quality_scale": "silver",
10+
"requirements": ["lunatone-rest-api-client==0.4.8"]
11+
}

0 commit comments

Comments
 (0)