Add scene platform to Qbus integration (#144032)

* Add scene platform

* Remove updating last_activated

* Simplify device info

* Move _attr_name to specific classes

* Refactor device info

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Thomas D 2025-05-26 15:37:07 +02:00 committed by GitHub
parent 54dce53628
commit 486535c189
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 141 additions and 7 deletions

View File

@ -57,6 +57,7 @@ async def async_setup_entry(
class QbusClimate(QbusEntity, ClimateEntity):
"""Representation of a Qbus climate entity."""
_attr_name = None
_attr_hvac_modes = [HVACMode.HEAT]
_attr_supported_features = (
ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE

View File

@ -8,6 +8,7 @@ DOMAIN: Final = "qbus"
PLATFORMS: list[Platform] = [
Platform.CLIMATE,
Platform.LIGHT,
Platform.SCENE,
Platform.SWITCH,
]

View File

@ -105,6 +105,7 @@ class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]):
device_registry = dr.async_get(self.hass)
device_registry.async_get_or_create(
config_entry_id=self.config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, self._controller.mac)},
identifiers={(DOMAIN, format_mac(self._controller.mac))},
manufacturer=MANUFACTURER,
model="CTD3.x",

View File

@ -54,34 +54,39 @@ def format_ref_id(ref_id: str) -> str | None:
return None
def create_main_device_identifier(mqtt_output: QbusMqttOutput) -> tuple[str, str]:
"""Create the identifier referring to the main device this output belongs to."""
return (DOMAIN, format_mac(mqtt_output.device.mac))
class QbusEntity(Entity, ABC):
"""Representation of a Qbus entity."""
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
def __init__(self, mqtt_output: QbusMqttOutput) -> None:
"""Initialize the Qbus entity."""
self._mqtt_output = mqtt_output
self._topic_factory = QbusMqttTopicFactory()
self._message_factory = QbusMqttMessageFactory()
self._state_topic = self._topic_factory.get_output_state_topic(
mqtt_output.device.id, mqtt_output.id
)
ref_id = format_ref_id(mqtt_output.ref_id)
self._attr_unique_id = f"ctd_{mqtt_output.device.serial_number}_{ref_id}"
# Create linked device
self._attr_device_info = DeviceInfo(
name=mqtt_output.name.title(),
manufacturer=MANUFACTURER,
identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")},
suggested_area=mqtt_output.location.title(),
via_device=(DOMAIN, format_mac(mqtt_output.device.mac)),
)
self._mqtt_output = mqtt_output
self._state_topic = self._topic_factory.get_output_state_topic(
mqtt_output.device.id, mqtt_output.id
via_device=create_main_device_identifier(mqtt_output),
)
async def async_added_to_hass(self) -> None:

View File

@ -43,6 +43,7 @@ async def async_setup_entry(
class QbusLight(QbusEntity, LightEntity):
"""Representation of a Qbus light entity."""
_attr_name = None
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
_attr_color_mode = ColorMode.BRIGHTNESS

View File

@ -0,0 +1,66 @@
"""Support for Qbus scene."""
from typing import Any
from qbusmqttapi.discovery import QbusMqttOutput
from qbusmqttapi.state import QbusMqttState, StateAction, StateType
from homeassistant.components.mqtt import ReceiveMessage
from homeassistant.components.scene import Scene
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import QbusConfigEntry
from .entity import QbusEntity, add_new_outputs, create_main_device_identifier
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: QbusConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up scene entities."""
coordinator = entry.runtime_data
added_outputs: list[QbusMqttOutput] = []
def _check_outputs() -> None:
add_new_outputs(
coordinator,
added_outputs,
lambda output: output.type == "scene",
QbusScene,
async_add_entities,
)
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
class QbusScene(QbusEntity, Scene):
"""Representation of a Qbus scene entity."""
def __init__(self, mqtt_output: QbusMqttOutput) -> None:
"""Initialize scene entity."""
super().__init__(mqtt_output)
# Add to main controller device
self._attr_device_info = DeviceInfo(
identifiers={create_main_device_identifier(mqtt_output)}
)
self._attr_name = mqtt_output.name.title()
async def async_activate(self, **kwargs: Any) -> None:
"""Activate scene."""
state = QbusMqttState(
id=self._mqtt_output.id, type=StateType.ACTION, action=StateAction.ACTIVE
)
await self._async_publish_output_state(state)
async def _state_received(self, msg: ReceiveMessage) -> None:
# Nothing to do
pass

View File

@ -42,6 +42,7 @@ async def async_setup_entry(
class QbusSwitch(QbusEntity, SwitchEntity):
"""Representation of a Qbus switch entity."""
_attr_name = None
_attr_device_class = SwitchDeviceClass.SWITCH
def __init__(self, mqtt_output: QbusMqttOutput) -> None:

View File

@ -99,6 +99,19 @@
"write": true
}
}
},
{
"id": "UL25",
"location": "Living",
"locationId": 0,
"name": "Watching TV",
"originalName": "Watching TV",
"refId": "000001/105/3",
"type": "scene",
"actions": {
"active": null
},
"properties": {}
}
]
}

View File

@ -0,0 +1,45 @@
"""Test Qbus scene entities."""
from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, SERVICE_TURN_ON
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from tests.common import async_fire_mqtt_message
from tests.typing import MqttMockHAClient
_PAYLOAD_SCENE_STATE = '{"id":"UL25","properties":{"value":true},"type":"state"}'
_PAYLOAD_SCENE_ACTIVATE = '{"id": "UL25", "type": "action", "action": "active"}'
_TOPIC_SCENE_STATE = "cloudapp/QBUSMQTTGW/UL1/UL25/state"
_TOPIC_SCENE_SET_STATE = "cloudapp/QBUSMQTTGW/UL1/UL25/setState"
_SCENE_ENTITY_ID = "scene.ctd_000001_watching_tv"
async def test_scene(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
setup_integration: None,
) -> None:
"""Test scene."""
assert hass.states.get(_SCENE_ENTITY_ID).state == STATE_UNKNOWN
# Activate scene
mqtt_mock.reset_mock()
await hass.services.async_call(
SCENE_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: _SCENE_ENTITY_ID},
blocking=True,
)
mqtt_mock.async_publish.assert_called_once_with(
_TOPIC_SCENE_SET_STATE, _PAYLOAD_SCENE_ACTIVATE, 0, False
)
# Simulate response
async_fire_mqtt_message(hass, _TOPIC_SCENE_STATE, _PAYLOAD_SCENE_STATE)
await hass.async_block_till_done()
assert hass.states.get(_SCENE_ENTITY_ID).state != STATE_UNKNOWN