Das Montana-Black-Shield (neues Ziel ledshield_rgb in der Firmware) steuert analoge RGB-Streifen und hat keinen eigenen Weisskanal. Sein Board meldet deshalb ct_min == ct_max (nl_board.c: 4000/4000) - die Konvention der Firmware fuer "weiss entsteht als RGB-Mischung". supported_color_modes bot COLOR_TEMP bisher IMMER an. Beim Shield bekaeme Home Assistant damit einen Farbtemperaturregler, dessen Grenzen zusammenfallen. Jetzt entscheidet der gemeldete Bereich: nur wenn oben > unten, gibt es COLOR_TEMP. Dieselbe Regel benutzt ninlight_core schon (device_state.dart, hasColorTemp), dort faellt der Regler beim Shield von selbst weg. Die Menge darf nie leer werden - HA verlangt mindestens einen Modus -, deshalb faellt sie im Grenzfall auf BRIGHTNESS zurueck: ein Geraet ohne Farbe und ohne Bereich kann immer noch dimmen.
248 lines
8.2 KiB
Python
248 lines
8.2 KiB
Python
"""The light entity."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from copy import deepcopy
|
|
from datetime import timedelta
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
from freezegun.api import FrozenDateTimeFactory
|
|
from homeassistant.components.light import (
|
|
ATTR_BRIGHTNESS,
|
|
ATTR_COLOR_MODE,
|
|
ATTR_COLOR_TEMP_KELVIN,
|
|
ATTR_MAX_COLOR_TEMP_KELVIN,
|
|
ATTR_MIN_COLOR_TEMP_KELVIN,
|
|
ATTR_RGB_COLOR,
|
|
ATTR_SUPPORTED_COLOR_MODES,
|
|
ATTR_TRANSITION,
|
|
ColorMode,
|
|
)
|
|
from homeassistant.components.light import (
|
|
DOMAIN as LIGHT_DOMAIN,
|
|
)
|
|
from homeassistant.const import (
|
|
ATTR_ENTITY_ID,
|
|
CONF_HOST,
|
|
CONF_TOKEN,
|
|
SERVICE_TURN_OFF,
|
|
SERVICE_TURN_ON,
|
|
STATE_OFF,
|
|
STATE_ON,
|
|
STATE_UNAVAILABLE,
|
|
)
|
|
from homeassistant.core import HomeAssistant
|
|
from pytest_homeassistant_custom_component.common import MockConfigEntry, async_fire_time_changed
|
|
|
|
from custom_components.ninlight.api import NinlightConnectionError
|
|
from custom_components.ninlight.const import DOMAIN
|
|
from custom_components.ninlight.light import (
|
|
ha_to_lamp_brightness,
|
|
lamp_to_ha_brightness,
|
|
transition_ms,
|
|
)
|
|
from tests.helpers import entity_id_for
|
|
from tests_unit.samples import (
|
|
HOST_CW,
|
|
HOST_RGB,
|
|
INFO_CW,
|
|
MAC_CW,
|
|
MAC_RGB,
|
|
STATE_CW,
|
|
STATE_RGB,
|
|
TOKEN,
|
|
)
|
|
|
|
|
|
def test_brightness_never_zero_and_round_trips() -> None:
|
|
for ha_value in range(1, 256):
|
|
assert 1 <= ha_to_lamp_brightness(ha_value) <= 100, ha_value
|
|
for lamp_value in range(1, 101):
|
|
assert 1 <= lamp_to_ha_brightness(lamp_value) <= 255, lamp_value
|
|
assert ha_to_lamp_brightness(lamp_to_ha_brightness(lamp_value)) == lamp_value, lamp_value
|
|
|
|
|
|
def test_transition_ms_rounds_and_clamps() -> None:
|
|
assert transition_ms(2.5) == 2500
|
|
assert transition_ms(0.0014) == 1
|
|
assert transition_ms(-1) == 0
|
|
assert transition_ms(90) == 60000
|
|
|
|
|
|
async def test_state_attributes_of_color_lamp(hass: HomeAssistant, init_integration) -> None:
|
|
state = hass.states.get(entity_id_for(hass, "light", "light"))
|
|
assert state.state == STATE_ON
|
|
assert state.attributes[ATTR_BRIGHTNESS] == 94 # lamp 37 %
|
|
assert state.attributes[ATTR_COLOR_MODE] == ColorMode.RGB
|
|
assert state.attributes[ATTR_RGB_COLOR] == (12, 200, 90)
|
|
assert set(state.attributes[ATTR_SUPPORTED_COLOR_MODES]) == {
|
|
ColorMode.RGB,
|
|
ColorMode.COLOR_TEMP,
|
|
}
|
|
assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700
|
|
assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500
|
|
|
|
|
|
async def test_white_lamp_offers_only_color_temp(
|
|
hass: HomeAssistant,
|
|
mock_client: MagicMock,
|
|
lamp_state: dict[str, Any],
|
|
lamp_info: dict[str, Any],
|
|
) -> None:
|
|
lamp_state.clear()
|
|
lamp_state.update(deepcopy(STATE_CW))
|
|
lamp_info.clear()
|
|
lamp_info.update(deepcopy(INFO_CW))
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN, unique_id=MAC_CW, data={CONF_HOST: HOST_CW, CONF_TOKEN: TOKEN}
|
|
)
|
|
entry.add_to_hass(hass)
|
|
assert await hass.config_entries.async_setup(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
state = hass.states.get(entity_id_for(hass, "light", "light", mac=MAC_CW))
|
|
assert state.state == STATE_OFF
|
|
assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.COLOR_TEMP]
|
|
assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2000
|
|
assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 5000
|
|
|
|
|
|
async def test_shield_without_ct_range_offers_only_rgb(
|
|
hass: HomeAssistant,
|
|
mock_client: MagicMock,
|
|
lamp_state: dict[str, Any],
|
|
) -> None:
|
|
"""Das Montana-Black-Shield steuert analoge RGB-Streifen und hat keinen
|
|
eigenen Weisskanal: sein Board meldet ct_min == ct_max (nl_board.c,
|
|
"ledshield_rgb", 4000/4000) - die Konvention fuer "weiss entsteht als
|
|
RGB-Mischung". Ein Bereich ohne Spielraum ist kein Farbtemperaturbereich;
|
|
HA bekaeme sonst einen Regler mit min == max.
|
|
"""
|
|
lamp_state.clear()
|
|
lamp_state.update(deepcopy(STATE_RGB))
|
|
lamp_state["capabilities"] = {"color": True, "color_temp": [4000, 4000]}
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN, unique_id=MAC_RGB, data={CONF_HOST: HOST_RGB, CONF_TOKEN: TOKEN}
|
|
)
|
|
entry.add_to_hass(hass)
|
|
assert await hass.config_entries.async_setup(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(entity_id_for(hass, "light", "light"))
|
|
assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGB]
|
|
|
|
|
|
async def test_turn_on_sends_everything_in_one_request(
|
|
hass: HomeAssistant, init_integration, mock_client: MagicMock
|
|
) -> None:
|
|
await hass.services.async_call(
|
|
LIGHT_DOMAIN,
|
|
SERVICE_TURN_ON,
|
|
{
|
|
ATTR_ENTITY_ID: entity_id_for(hass, "light", "light"),
|
|
ATTR_BRIGHTNESS: 200,
|
|
ATTR_COLOR_TEMP_KELVIN: 4100,
|
|
ATTR_TRANSITION: 2.5,
|
|
},
|
|
blocking=True,
|
|
)
|
|
mock_client.set_state.assert_awaited_once_with(
|
|
on=True, brightness=78, color_temp=4100, transition=2500
|
|
)
|
|
|
|
|
|
async def test_turn_on_with_rgb_and_without_transition(
|
|
hass: HomeAssistant, init_integration, mock_client: MagicMock
|
|
) -> None:
|
|
await hass.services.async_call(
|
|
LIGHT_DOMAIN,
|
|
SERVICE_TURN_ON,
|
|
{ATTR_ENTITY_ID: entity_id_for(hass, "light", "light"), ATTR_RGB_COLOR: (40, 10, 220)},
|
|
blocking=True,
|
|
)
|
|
mock_client.set_state.assert_awaited_once_with(on=True, color={"r": 40, "g": 10, "b": 220})
|
|
|
|
|
|
async def test_turn_off_clamps_transition(
|
|
hass: HomeAssistant, init_integration, mock_client: MagicMock
|
|
) -> None:
|
|
await hass.services.async_call(
|
|
LIGHT_DOMAIN,
|
|
SERVICE_TURN_OFF,
|
|
{ATTR_ENTITY_ID: entity_id_for(hass, "light", "light"), ATTR_TRANSITION: 90},
|
|
blocking=True,
|
|
)
|
|
mock_client.set_state.assert_awaited_once_with(on=False, transition=60000)
|
|
|
|
|
|
async def test_write_adopts_lamp_answer_without_reading_again(
|
|
hass: HomeAssistant, init_integration, mock_client: MagicMock
|
|
) -> None:
|
|
async def lamp_clamps(**fields: Any) -> dict[str, Any]:
|
|
return {**deepcopy(STATE_RGB), "mode": "white", "brightness": 55}
|
|
|
|
mock_client.set_state.side_effect = lamp_clamps
|
|
reads_before = mock_client.get_state.await_count
|
|
entity_id = entity_id_for(hass, "light", "light")
|
|
await hass.services.async_call(
|
|
LIGHT_DOMAIN,
|
|
SERVICE_TURN_ON,
|
|
{ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 200},
|
|
blocking=True,
|
|
)
|
|
state = hass.states.get(entity_id)
|
|
assert state.attributes[ATTR_BRIGHTNESS] == 140 # the lamp's 55 %, not the requested 78 %
|
|
assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP
|
|
assert mock_client.get_state.await_count == reads_before
|
|
|
|
|
|
async def test_requests_to_one_lamp_never_overlap(
|
|
hass: HomeAssistant, init_integration, mock_client: MagicMock, lamp_state: dict[str, Any]
|
|
) -> None:
|
|
active = 0
|
|
peak = 0
|
|
|
|
async def slow(**fields: Any) -> dict[str, Any]:
|
|
nonlocal active, peak
|
|
active += 1
|
|
peak = max(peak, active)
|
|
await asyncio.sleep(0.01)
|
|
active -= 1
|
|
lamp_state.update(fields)
|
|
return deepcopy(lamp_state)
|
|
|
|
mock_client.set_state.side_effect = slow
|
|
entity_id = entity_id_for(hass, "light", "light")
|
|
await asyncio.gather(
|
|
*(
|
|
hass.services.async_call(
|
|
LIGHT_DOMAIN,
|
|
SERVICE_TURN_ON,
|
|
{ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: value},
|
|
blocking=True,
|
|
)
|
|
for value in (60, 120, 180)
|
|
)
|
|
)
|
|
assert mock_client.set_state.await_count == 3
|
|
assert peak == 1
|
|
|
|
|
|
async def test_unreachable_lamp_becomes_unavailable_and_recovers(
|
|
hass: HomeAssistant, init_integration, mock_client: MagicMock, freezer: FrozenDateTimeFactory
|
|
) -> None:
|
|
entity_id = entity_id_for(hass, "light", "light")
|
|
mock_client.get_state.side_effect = NinlightConnectionError("down")
|
|
for _ in range(3):
|
|
freezer.tick(timedelta(seconds=31))
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
|
|
|
|
mock_client.get_state.side_effect = lambda: deepcopy(STATE_RGB)
|
|
freezer.tick(timedelta(seconds=31))
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
assert hass.states.get(entity_id).state == STATE_ON
|