Add light platform to qbus (#136168)

* Add light platform

* Add on/off for light

* Renamed add_entities to async_add_entities

* Revert qbusmqttapi bump

* Align dependency version

* Use AddConfigEntryEntitiesCallback

* Use AddConfigEntryEntitiesCallback
This commit is contained in:
Thomas D 2025-02-20 06:13:13 +01:00 committed by GitHub
parent 5dfd358fc9
commit 5d851b6a56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 293 additions and 20 deletions

View File

@ -5,7 +5,10 @@ from typing import Final
from homeassistant.const import Platform
DOMAIN: Final = "qbus"
PLATFORMS: list[Platform] = [Platform.SWITCH]
PLATFORMS: list[Platform] = [
Platform.LIGHT,
Platform.SWITCH,
]
CONF_SERIAL_NUMBER: Final = "serial"

View File

@ -1,6 +1,9 @@
"""Base class for Qbus entities."""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Callable
import re
from qbusmqttapi.discovery import QbusMqttOutput
@ -10,12 +13,36 @@ from qbusmqttapi.state import QbusMqttState
from homeassistant.components.mqtt import ReceiveMessage, client as mqtt
from homeassistant.helpers.device_registry import DeviceInfo, format_mac
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, MANUFACTURER
from .coordinator import QbusControllerCoordinator
_REFID_REGEX = re.compile(r"^\d+\/(\d+(?:\/\d+)?)$")
def add_new_outputs(
coordinator: QbusControllerCoordinator,
added_outputs: list[QbusMqttOutput],
filter_fn: Callable[[QbusMqttOutput], bool],
entity_type: type[QbusEntity],
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Call async_add_entities for new outputs."""
added_ref_ids = {k.ref_id for k in added_outputs}
new_outputs = [
output
for output in coordinator.data
if filter_fn(output) and output.ref_id not in added_ref_ids
]
if new_outputs:
added_outputs.extend(new_outputs)
async_add_entities([entity_type(output) for output in new_outputs])
def format_ref_id(ref_id: str) -> str | None:
"""Format the Qbus ref_id."""
matches: list[str] = re.findall(_REFID_REGEX, ref_id)

View File

@ -0,0 +1,110 @@
"""Support for Qbus light."""
from typing import Any
from qbusmqttapi.discovery import QbusMqttOutput
from qbusmqttapi.state import QbusMqttAnalogState, StateType
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.components.mqtt import ReceiveMessage
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.color import brightness_to_value, value_to_brightness
from .coordinator import QbusConfigEntry
from .entity import QbusEntity, add_new_outputs
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: QbusConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up light entities."""
coordinator = entry.runtime_data
added_outputs: list[QbusMqttOutput] = []
def _check_outputs() -> None:
add_new_outputs(
coordinator,
added_outputs,
lambda output: output.type == "analog",
QbusLight,
async_add_entities,
)
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
class QbusLight(QbusEntity, LightEntity):
"""Representation of a Qbus light entity."""
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
_attr_color_mode = ColorMode.BRIGHTNESS
def __init__(self, mqtt_output: QbusMqttOutput) -> None:
"""Initialize light entity."""
super().__init__(mqtt_output)
self._set_state()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
brightness = kwargs.get(ATTR_BRIGHTNESS)
percentage: int | None = None
on: bool | None = None
state = QbusMqttAnalogState(id=self._mqtt_output.id)
if brightness is None:
on = True
state.type = StateType.ACTION
state.write_on_off(on)
else:
percentage = round(brightness_to_value((1, 100), brightness))
state.type = StateType.STATE
state.write_percentage(percentage)
await self._async_publish_output_state(state)
self._set_state(percentage=percentage, on=on)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
state = QbusMqttAnalogState(id=self._mqtt_output.id, type=StateType.ACTION)
state.write_on_off(on=False)
await self._async_publish_output_state(state)
self._set_state(on=False)
async def _state_received(self, msg: ReceiveMessage) -> None:
output = self._message_factory.parse_output_state(
QbusMqttAnalogState, msg.payload
)
if output is not None:
percentage = round(output.read_percentage())
self._set_state(percentage=percentage)
self.async_schedule_update_ha_state()
def _set_state(
self, *, percentage: int | None = None, on: bool | None = None
) -> None:
if percentage is None:
# When turning on without brightness, we don't know the desired
# brightness. It will be set during _state_received().
if on is True:
self._attr_is_on = True
else:
self._attr_is_on = False
self._attr_brightness = 0
else:
self._attr_is_on = percentage > 0
self._attr_brightness = value_to_brightness((1, 100), percentage)

View File

@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import QbusConfigEntry
from .entity import QbusEntity
from .entity import QbusEntity, add_new_outputs
PARALLEL_UPDATES = 0
@ -19,26 +19,21 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: QbusConfigEntry,
add_entities: AddConfigEntryEntitiesCallback,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch entities."""
coordinator = entry.runtime_data
coordinator = entry.runtime_data
added_outputs: list[QbusMqttOutput] = []
# Local function that calls add_entities for new entities
def _check_outputs() -> None:
added_output_ids = {k.id for k in added_outputs}
new_outputs = [
item
for item in coordinator.data
if item.type == "onoff" and item.id not in added_output_ids
]
if new_outputs:
added_outputs.extend(new_outputs)
add_entities([QbusSwitch(output) for output in new_outputs])
add_new_outputs(
coordinator,
added_outputs,
lambda output: output.type == "onoff",
QbusSwitch,
async_add_entities,
)
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
@ -49,10 +44,7 @@ class QbusSwitch(QbusEntity, SwitchEntity):
_attr_device_class = SwitchDeviceClass.SWITCH
def __init__(
self,
mqtt_output: QbusMqttOutput,
) -> None:
def __init__(self, mqtt_output: QbusMqttOutput) -> None:
"""Initialize switch entity."""
super().__init__(mqtt_output)

View File

@ -42,6 +42,29 @@
"write": true
}
}
},
{
"id": "UL15",
"location": "Media room",
"locationId": 0,
"name": "MEDIA ROOM",
"originalName": "MEDIA ROOM",
"refId": "000001/28",
"type": "analog",
"actions": {
"off": null,
"on": null
},
"properties": {
"value": {
"max": 100,
"min": 5,
"read": true,
"step": 0.1,
"type": "number",
"write": true
}
}
}
]
}

View File

@ -0,0 +1,118 @@
"""Test Qbus light entities."""
import json
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
DOMAIN as LIGHT_DOMAIN,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.util.json import JsonObjectType
from .const import TOPIC_CONFIG
from tests.common import MockConfigEntry, async_fire_mqtt_message
from tests.typing import MqttMockHAClient
# 186 = 73% (rounded)
_BRIGHTNESS = 186
_BRIGHTNESS_PCT = 73
_PAYLOAD_LIGHT_STATE_ON = '{"id":"UL15","properties":{"value":60},"type":"state"}'
_PAYLOAD_LIGHT_STATE_BRIGHTNESS = (
'{"id":"UL15","properties":{"value":' + str(_BRIGHTNESS_PCT) + '},"type":"state"}'
)
_PAYLOAD_LIGHT_STATE_OFF = '{"id":"UL15","properties":{"value":0},"type":"state"}'
_PAYLOAD_LIGHT_SET_STATE_ON = '{"id": "UL15", "type": "action", "action": "on"}'
_PAYLOAD_LIGHT_SET_STATE_BRIGHTNESS = (
'{"id": "UL15", "type": "state", "properties": {"value": '
+ str(_BRIGHTNESS_PCT)
+ "}}"
)
_PAYLOAD_LIGHT_SET_STATE_OFF = '{"id": "UL15", "type": "action", "action": "off"}'
_TOPIC_LIGHT_STATE = "cloudapp/QBUSMQTTGW/UL1/UL15/state"
_TOPIC_LIGHT_SET_STATE = "cloudapp/QBUSMQTTGW/UL1/UL15/setState"
_LIGHT_ENTITY_ID = "light.media_room"
async def test_light(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
mock_config_entry: MockConfigEntry,
payload_config: JsonObjectType,
) -> None:
"""Test turning on and off."""
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
async_fire_mqtt_message(hass, TOPIC_CONFIG, json.dumps(payload_config))
await hass.async_block_till_done()
# Switch ON
mqtt_mock.reset_mock()
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: _LIGHT_ENTITY_ID},
blocking=True,
)
mqtt_mock.async_publish.assert_called_once_with(
_TOPIC_LIGHT_SET_STATE, _PAYLOAD_LIGHT_SET_STATE_ON, 0, False
)
# Simulate response
async_fire_mqtt_message(hass, _TOPIC_LIGHT_STATE, _PAYLOAD_LIGHT_STATE_ON)
await hass.async_block_till_done()
assert hass.states.get(_LIGHT_ENTITY_ID).state == STATE_ON
# Set brightness
mqtt_mock.reset_mock()
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: _LIGHT_ENTITY_ID,
ATTR_BRIGHTNESS: _BRIGHTNESS,
},
blocking=True,
)
mqtt_mock.async_publish.assert_called_once_with(
_TOPIC_LIGHT_SET_STATE, _PAYLOAD_LIGHT_SET_STATE_BRIGHTNESS, 0, False
)
# Simulate response
async_fire_mqtt_message(hass, _TOPIC_LIGHT_STATE, _PAYLOAD_LIGHT_STATE_BRIGHTNESS)
await hass.async_block_till_done()
entity = hass.states.get(_LIGHT_ENTITY_ID)
assert entity.state == STATE_ON
assert entity.attributes.get(ATTR_BRIGHTNESS) == _BRIGHTNESS
# Switch OFF
mqtt_mock.reset_mock()
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: _LIGHT_ENTITY_ID},
blocking=True,
)
mqtt_mock.async_publish.assert_called_once_with(
_TOPIC_LIGHT_SET_STATE, _PAYLOAD_LIGHT_SET_STATE_OFF, 0, False
)
# Simulate response
async_fire_mqtt_message(hass, _TOPIC_LIGHT_STATE, _PAYLOAD_LIGHT_STATE_OFF)
await hass.async_block_till_done()
assert hass.states.get(_LIGHT_ENTITY_ID).state == STATE_OFF