Skip to content

Commit

Permalink
New Integration: SMLIGHT SLZB-06 Adapters Integration (home-assistant…
Browse files Browse the repository at this point in the history
…#118675)

* Initial SMLIGHT integration

Signed-off-by: Tim Lunn <tl@smlight.tech>

* Generated content

Signed-off-by: Tim Lunn <tl@smlight.tech>

* Cleanup LOGGING

* Use runtime data

* Call super first

* coordinator instance attributes

* Move coordinatorEntity and attr to base class

* cleanup sensors

* update strings to use sentence case

* Improve reauth flow on incorrect credentials

* Use fixture for config_flow tests and test to completion

* Split uptime hndling into a new uptime sensor entity

* Drop server side events and internet callback

will bring this back with binary sensor Platform

* consolidate coordinator setup

* entity always include connections

* get_hostname tweak

* Add tests for init, coordinator and sensor

* Use custom type SmConfigEntry

* update sensor snapshot

* Drop reauth flow for later PR

* Use _async_setup for initial setup

* drop internet to be set later

* sensor fixes

* config flow re

* typing fixes

* Bump pysmlight dependency to 0.0.12

* dont trigger invalid auth message when first loading auth step

* Merge uptime sensors back into main sensor class

* clarify uptime handling

* Apply suggestions from code review

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* address review comments

* pass host as parameter to the dataCoordinator

* drop uptime sensors for a later PR

* update sensor test snapshot

* move coordinator unique_id to _async_setup

* fix CI

* Apply suggestions from code review

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* drop invalid_auth test tag

* use snapshot_platform, update fixtures

* Finish all tests with abort or create entry

* drop coordinator tests and remove hostname support

* add test for update failure on connection error

* use freezer for update_failed test

* fix pysmlight imports

---------

Signed-off-by: Tim Lunn <tl@smlight.tech>
Co-authored-by: Tim Lunn <tim@feathertop.org>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
  • Loading branch information
3 people authored Aug 20, 2024
1 parent b464813 commit 98a007c
Show file tree
Hide file tree
Showing 23 changed files with 1,871 additions and 0 deletions.
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -1329,6 +1329,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/smarty/ @z0mbieprocess
/homeassistant/components/smhi/ @gjohansson-ST
/tests/components/smhi/ @gjohansson-ST
/homeassistant/components/smlight/ @tl-sl
/tests/components/smlight/ @tl-sl
/homeassistant/components/sms/ @ocalvo
/tests/components/sms/ @ocalvo
/homeassistant/components/snapcast/ @luar123
Expand Down
30 changes: 30 additions & 0 deletions homeassistant/components/smlight/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""SMLIGHT SLZB Zigbee device integration."""

from __future__ import annotations

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant

from .coordinator import SmDataUpdateCoordinator

PLATFORMS: list[Platform] = [
Platform.SENSOR,
]
type SmConfigEntry = ConfigEntry[SmDataUpdateCoordinator]


async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool:
"""Set up SMLIGHT Zigbee from a config entry."""
coordinator = SmDataUpdateCoordinator(hass, entry.data[CONF_HOST])
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

return True


async def async_unload_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
151 changes: 151 additions & 0 deletions homeassistant/components/smlight/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Config flow for SMLIGHT Zigbee integration."""

from __future__ import annotations

from typing import Any

from pysmlight import Api2
from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError
import voluptuous as vol

from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac

from .const import DOMAIN

STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
}
)

STEP_AUTH_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)


class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for SMLIGHT Zigbee."""

def __init__(self) -> None:
"""Initialize the config flow."""
self.client: Api2
self.host: str | None = None

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}

if user_input is not None:
host = user_input[CONF_HOST]
self.client = Api2(host, session=async_get_clientsession(self.hass))
self.host = host

try:
if not await self._async_check_auth_required(user_input):
return await self._async_complete_entry(user_input)
except SmlightConnectionError:
errors["base"] = "cannot_connect"
except SmlightAuthError:
return await self.async_step_auth()

return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

async def async_step_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle authentication to SLZB-06 device."""
errors: dict[str, str] = {}

if user_input is not None:
try:
if not await self._async_check_auth_required(user_input):
return await self._async_complete_entry(user_input)
except SmlightConnectionError:
return self.async_abort(reason="cannot_connect")
except SmlightAuthError:
errors["base"] = "invalid_auth"

return self.async_show_form(
step_id="auth", data_schema=STEP_AUTH_DATA_SCHEMA, errors=errors
)

async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered Lan coordinator."""
local_name = discovery_info.hostname[:-1]
node_name = local_name.removesuffix(".local")

self.host = local_name
self.context["title_placeholders"] = {CONF_NAME: node_name}
self.client = Api2(self.host, session=async_get_clientsession(self.hass))

mac = discovery_info.properties.get("mac")
# fallback for legacy firmware
if mac is None:
info = await self.client.get_info()
mac = info.MAC
await self.async_set_unique_id(format_mac(mac))
self._abort_if_unique_id_configured()

return await self.async_step_confirm_discovery()

async def async_step_confirm_discovery(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle discovery confirm."""
errors: dict[str, str] = {}

if user_input is not None:
user_input[CONF_HOST] = self.host
try:
if not await self._async_check_auth_required(user_input):
return await self._async_complete_entry(user_input)

except SmlightConnectionError:
return self.async_abort(reason="cannot_connect")

except SmlightAuthError:
return await self.async_step_auth()

self._set_confirm_only()

return self.async_show_form(
step_id="confirm_discovery",
description_placeholders={"host": self.host},
errors=errors,
)

async def _async_check_auth_required(self, user_input: dict[str, Any]) -> bool:
"""Check if auth required and attempt to authenticate."""
if await self.client.check_auth_needed():
if user_input.get(CONF_USERNAME) and user_input.get(CONF_PASSWORD):
return not await self.client.authenticate(
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
)
raise SmlightAuthError
return False

async def _async_complete_entry(
self, user_input: dict[str, Any]
) -> ConfigFlowResult:
info = await self.client.get_info()
await self.async_set_unique_id(format_mac(info.MAC))
self._abort_if_unique_id_configured()

if user_input.get(CONF_HOST) is None:
user_input[CONF_HOST] = self.host

assert info.model is not None
return self.async_create_entry(title=info.model, data=user_input)
11 changes: 11 additions & 0 deletions homeassistant/components/smlight/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Constants for the SMLIGHT Zigbee integration."""

from datetime import timedelta
import logging

DOMAIN = "smlight"

ATTR_MANUFACTURER = "SMLIGHT"

LOGGER = logging.getLogger(__package__)
SCAN_INTERVAL = timedelta(seconds=300)
71 changes: 71 additions & 0 deletions homeassistant/components/smlight/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""DataUpdateCoordinator for Smlight."""

from dataclasses import dataclass

from pysmlight import Api2, Info, Sensors
from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import DOMAIN, LOGGER, SCAN_INTERVAL


@dataclass
class SmData:
"""SMLIGHT data stored in the DataUpdateCoordinator."""

sensors: Sensors
info: Info


class SmDataUpdateCoordinator(DataUpdateCoordinator[SmData]):
"""Class to manage fetching SMLIGHT data."""

config_entry: ConfigEntry

def __init__(self, hass: HomeAssistant, host: str) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
LOGGER,
name=f"{DOMAIN}_{host}",
update_interval=SCAN_INTERVAL,
)

self.unique_id: str | None = None
self.client = Api2(host=host, session=async_get_clientsession(hass))

async def _async_setup(self) -> None:
"""Authenticate if needed during initial setup."""
if await self.client.check_auth_needed():
if (
CONF_USERNAME in self.config_entry.data
and CONF_PASSWORD in self.config_entry.data
):
try:
await self.client.authenticate(
self.config_entry.data[CONF_USERNAME],
self.config_entry.data[CONF_PASSWORD],
)
except SmlightAuthError as err:
LOGGER.error("Failed to authenticate: %s", err)
raise ConfigEntryError from err

info = await self.client.get_info()
self.unique_id = format_mac(info.MAC)

async def _async_update_data(self) -> SmData:
"""Fetch data from the SMLIGHT device."""
try:
return SmData(
sensors=await self.client.get_sensors(),
info=await self.client.get_info(),
)
except SmlightConnectionError as err:
raise UpdateFailed(err) from err
31 changes: 31 additions & 0 deletions homeassistant/components/smlight/entity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Base class for all SMLIGHT entities."""

from __future__ import annotations

from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import ATTR_MANUFACTURER
from .coordinator import SmDataUpdateCoordinator


class SmEntity(CoordinatorEntity[SmDataUpdateCoordinator]):
"""Base class for all SMLight entities."""

_attr_has_entity_name = True

def __init__(self, coordinator: SmDataUpdateCoordinator) -> None:
"""Initialize entity with device."""
super().__init__(coordinator)
mac = format_mac(coordinator.data.info.MAC)
self._attr_device_info = DeviceInfo(
configuration_url=f"http://{coordinator.client.host}",
connections={(CONNECTION_NETWORK_MAC, mac)},
manufacturer=ATTR_MANUFACTURER,
model=coordinator.data.info.model,
sw_version=f"core: {coordinator.data.info.sw_version} / zigbee: {coordinator.data.info.zb_version}",
)
15 changes: 15 additions & 0 deletions homeassistant/components/smlight/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"domain": "smlight",
"name": "SMLIGHT SLZB",
"codeowners": ["@tl-sl"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/smlight",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pysmlight==0.0.12"],
"zeroconf": [
{
"type": "_slzb-06._tcp.local."
}
]
}
Loading

0 comments on commit 98a007c

Please sign in to comment.