Skip to content

Commit 8839c3c

Browse files
committed
feat: add support for lights
1 parent d5cd07f commit 8839c3c

File tree

6 files changed

+407
-2
lines changed

6 files changed

+407
-2
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ Once configured, the integration **creates entities** for:
7373
| Entity | Description |
7474
|----------------------|------------------------------------------------------------------------------------------------------------------|
7575
| Cover | Manage the shutter. ([API Reference](https://developers.home-assistant.io/docs/core/entity/cover/)) |
76+
| Light | Manage the (dimmable) light. ([API Reference](https://developers.home-assistant.io/docs/core/entity/light/)) |
7677
| Sensor (Device Type) | The device type of the ONYX device. ([API Reference](https://developers.home-assistant.io/docs/core/entity/sensor/)) |
7778
| Sensor (Weather Humidity) | The humidity of the weather sensor. ([API Reference](https://developers.home-assistant.io/docs/core/entity/sensor/)) |
7879
| Sensor (Weather Temperature) | The temperature of the weather sensor. ([API Reference](https://developers.home-assistant.io/docs/core/entity/sensor/)) |
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""The ONYX light entity."""
2+
import logging
3+
from typing import Callable, Optional
4+
5+
from homeassistant.config_entries import ConfigEntry
6+
from homeassistant.core import HomeAssistant
7+
from homeassistant.helpers.typing import DiscoveryInfoType
8+
9+
from custom_components.hella_onyx import DOMAIN, ONYX_TIMEZONE
10+
from custom_components.hella_onyx.const import ONYX_API, ONYX_COORDINATOR
11+
from custom_components.hella_onyx.sensors.light import OnyxLight
12+
13+
_LOGGER = logging.getLogger(__name__)
14+
15+
16+
async def async_setup_entry(
17+
hass: HomeAssistant,
18+
entry: ConfigEntry,
19+
async_add_entities: Callable,
20+
discovery_info: Optional[DiscoveryInfoType] = None,
21+
):
22+
"""Set up the ONYX light platform."""
23+
data = hass.data[DOMAIN][entry.entry_id]
24+
api = data[ONYX_API]
25+
timezone = data[ONYX_TIMEZONE]
26+
coordinator = data[ONYX_COORDINATOR]
27+
28+
lights = [
29+
OnyxLight(
30+
api, timezone, coordinator, device.name, device.device_type, device_id
31+
)
32+
for device_id, device in filter(
33+
lambda item: item[1].device_type is not None
34+
and item[1].device_type.is_light(),
35+
api.devices.items(),
36+
)
37+
]
38+
_LOGGER.info("adding %s hella_onyx light entities", len(lights))
39+
async_add_entities(lights, True)
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""The ONYX light entity."""
2+
import asyncio
3+
import logging
4+
from math import ceil
5+
from typing import Any
6+
7+
from homeassistant.components.light import (
8+
LightEntity,
9+
ColorMode,
10+
ATTR_BRIGHTNESS,
11+
)
12+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
13+
from onyx_client.enum.action import Action
14+
from onyx_client.enum.device_type import DeviceType
15+
16+
from custom_components.hella_onyx.api_connector import APIConnector
17+
from custom_components.hella_onyx.sensors.onyx_entity import OnyxEntity
18+
19+
_LOGGER = logging.getLogger(__name__)
20+
21+
MIN_USED_DIM_DURATION = 500
22+
MAX_USED_DIM_DURATION = 6000
23+
24+
25+
class OnyxLight(OnyxEntity, LightEntity):
26+
"""A light entity."""
27+
28+
def __init__(
29+
self,
30+
api: APIConnector,
31+
timezone: str,
32+
coordinator: DataUpdateCoordinator,
33+
name: str,
34+
device_type: DeviceType,
35+
uuid: str,
36+
):
37+
"""Initialize a light entity."""
38+
super().__init__(api, timezone, coordinator, name, device_type, uuid)
39+
40+
@property
41+
def icon(self) -> str:
42+
"""Icon to use in the frontend, if any."""
43+
return "mdi:lightbulb-on-outline"
44+
45+
@property
46+
def name(self) -> str:
47+
"""Return the display name of the sensor."""
48+
return self._name
49+
50+
@property
51+
def unique_id(self) -> str:
52+
"""Return the unique id of the sensor."""
53+
return f"{self._uuid}/Light"
54+
55+
@property
56+
def supported_features(self):
57+
"""Flag supported features."""
58+
return []
59+
60+
@property
61+
def color_mode(self) -> ColorMode | str | None:
62+
"""Return the color mode of the light."""
63+
return (
64+
ColorMode.ONOFF
65+
if self._device.device_type == DeviceType.BASIC_LIGHT
66+
else ColorMode.BRIGHTNESS
67+
)
68+
69+
@property
70+
def supported_color_modes(self) -> set[ColorMode] | set[str] | None:
71+
"""Flag supported color modes."""
72+
return [self.color_mode]
73+
74+
@property
75+
def brightness(self) -> int | None:
76+
"""Return the brightness of this light between 0..255."""
77+
brightness = self._device.actual_brightness
78+
_LOGGER.debug(
79+
"received brightness for device %s: %s (%s/%s)",
80+
self._uuid,
81+
brightness.value,
82+
brightness.minimum,
83+
brightness.maximum,
84+
)
85+
return brightness.value / brightness.maximum * 255
86+
87+
def turn_on(self, **kwargs: Any) -> None:
88+
"""Turns the light on."""
89+
hella_brightness = ceil(
90+
kwargs[ATTR_BRIGHTNESS] / 255 * self._device.actual_brightness.maximum
91+
)
92+
dim_duration = self._get_dim_duration(hella_brightness)
93+
_LOGGER.debug(
94+
"setting brightness for device %s: %s (%s)",
95+
self._uuid,
96+
hella_brightness,
97+
dim_duration,
98+
)
99+
asyncio.run_coroutine_threadsafe(
100+
self.api.send_device_command_properties(
101+
self._uuid,
102+
{
103+
"target_brightness": hella_brightness,
104+
"dim_duration": dim_duration,
105+
},
106+
),
107+
self.hass.loop,
108+
)
109+
110+
def turn_off(self, **kwargs: Any) -> None:
111+
"""Turns the light off."""
112+
_LOGGER.debug(
113+
"turning light off %s",
114+
self._uuid,
115+
)
116+
asyncio.run_coroutine_threadsafe(
117+
self.api.send_device_command_action(self._uuid, Action.LIGHT_OFF),
118+
self.hass.loop,
119+
)
120+
121+
def _get_dim_duration(self, target) -> int:
122+
"""Get the dim duration."""
123+
return abs(
124+
int(
125+
(target - self._device.actual_brightness.value)
126+
/ self._device.actual_brightness.maximum
127+
* (MAX_USED_DIM_DURATION - MIN_USED_DIM_DURATION)
128+
+ MIN_USED_DIM_DURATION
129+
)
130+
)

custom_components/hella_onyx/sensors/shutter.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def current_cover_position(self) -> int:
9494
"""
9595
position = self._device.actual_position
9696
_LOGGER.debug(
97-
"received position fo device %s: %s (%s/%s)",
97+
"received position for device %s: %s (%s/%s)",
9898
self._uuid,
9999
position.value,
100100
position.minimum,
@@ -112,7 +112,7 @@ def current_cover_tilt_position(self) -> int:
112112
"""
113113
position = self._device.actual_angle
114114
_LOGGER.debug(
115-
"received tilt position fo device %s: %s (%s/%s)",
115+
"received tilt position for device %s: %s (%s/%s)",
116116
self._uuid,
117117
position.value,
118118
position.minimum,

tests/sensors/test_light.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Test for the ONYX Light Entity."""
2+
from unittest.mock import MagicMock, patch
3+
4+
import pytest
5+
from homeassistant.components.light import (
6+
ColorMode,
7+
)
8+
from homeassistant.core import HomeAssistant
9+
from onyx_client.data.numeric_value import NumericValue
10+
from onyx_client.device.light import Light
11+
from onyx_client.enum.action import Action
12+
from onyx_client.enum.device_type import DeviceType
13+
14+
from custom_components.hella_onyx.light import OnyxLight
15+
16+
17+
class TestOnyxLight:
18+
@pytest.fixture
19+
def api(self):
20+
yield MagicMock()
21+
22+
@pytest.fixture
23+
def coordinator(self):
24+
yield MagicMock()
25+
26+
@pytest.fixture
27+
def hass(self):
28+
hass = MagicMock(spec=HomeAssistant)
29+
hass.loop = MagicMock()
30+
yield hass
31+
32+
@pytest.fixture
33+
def entity(self, api, coordinator, hass):
34+
shutter = OnyxLight(
35+
api, "UTC", coordinator, "name", DeviceType.BASIC_LIGHT, "uuid"
36+
)
37+
shutter.hass = hass
38+
yield shutter
39+
40+
@pytest.fixture
41+
def dimmable_entity(self, api, coordinator):
42+
yield OnyxLight(
43+
api, "UTC", coordinator, "name", DeviceType.DIMMABLE_LIGHT, "uuid"
44+
)
45+
46+
@pytest.fixture
47+
def device(self):
48+
yield Light(
49+
"id",
50+
"name",
51+
DeviceType.BASIC_LIGHT,
52+
None,
53+
list(Action),
54+
)
55+
56+
def test_icon(self, entity):
57+
assert entity.icon == "mdi:lightbulb-on-outline"
58+
59+
def test_name(self, entity):
60+
assert entity.name == "name"
61+
62+
def test_unique_id(self, entity):
63+
assert entity.unique_id == "uuid/Light"
64+
65+
def test_supported_features(self, entity):
66+
assert len(entity.supported_features) == 0
67+
68+
def test_color_mode(self, api, entity, device):
69+
device.device_type = DeviceType.BASIC_LIGHT
70+
api.device.return_value = device
71+
assert entity.color_mode == ColorMode.ONOFF
72+
assert len(entity.supported_color_modes) == 1
73+
assert entity.supported_color_modes[0] == ColorMode.ONOFF
74+
assert api.device.called
75+
76+
def test_color_mode_brightness(self, api, dimmable_entity, device):
77+
device.device_type = DeviceType.DIMMABLE_LIGHT
78+
api.device.return_value = device
79+
assert dimmable_entity.color_mode == ColorMode.BRIGHTNESS
80+
assert len(dimmable_entity.supported_color_modes) == 1
81+
assert dimmable_entity.supported_color_modes[0] == ColorMode.BRIGHTNESS
82+
assert api.device.called
83+
84+
def test_brightness(self, api, entity, device):
85+
device.actual_brightness = NumericValue(
86+
value=10, minimum=0, maximum=100, read_only=False
87+
)
88+
api.device.return_value = device
89+
assert entity.brightness == 25.5
90+
assert api.device.called
91+
92+
@patch("asyncio.run_coroutine_threadsafe")
93+
def test_turn_off(self, mock_run_coroutine_threadsafe, api, entity, device):
94+
device.actual_brightness = NumericValue(
95+
value=100, maximum=100, minimum=0, read_only=False
96+
)
97+
api.device.return_value = device
98+
entity.turn_off()
99+
api.send_device_command_action.assert_called_with("uuid", Action.LIGHT_OFF)
100+
assert mock_run_coroutine_threadsafe.called
101+
102+
@patch("asyncio.run_coroutine_threadsafe")
103+
def test_turn_on(self, mock_run_coroutine_threadsafe, api, entity, device):
104+
device.actual_brightness = NumericValue(
105+
value=100, maximum=100, minimum=0, read_only=False
106+
)
107+
api.device.return_value = device
108+
entity.turn_on(brightness=10)
109+
api.send_device_command_properties.assert_called_with(
110+
"uuid",
111+
{
112+
"target_brightness": 4,
113+
"dim_duration": 4780,
114+
},
115+
)
116+
assert mock_run_coroutine_threadsafe.called
117+
assert api.device.called
118+
119+
def test__get_dim_duration(self, api, entity, device):
120+
device.actual_brightness = NumericValue(
121+
value=100, maximum=100, minimum=0, read_only=False
122+
)
123+
api.device.return_value = device
124+
assert entity._get_dim_duration(90) == 50
125+
assert api.device.called
126+
127+
def test__get_dim_duration_same(self, api, entity, device):
128+
device.actual_brightness = NumericValue(
129+
value=100, maximum=100, minimum=0, read_only=False
130+
)
131+
api.device.return_value = device
132+
assert entity._get_dim_duration(100) == 500
133+
assert api.device.called

0 commit comments

Comments
 (0)