Create one light entity per Modern Forms Gen4 light fixture (#180524)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brian Towles
2026-08-30 17:55:25 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 20ea690f96
commit fb8a2aa00e
9 changed files with 501 additions and 26 deletions
@@ -8,6 +8,22 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ModernFormsDataUpdateCoordinator
_NAME_SEPARATORS = " -_"
def strip_device_name_prefix(device_name: str, name: str) -> str | None:
"""Strip a leading device-name prefix so has_entity_name doesn't duplicate it.
Returns None (rather than a name identical to the device name) when
the fixture name adds nothing beyond the device name.
"""
if not device_name or not name.lower().startswith(device_name.lower()):
return name
rest = name[len(device_name) :]
if rest and rest[0] not in _NAME_SEPARATORS:
return name
return rest.lstrip(_NAME_SEPARATORS) or None
class ModernFormsDeviceEntity(CoordinatorEntity[ModernFormsDataUpdateCoordinator]):
"""Defines a Modern Forms device entity."""
+60 -21
View File
@@ -3,6 +3,7 @@
from typing import Any, override
from aiomodernforms.const import LIGHT_POWER_OFF, LIGHT_POWER_ON
from aiomodernforms.models import Light
import voluptuous as vol
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
@@ -24,7 +25,7 @@ from .const import (
SERVICE_SET_LIGHT_SLEEP_TIMER,
)
from .coordinator import ModernFormsConfigEntry, ModernFormsDataUpdateCoordinator
from .entity import ModernFormsDeviceEntity
from .entity import ModernFormsDeviceEntity, strip_device_name_prefix
BRIGHTNESS_RANGE = (1, 255)
@@ -61,11 +62,12 @@ async def async_setup_entry(
)
async_add_entities(
[
ModernFormsLightEntity(
entry_id=config_entry.entry_id, coordinator=coordinator
)
]
ModernFormsLightEntity(
entry_id=config_entry.entry_id,
coordinator=coordinator,
light_address=light.address,
)
for light in coordinator.data.state.light_fixtures
)
@@ -77,49 +79,79 @@ class ModernFormsLightEntity(ModernFormsDeviceEntity, LightEntity):
_attr_translation_key = "light"
def __init__(
self, entry_id: str, coordinator: ModernFormsDataUpdateCoordinator
self,
entry_id: str,
coordinator: ModernFormsDataUpdateCoordinator,
light_address: int | None,
) -> None:
"""Initialize Modern Forms light."""
super().__init__(
entry_id=entry_id,
coordinator=coordinator,
)
self._attr_unique_id = f"{self.coordinator.data.info.mac_address}"
super().__init__(entry_id=entry_id, coordinator=coordinator)
self._address = light_address
mac_address = self.coordinator.data.info.mac_address
if light_address is None:
self._attr_unique_id = mac_address
else:
# Real Gen4 fixtures are named by the user, so the device-name
# prefix strip below is per-device data rather than static.
self._attr_unique_id = f"{mac_address}_{light_address}"
fixture_name = next(
light.name
for light in coordinator.data.state.light_fixtures
if light.address == light_address
)
self._attr_name = strip_device_name_prefix(
self.coordinator.data.info.device_name, fixture_name
)
@property
def _light(self) -> Light | None:
"""Return this entity's current fixture data, if it still exists."""
for light in self.coordinator.data.state.light_fixtures:
if light.address == self._address:
return light
return None
@property
@override
def available(self) -> bool:
"""Return True if the fixture this entity represents still exists."""
return super().available and self._light is not None
@property
@override
def brightness(self) -> int | None:
"""Return the brightness of this light between 1..255."""
if self._light is None:
return None
return round(
percentage_to_ranged_value(
BRIGHTNESS_RANGE, self.coordinator.data.state.light_brightness
)
percentage_to_ranged_value(BRIGHTNESS_RANGE, self._light.brightness)
)
@property
@override
def is_on(self) -> bool:
"""Return the state of the light."""
return bool(self.coordinator.data.state.light_on)
return self._light is not None and bool(self._light.on)
@modernforms_exception_handler
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
await self.coordinator.modern_forms.light(on=LIGHT_POWER_OFF)
await self._async_control_light(on=LIGHT_POWER_OFF)
@modernforms_exception_handler
@override
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
data = {OPT_ON: LIGHT_POWER_ON}
data: dict[str, Any] = {OPT_ON: LIGHT_POWER_ON}
if ATTR_BRIGHTNESS in kwargs:
data[OPT_BRIGHTNESS] = ranged_value_to_percentage(
BRIGHTNESS_RANGE, kwargs[ATTR_BRIGHTNESS]
)
await self.coordinator.modern_forms.light(**data)
await self._async_control_light(**data)
@modernforms_exception_handler
async def async_set_light_sleep_timer(
@@ -127,11 +159,18 @@ class ModernFormsLightEntity(ModernFormsDeviceEntity, LightEntity):
sleep_time: int,
) -> None:
"""Set a Modern Forms light sleep timer."""
await self.coordinator.modern_forms.light(sleep=sleep_time * 60)
await self._async_control_light(sleep=sleep_time * 60)
@modernforms_exception_handler
async def async_clear_light_sleep_timer(
self,
) -> None:
"""Clear a Modern Forms light sleep timer."""
await self.coordinator.modern_forms.light(sleep=CLEAR_TIMER)
await self._async_control_light(sleep=CLEAR_TIMER)
async def _async_control_light(self, **kwargs: Any) -> None:
"""Send a control command to this entity's fixture."""
if self._address is None:
await self.coordinator.modern_forms.light(**kwargs)
else:
await self.coordinator.modern_forms.light_fixture(self._address, **kwargs)
+55 -1
View File
@@ -6,12 +6,17 @@ import json
from typing import Any
from aiomodernforms.const import COMMAND_QUERY_STATIC_DATA
from yarl import URL
from homeassistant.components.modern_forms.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_MAC, CONTENT_TYPE_JSON
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, async_load_fixture
from tests.common import (
MockConfigEntry,
async_load_fixture,
async_load_json_object_fixture,
)
from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse
@@ -120,3 +125,52 @@ async def init_integration(
await hass.async_block_till_done()
return entry
async def modern_forms_gen4_call_mock(
hass: HomeAssistant, method: str, url: URL, data: dict[str, Any]
) -> AiohttpClientMockResponse:
"""Route Gen4 /device and /fixture requests to their fixtures."""
fixture = (
"device_gen4.json" if url.path.endswith("/device") else "fixture_gen4.json"
)
return AiohttpClientMockResponse(
method=method,
url=url,
json=await async_load_json_object_fixture(hass, fixture, DOMAIN),
)
async def init_integration_gen4(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
skip_setup: bool = False,
mock_type: Callable[
[HomeAssistant, str, URL, dict[str, Any]],
Coroutine[Any, Any, AiohttpClientMockResponse],
] = modern_forms_gen4_call_mock,
) -> MockConfigEntry:
"""Set up the Modern Forms integration against a mock Gen4 device."""
aioclient_mock.post("http://192.168.1.123:80/mf", text="", status=404)
aioclient_mock.post(
"http://192.168.1.123:80/device",
side_effect=partial(mock_type, hass),
)
aioclient_mock.post(
"http://192.168.1.123:80/fixture",
side_effect=partial(mock_type, hass),
)
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "192.168.1.123", CONF_MAC: "AA:BB:CC:00:11:22"},
unique_id="AA:BB:CC:00:11:22",
)
entry.add_to_hass(hass)
if not skip_setup:
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry
@@ -0,0 +1,8 @@
{
"systemType": "FAN_G4",
"deviceName": "ModernFormsFan",
"iotmVer": "01.03.0025",
"scmVer": "01.03.3008",
"owner": "someone@somewhere.com",
"staMac": "AA:BB:CC:00:11:22"
}
@@ -0,0 +1,31 @@
{
"fixture": [
{
"addr": 1,
"type": 13,
"name": "Fan",
"detail": { "model": "2603-56" },
"state": {
"status": true,
"fanSpeed": 3,
"fanDirection": false,
"wind": false,
"windSpeed": 3
}
},
{
"addr": 2,
"type": 0,
"name": "ModernFormsFan Uplight",
"detail": { "minColorTemp": 2700, "maxColorTemp": 5000 },
"state": { "status": true, "level": 8000, "mixColorTemp": 3000 }
},
{
"addr": 3,
"type": 0,
"name": "ModernFormsFan Downlight",
"detail": { "minColorTemp": 2700, "maxColorTemp": 5000 },
"state": { "status": false, "level": 5000, "mixColorTemp": 4000 }
}
]
}
@@ -77,3 +77,91 @@
}),
})
# ---
# name: test_entry_diagnostics_gen4
dict({
'config_entry': dict({
'data': dict({
'host': '192.168.1.123',
'mac': '**REDACTED**',
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'modern_forms',
'minor_version': 1,
'options': dict({
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'user',
'subentries': list([
]),
'title': 'Mock Title',
'unique_id': 'AA:BB:CC:00:11:22',
'version': 1,
}),
'device': dict({
'info': dict({
'brand': None,
'client_id': '',
'date_code': '',
'device_name': 'ModernFormsFan',
'fan_motor_type': '',
'fan_type': '2603-56',
'federated_identity': '',
'firmware_url': '',
'firmware_version': '01.03.0025',
'light_type': 'gen4',
'mac_address': '**REDACTED**',
'main_mcu_firmware_version': '01.03.3008',
'owner': '**REDACTED**',
'product_sku': '',
'production_lot_number': '',
}),
'status': dict({
'adaptive_learning_enabled': False,
'away_mode_enabled': False,
'decommission': False,
'factory_reset': False,
'fan_direction': 'forward',
'fan_on': True,
'fan_sleep_timer': 0,
'fan_speed': 3,
'fan_timer': None,
'light_brightness': 80,
'light_color_temp_kelvin': 3000,
'light_fixtures': list([
dict({
'address': 2,
'brightness': 80,
'color_temp_kelvin': 3000,
'fixture_type': 0,
'max_color_temp_kelvin': 5000,
'min_color_temp_kelvin': 2700,
'name': '**REDACTED**',
'on': True,
}),
dict({
'address': 3,
'brightness': 50,
'color_temp_kelvin': 4000,
'fixture_type': 0,
'max_color_temp_kelvin': 5000,
'min_color_temp_kelvin': 2700,
'name': '**REDACTED**',
'on': False,
}),
]),
'light_on': True,
'light_sleep_timer': 0,
'light_timer': None,
'reset_rf_pair_list': False,
'rf_pair_mode_active': False,
'schedule': '',
'user_data': '',
'wind': False,
'wind_speed': 3,
}),
}),
})
# ---
@@ -1,5 +1,6 @@
"""Tests for the Modern Forms config flow."""
from functools import partial
from ipaddress import ip_address
from unittest.mock import MagicMock, patch
@@ -13,7 +14,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import init_integration
from . import init_integration, modern_forms_gen4_call_mock
from tests.common import async_load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
@@ -233,3 +234,36 @@ async def test_zeroconf_with_mac_device_exists_abort(
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "already_configured"
async def test_full_user_flow_gen4(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test the user flow successfully adds a Gen4 fan."""
aioclient_mock.post("http://192.168.1.123:80/mf", text="", status=404)
aioclient_mock.post(
"http://192.168.1.123:80/device",
side_effect=partial(modern_forms_gen4_call_mock, hass),
)
aioclient_mock.post(
"http://192.168.1.123:80/fixture",
side_effect=partial(modern_forms_gen4_call_mock, hass),
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
with patch(
"homeassistant.components.modern_forms.async_setup_entry",
return_value=True,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "192.168.1.123"}
)
assert result2.get("type") is FlowResultType.CREATE_ENTRY
assert result2.get("title") == "ModernFormsFan"
assert result2["data"][CONF_HOST] == "192.168.1.123"
assert result2["data"][CONF_MAC] == "AA:BB:CC:00:11:22"
@@ -5,7 +5,7 @@ from syrupy.filters import props
from homeassistant.core import HomeAssistant
from . import init_integration
from . import init_integration, init_integration_gen4
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.test_util.aiohttp import AiohttpClientMocker
@@ -24,3 +24,21 @@ async def test_entry_diagnostics(
result = await get_diagnostics_for_config_entry(hass, hass_client, entry)
assert result == snapshot(exclude=props("created_at", "modified_at", "entry_id"))
async def test_entry_diagnostics_gen4(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
hass_client: ClientSessionGenerator,
snapshot: SnapshotAssertion,
) -> None:
"""Test Gen4 fixture names are redacted from diagnostics."""
entry = await init_integration_gen4(hass, aioclient_mock)
result = await get_diagnostics_for_config_entry(hass, hass_client, entry)
fixture_names = [
fixture["name"] for fixture in result["device"]["status"]["light_fixtures"]
]
assert fixture_names == ["**REDACTED**", "**REDACTED**"]
assert result == snapshot(exclude=props("created_at", "modified_at", "entry_id"))
+189 -2
View File
@@ -1,9 +1,11 @@
"""Tests for the Modern Forms light platform."""
from typing import Any
from unittest.mock import patch
from aiomodernforms import ModernFormsConnectionError
import pytest
from yarl import URL
from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN
from homeassistant.components.modern_forms.const import (
@@ -17,6 +19,7 @@ from homeassistant.const import (
ATTR_FRIENDLY_NAME,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
)
@@ -24,9 +27,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import init_integration
from . import init_integration, init_integration_gen4, modern_forms_gen4_call_mock
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.common import async_load_json_object_fixture
from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse
async def test_light_state(
@@ -164,3 +168,186 @@ async def test_light_connection_error(
state = hass.states.get("light.modernformsfan_light")
assert state.state == STATE_UNAVAILABLE
async def test_light_state_gen4(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test a multi-fixture Gen4 fan creates one light entity per fixture."""
await init_integration_gen4(hass, aioclient_mock)
state = hass.states.get("light.modernformsfan_uplight")
assert state
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "ModernFormsFan Uplight"
assert state.state == STATE_ON
assert state.attributes.get(ATTR_BRIGHTNESS) == 204
entry = entity_registry.async_get("light.modernformsfan_uplight")
assert entry
assert entry.unique_id == "AA:BB:CC:00:11:22_2"
state = hass.states.get("light.modernformsfan_downlight")
assert state
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "ModernFormsFan Downlight"
assert state.state == STATE_OFF
assert state.attributes.get(ATTR_BRIGHTNESS) is None
entry = entity_registry.async_get("light.modernformsfan_downlight")
assert entry
assert entry.unique_id == "AA:BB:CC:00:11:22_3"
async def test_light_name_requires_word_boundary_gen4(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test a fixture name isn't stripped without a real device-name boundary."""
async def partial_word_name_mock(
hass: HomeAssistant, method: str, url: URL, data: dict[str, Any]
) -> AiohttpClientMockResponse:
"""Serve the normal Gen4 fixtures, with the uplight renamed."""
if not url.path.endswith("/fixture"):
return await modern_forms_gen4_call_mock(hass, method, url, data)
payload = await async_load_json_object_fixture(
hass, "fixture_gen4.json", DOMAIN
)
for fixture in payload["fixture"]:
if fixture["addr"] == 2:
fixture["name"] = "ModernFormsFancy Light"
return AiohttpClientMockResponse(method=method, url=url, json=payload)
await init_integration_gen4(hass, aioclient_mock, mock_type=partial_word_name_mock)
entity_id = entity_registry.async_get_entity_id(
LIGHT_DOMAIN, DOMAIN, "AA:BB:CC:00:11:22_2"
)
assert entity_id
state = hass.states.get(entity_id)
assert state
assert (
state.attributes.get(ATTR_FRIENDLY_NAME)
== "ModernFormsFan ModernFormsFancy Light"
)
async def test_light_name_falls_back_to_device_name_gen4(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test a fixture named exactly like the device falls back to that name alone."""
async def exact_match_name_mock(
hass: HomeAssistant, method: str, url: URL, data: dict[str, Any]
) -> AiohttpClientMockResponse:
"""Serve the normal Gen4 fixtures, with the uplight renamed."""
if not url.path.endswith("/fixture"):
return await modern_forms_gen4_call_mock(hass, method, url, data)
payload = await async_load_json_object_fixture(
hass, "fixture_gen4.json", DOMAIN
)
for fixture in payload["fixture"]:
if fixture["addr"] == 2:
fixture["name"] = "ModernFormsFan"
return AiohttpClientMockResponse(method=method, url=url, json=payload)
await init_integration_gen4(hass, aioclient_mock, mock_type=exact_match_name_mock)
entity_id = entity_registry.async_get_entity_id(
LIGHT_DOMAIN, DOMAIN, "AA:BB:CC:00:11:22_2"
)
assert entity_id
state = hass.states.get(entity_id)
assert state
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "ModernFormsFan"
async def test_light_unavailable_when_fixture_disappears_gen4(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test a Gen4 light entity goes unavailable if its fixture disappears."""
removed_addresses: set[int] = set()
async def fixture_removal_mock(
hass: HomeAssistant, method: str, url: URL, data: dict[str, Any]
) -> AiohttpClientMockResponse:
"""Serve the normal Gen4 fixtures, minus any addresses removed."""
if not url.path.endswith("/fixture") or not removed_addresses:
return await modern_forms_gen4_call_mock(hass, method, url, data)
payload = await async_load_json_object_fixture(
hass, "fixture_gen4.json", DOMAIN
)
payload["fixture"] = [
fixture
for fixture in payload["fixture"]
if fixture["addr"] not in removed_addresses
]
return AiohttpClientMockResponse(method=method, url=url, json=payload)
entry = await init_integration_gen4(
hass, aioclient_mock, mock_type=fixture_removal_mock
)
state = hass.states.get("light.modernformsfan_uplight")
assert state
assert state.state == STATE_ON
removed_addresses.add(2)
await entry.runtime_data.async_refresh()
await hass.async_block_till_done()
state = hass.states.get("light.modernformsfan_uplight")
assert state
assert state.state == STATE_UNAVAILABLE
async def test_light_change_state_gen4(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test Gen4 fixture entities control via light_fixture(), not light()."""
await init_integration_gen4(hass, aioclient_mock)
with (
patch("aiomodernforms.ModernFormsDevice.light_fixture") as light_fixture_mock,
patch("aiomodernforms.ModernFormsDevice.light") as light_mock,
):
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: "light.modernformsfan_uplight"},
blocking=True,
)
await hass.async_block_till_done()
light_fixture_mock.assert_called_once_with(2, on=False)
light_mock.assert_not_called()
async def test_sleep_timer_services_gen4(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test Gen4 sleep timer services pass sleep through light_fixture()."""
await init_integration_gen4(hass, aioclient_mock)
with patch("aiomodernforms.ModernFormsDevice.light_fixture") as light_fixture_mock:
await hass.services.async_call(
DOMAIN,
SERVICE_SET_LIGHT_SLEEP_TIMER,
{ATTR_ENTITY_ID: "light.modernformsfan_uplight", ATTR_SLEEP_TIME: 1},
blocking=True,
)
await hass.async_block_till_done()
light_fixture_mock.assert_called_once_with(2, sleep=60)
with patch("aiomodernforms.ModernFormsDevice.light_fixture") as light_fixture_mock:
await hass.services.async_call(
DOMAIN,
SERVICE_CLEAR_LIGHT_SLEEP_TIMER,
{ATTR_ENTITY_ID: "light.modernformsfan_uplight"},
blocking=True,
)
await hass.async_block_till_done()
light_fixture_mock.assert_called_once_with(2, sleep=0)