Skip to content

Commit

Permalink
Add Deako integration (home-assistant#121132)
Browse files Browse the repository at this point in the history
* Deako integration using pydeako

* fix: address feedback

- make unit tests more e2e
- use runtime_data to store connection

* fix: address feedback part 2

- added better type safety for Deako config entries
- refactored the config flow tests to use a conftest mock instead of directly patching
- removed pytest.mark.asyncio test decorators

* fix: address feedback pt 3

- simplify config entry type
- add test for single_instance_allowed
- remove light.py get_state(), only used once, no need to be separate function

* fix: ruff format

* Update homeassistant/components/deako/__init__.py

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

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
  • Loading branch information
Balake and joostlek authored Aug 28, 2024
1 parent 2dce876 commit c049129
Show file tree
Hide file tree
Showing 20 changed files with 817 additions and 0 deletions.
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ homeassistant.components.cpuspeed.*
homeassistant.components.crownstone.*
homeassistant.components.date.*
homeassistant.components.datetime.*
homeassistant.components.deako.*
homeassistant.components.deconz.*
homeassistant.components.default_config.*
homeassistant.components.demo.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,8 @@ build.json @home-assistant/supervisor
/tests/components/date/ @home-assistant/core
/homeassistant/components/datetime/ @home-assistant/core
/tests/components/datetime/ @home-assistant/core
/homeassistant/components/deako/ @sebirdman @balake @deakolights
/tests/components/deako/ @sebirdman @balake @deakolights
/homeassistant/components/debugpy/ @frenck
/tests/components/debugpy/ @frenck
/homeassistant/components/deconz/ @Kane610
Expand Down
59 changes: 59 additions & 0 deletions homeassistant/components/deako/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""The deako integration."""

from __future__ import annotations

import logging

from pydeako.deako import Deako, DeviceListTimeout, FindDevicesTimeout
from pydeako.discover import DeakoDiscoverer

from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady

_LOGGER: logging.Logger = logging.getLogger(__name__)

PLATFORMS: list[Platform] = [Platform.LIGHT]

type DeakoConfigEntry = ConfigEntry[Deako]


async def async_setup_entry(hass: HomeAssistant, entry: DeakoConfigEntry) -> bool:
"""Set up deako."""
_zc = await zeroconf.async_get_instance(hass)
discoverer = DeakoDiscoverer(_zc)

connection = Deako(discoverer.get_address)

await connection.connect()
try:
await connection.find_devices()
except DeviceListTimeout as exc: # device list never received
_LOGGER.warning("Device not responding to device list")
await connection.disconnect()
raise ConfigEntryNotReady(exc) from exc
except FindDevicesTimeout as exc: # total devices expected not received
_LOGGER.warning("Device not responding to device requests")
await connection.disconnect()
raise ConfigEntryNotReady(exc) from exc

# If deako devices are advertising on mdns, we should be able to get at least one device
devices = connection.get_devices()
if len(devices) == 0:
await connection.disconnect()
raise ConfigEntryNotReady(devices)

entry.runtime_data = connection

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

return True


async def async_unload_entry(hass: HomeAssistant, entry: DeakoConfigEntry) -> bool:
"""Unload a config entry."""
await entry.runtime_data.disconnect()

return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
26 changes: 26 additions & 0 deletions homeassistant/components/deako/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Config flow for deako."""

from pydeako.discover import DeakoDiscoverer, DevicesNotFoundException

from homeassistant.components import zeroconf
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_flow

from .const import DOMAIN, NAME


async def _async_has_devices(hass: HomeAssistant) -> bool:
"""Return if there are devices that can be discovered."""
_zc = await zeroconf.async_get_instance(hass)
discoverer = DeakoDiscoverer(_zc)

try:
await discoverer.get_address()
except DevicesNotFoundException:
return False
else:
# address exists, there's at least one device
return True


config_entry_flow.register_discovery_flow(DOMAIN, NAME, _async_has_devices)
5 changes: 5 additions & 0 deletions homeassistant/components/deako/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Constants for Deako."""

# Base component constants
NAME = "Deako"
DOMAIN = "deako"
96 changes: 96 additions & 0 deletions homeassistant/components/deako/light.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Binary sensor platform for integration_blueprint."""

from typing import Any

from pydeako.deako import Deako

from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from . import DeakoConfigEntry
from .const import DOMAIN

# Model names
MODEL_SMART = "smart"
MODEL_DIMMER = "dimmer"


async def async_setup_entry(
hass: HomeAssistant,
config: DeakoConfigEntry,
add_entities: AddEntitiesCallback,
) -> None:
"""Configure the platform."""
client = config.runtime_data

add_entities([DeakoLightEntity(client, uuid) for uuid in client.get_devices()])


class DeakoLightEntity(LightEntity):
"""Deako LightEntity class."""

_attr_has_entity_name = True
_attr_name = None
_attr_is_on = False
_attr_available = True

client: Deako

def __init__(self, client: Deako, uuid: str) -> None:
"""Save connection reference."""
self.client = client
self._attr_unique_id = uuid

dimmable = client.is_dimmable(uuid)

model = MODEL_SMART
self._attr_color_mode = ColorMode.ONOFF
if dimmable:
model = MODEL_DIMMER
self._attr_color_mode = ColorMode.BRIGHTNESS

self._attr_supported_color_modes = {self._attr_color_mode}

self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, uuid)},
name=client.get_name(uuid),
manufacturer="Deako",
model=model,
)

client.set_state_callback(uuid, self.on_update)
self.update() # set initial state

def on_update(self) -> None:
"""State update callback."""
self.update()
self.schedule_update_ha_state()

async def control_device(self, power: bool, dim: int | None = None) -> None:
"""Control entity state via client."""
assert self._attr_unique_id is not None
await self.client.control_device(self._attr_unique_id, power, dim)

async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
dim = None
if ATTR_BRIGHTNESS in kwargs:
dim = round(kwargs[ATTR_BRIGHTNESS] / 2.55, 0)
await self.control_device(True, dim)

async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the device."""
await self.control_device(False)

def update(self) -> None:
"""Call to update state."""
assert self._attr_unique_id is not None
state = self.client.get_state(self._attr_unique_id) or {}
self._attr_is_on = bool(state.get("power", False))
if (
self._attr_supported_color_modes is not None
and ColorMode.BRIGHTNESS in self._attr_supported_color_modes
):
self._attr_brightness = int(round(state.get("dim", 0) * 2.55))
13 changes: 13 additions & 0 deletions homeassistant/components/deako/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"domain": "deako",
"name": "Deako",
"codeowners": ["@sebirdman", "@balake", "@deakolights"],
"config_flow": true,
"dependencies": ["zeroconf"],
"documentation": "https://www.home-assistant.io/integrations/deako",
"iot_class": "local_polling",
"loggers": ["pydeako"],
"requirements": ["pydeako==0.4.0"],
"single_config_entry": true,
"zeroconf": ["_deako._tcp.local."]
}
13 changes: 13 additions & 0 deletions homeassistant/components/deako/strings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"config": {
"step": {
"confirm": {
"description": "Please confirm setting up the Deako integration"
}
},
"abort": {
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
}
}
}
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"cpuspeed",
"crownstone",
"daikin",
"deako",
"deconz",
"deluge",
"denonavr",
Expand Down
7 changes: 7 additions & 0 deletions homeassistant/generated/integrations.json
Original file line number Diff line number Diff line change
Expand Up @@ -1091,6 +1091,13 @@
"config_flow": false,
"iot_class": "local_polling"
},
"deako": {
"name": "Deako",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling",
"single_config_entry": true
},
"debugpy": {
"name": "Remote Python Debugger",
"integration_type": "service",
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/generated/zeroconf.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,11 @@
"domain": "forked_daapd",
},
],
"_deako._tcp.local.": [
{
"domain": "deako",
},
],
"_devialet-http._tcp.local.": [
{
"domain": "devialet",
Expand Down
10 changes: 10 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1145,6 +1145,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true

[mypy-homeassistant.components.deako.*]
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.deconz.*]
check_untyped_defs = true
disallow_incomplete_defs = true
Expand Down
3 changes: 3 additions & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1803,6 +1803,9 @@ pydaikin==2.13.4
# homeassistant.components.danfoss_air
pydanfossair==0.1.0

# homeassistant.components.deako
pydeako==0.4.0

# homeassistant.components.deconz
pydeconz==116

Expand Down
3 changes: 3 additions & 0 deletions requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1447,6 +1447,9 @@ pycsspeechtts==1.0.8
# homeassistant.components.daikin
pydaikin==2.13.4

# homeassistant.components.deako
pydeako==0.4.0

# homeassistant.components.deconz
pydeconz==116

Expand Down
1 change: 1 addition & 0 deletions tests/components/deako/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for the Deako integration."""
45 changes: 45 additions & 0 deletions tests/components/deako/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""deako session fixtures."""

from collections.abc import Generator
from unittest.mock import MagicMock, patch

import pytest

from homeassistant.components.deako.const import DOMAIN

from tests.common import MockConfigEntry


@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
domain=DOMAIN,
)


@pytest.fixture(autouse=True)
def pydeako_deako_mock() -> Generator[MagicMock]:
"""Mock pydeako deako client."""
with patch("homeassistant.components.deako.Deako", autospec=True) as mock:
yield mock


@pytest.fixture(autouse=True)
def pydeako_discoverer_mock(mock_async_zeroconf: MagicMock) -> Generator[MagicMock]:
"""Mock pydeako discovery client."""
with (
patch("homeassistant.components.deako.DeakoDiscoverer", autospec=True) as mock,
patch("homeassistant.components.deako.config_flow.DeakoDiscoverer", new=mock),
):
yield mock


@pytest.fixture
def mock_deako_setup() -> Generator[MagicMock]:
"""Mock async_setup_entry for config flow tests."""
with patch(
"homeassistant.components.deako.async_setup_entry",
return_value=True,
) as mock_setup:
yield mock_setup
Loading

0 comments on commit c049129

Please sign in to comment.