Add binary sensor platform to qbus integration (#149975)

This commit is contained in:
Thomas D
2025-08-15 15:35:51 +02:00
committed by GitHub
parent f72f2a326a
commit 2a62e033dd
8 changed files with 383 additions and 28 deletions

View File

@@ -0,0 +1,144 @@
"""Support for Qbus binary sensor."""
from dataclasses import dataclass
from typing import cast
from qbusmqttapi.discovery import QbusMqttDevice, QbusMqttOutput
from qbusmqttapi.factory import QbusMqttTopicFactory
from qbusmqttapi.state import QbusMqttDeviceState, QbusMqttWeatherState
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import QbusConfigEntry
from .entity import (
QbusEntity,
create_device_identifier,
create_unique_id,
determine_new_outputs,
)
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class QbusWeatherDescription(BinarySensorEntityDescription):
"""Description for Qbus weather entities."""
property: str
_WEATHER_DESCRIPTIONS = (
QbusWeatherDescription(
key="raining",
property="raining",
translation_key="raining",
),
QbusWeatherDescription(
key="twilight",
property="twilight",
translation_key="twilight",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: QbusConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensor entities."""
coordinator = entry.runtime_data
added_outputs: list[QbusMqttOutput] = []
added_controllers: list[str] = []
def _create_weather_entities() -> list[BinarySensorEntity]:
new_outputs = determine_new_outputs(
coordinator, added_outputs, lambda output: output.type == "weatherstation"
)
return [
QbusWeatherBinarySensor(output, description)
for output in new_outputs
for description in _WEATHER_DESCRIPTIONS
]
def _create_controller_entities() -> list[BinarySensorEntity]:
if coordinator.data and coordinator.data.id not in added_controllers:
added_controllers.extend(coordinator.data.id)
return [QbusControllerConnectedBinarySensor(coordinator.data)]
return []
def _check_outputs() -> None:
entities = [*_create_weather_entities(), *_create_controller_entities()]
async_add_entities(entities)
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
class QbusWeatherBinarySensor(QbusEntity, BinarySensorEntity):
"""Representation of a Qbus weather binary sensor."""
_state_cls = QbusMqttWeatherState
entity_description: QbusWeatherDescription
def __init__(
self, mqtt_output: QbusMqttOutput, description: QbusWeatherDescription
) -> None:
"""Initialize binary sensor entity."""
super().__init__(mqtt_output, id_suffix=description.key)
self.entity_description = description
async def _handle_state_received(self, state: QbusMqttWeatherState) -> None:
if value := state.read_property(self.entity_description.property, None):
self._attr_is_on = (
None if value is None else cast(str, value).lower() == "true"
)
class QbusControllerConnectedBinarySensor(BinarySensorEntity):
"""Representation of the Qbus controller connected sensor."""
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
def __init__(self, controller: QbusMqttDevice) -> None:
"""Initialize binary sensor entity."""
self._controller = controller
self._attr_unique_id = create_unique_id(controller.serial_number, "connected")
self._attr_device_info = DeviceInfo(
identifiers={create_device_identifier(controller)}
)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
topic = QbusMqttTopicFactory().get_device_state_topic(self._controller.id)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{topic}",
self._state_received,
)
)
@callback
def _state_received(self, state: QbusMqttDeviceState) -> None:
self._attr_is_on = state.properties.connected if state.properties else None
self.async_schedule_update_ha_state()

View File

@@ -6,6 +6,7 @@ from homeassistant.const import Platform
DOMAIN: Final = "qbus" DOMAIN: Final = "qbus"
PLATFORMS: list[Platform] = [ PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.CLIMATE, Platform.CLIMATE,
Platform.COVER, Platform.COVER,
Platform.LIGHT, Platform.LIGHT,

View File

@@ -6,7 +6,7 @@ from datetime import datetime
import logging import logging
from typing import cast from typing import cast
from qbusmqttapi.discovery import QbusDiscovery, QbusMqttDevice, QbusMqttOutput from qbusmqttapi.discovery import QbusDiscovery, QbusMqttDevice
from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory
from homeassistant.components.mqtt import ( from homeassistant.components.mqtt import (
@@ -19,6 +19,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
@@ -32,7 +33,7 @@ type QbusConfigEntry = ConfigEntry[QbusControllerCoordinator]
QBUS_KEY: HassKey[QbusConfigCoordinator] = HassKey(DOMAIN) QBUS_KEY: HassKey[QbusConfigCoordinator] = HassKey(DOMAIN)
class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]): class QbusControllerCoordinator(DataUpdateCoordinator[QbusMqttDevice | None]):
"""Qbus data coordinator.""" """Qbus data coordinator."""
_STATE_REQUEST_DELAY = 3 _STATE_REQUEST_DELAY = 3
@@ -63,8 +64,8 @@ class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]):
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown)
) )
async def _async_update_data(self) -> list[QbusMqttOutput]: async def _async_update_data(self) -> QbusMqttDevice | None:
return self._controller.outputs if self._controller else [] return self._controller
def shutdown(self, event: Event | None = None) -> None: def shutdown(self, event: Event | None = None) -> None:
"""Shutdown Qbus coordinator.""" """Shutdown Qbus coordinator."""
@@ -140,20 +141,25 @@ class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]):
"%s - Receiving controller state %s", self.config_entry.unique_id, msg.topic "%s - Receiving controller state %s", self.config_entry.unique_id, msg.topic
) )
if self._controller is None or self._controller_activated: if self._controller is None:
return return
state = self._message_factory.parse_device_state(msg.payload) state = self._message_factory.parse_device_state(msg.payload)
if state and state.properties and state.properties.connectable is False: if state and state.properties:
_LOGGER.debug( async_dispatcher_send(self.hass, f"{DOMAIN}_{msg.topic}", state)
"%s - Activating controller %s", self.config_entry.unique_id, state.id
) if not self._controller_activated and state.properties.connectable is False:
self._controller_activated = True _LOGGER.debug(
request = self._message_factory.create_device_activate_request( "%s - Activating controller %s",
self._controller self.config_entry.unique_id,
) state.id,
await mqtt.async_publish(self.hass, request.topic, request.payload) )
self._controller_activated = True
request = self._message_factory.create_device_activate_request(
self._controller
)
await mqtt.async_publish(self.hass, request.topic, request.payload)
def _request_entity_states(self) -> None: def _request_entity_states(self) -> None:
async def request_state(_: datetime) -> None: async def request_state(_: datetime) -> None:

View File

@@ -7,7 +7,7 @@ from collections.abc import Callable
import re import re
from typing import Generic, TypeVar, cast from typing import Generic, TypeVar, cast
from qbusmqttapi.discovery import QbusMqttOutput from qbusmqttapi.discovery import QbusMqttDevice, QbusMqttOutput
from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory
from qbusmqttapi.state import QbusMqttState from qbusmqttapi.state import QbusMqttState
@@ -44,11 +44,15 @@ def determine_new_outputs(
added_ref_ids = {k.ref_id for k in added_outputs} added_ref_ids = {k.ref_id for k in added_outputs}
new_outputs = [ new_outputs = (
output [
for output in coordinator.data output
if filter_fn(output) and output.ref_id not in added_ref_ids for output in coordinator.data.outputs
] if filter_fn(output) and output.ref_id not in added_ref_ids
]
if coordinator.data
else []
)
if new_outputs: if new_outputs:
added_outputs.extend(new_outputs) added_outputs.extend(new_outputs)
@@ -64,9 +68,14 @@ def format_ref_id(ref_id: str) -> str | None:
return None return None
def create_main_device_identifier(mqtt_output: QbusMqttOutput) -> tuple[str, str]: def create_device_identifier(mqtt_device: QbusMqttDevice) -> tuple[str, str]:
"""Create the identifier referring to the main device this output belongs to.""" """Create the device identifier."""
return (DOMAIN, format_mac(mqtt_output.device.mac)) return (DOMAIN, format_mac(mqtt_device.mac))
def create_unique_id(serial_number: str, suffix: str) -> str:
"""Create the unique id."""
return f"ctd_{serial_number}_{suffix}"
class QbusEntity(Entity, Generic[StateT], ABC): class QbusEntity(Entity, Generic[StateT], ABC):
@@ -95,16 +104,18 @@ class QbusEntity(Entity, Generic[StateT], ABC):
) )
ref_id = format_ref_id(mqtt_output.ref_id) ref_id = format_ref_id(mqtt_output.ref_id)
unique_id = f"ctd_{mqtt_output.device.serial_number}_{ref_id}" suffix = ref_id or ""
if id_suffix: if id_suffix:
unique_id += f"_{id_suffix}" suffix += f"_{id_suffix}"
self._attr_unique_id = unique_id self._attr_unique_id = create_unique_id(
mqtt_output.device.serial_number, suffix
)
if link_to_main_device: if link_to_main_device:
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={create_main_device_identifier(mqtt_output)} identifiers={create_device_identifier(mqtt_output.device)}
) )
else: else:
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
@@ -112,7 +123,7 @@ class QbusEntity(Entity, Generic[StateT], ABC):
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")}, identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")},
suggested_area=mqtt_output.location.title(), suggested_area=mqtt_output.location.title(),
via_device=create_main_device_identifier(mqtt_output), via_device=create_device_identifier(mqtt_output.device),
) )
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:

View File

@@ -0,0 +1,12 @@
{
"entity": {
"binary_sensor": {
"raining": {
"default": "mdi:weather-pouring"
},
"twilight": {
"default": "mdi:weather-sunset"
}
}
}
}

View File

@@ -17,6 +17,14 @@
} }
}, },
"entity": { "entity": {
"binary_sensor": {
"raining": {
"name": "Raining"
},
"twilight": {
"name": "Twilight"
}
},
"sensor": { "sensor": {
"daylight": { "daylight": {
"name": "Daylight" "name": "Daylight"

View File

@@ -0,0 +1,146 @@
# serializer version: 1
# name: test_binary_sensor[binary_sensor.ctd_000001-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.ctd_000001',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
'original_icon': None,
'original_name': None,
'platform': 'qbus',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'ctd_000001_connected',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensor[binary_sensor.ctd_000001-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'connectivity',
'friendly_name': 'CTD 000001',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.ctd_000001',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_binary_sensor[binary_sensor.weersensor_raining-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.weersensor_raining',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Raining',
'platform': 'qbus',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'raining',
'unique_id': 'ctd_000001_21007_raining',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensor[binary_sensor.weersensor_raining-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Weersensor Raining',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.weersensor_raining',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_binary_sensor[binary_sensor.weersensor_twilight-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.weersensor_twilight',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Twilight',
'platform': 'qbus',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'twilight',
'unique_id': 'ctd_000001_21007_twilight',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensor[binary_sensor.weersensor_twilight-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Weersensor Twilight',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.weersensor_twilight',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@@ -0,0 +1,27 @@
"""Test Qbus binary sensors."""
from collections.abc import Awaitable, Callable
from unittest.mock import patch
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
async def test_binary_sensor(
hass: HomeAssistant,
setup_integration_deferred: Callable[[], Awaitable],
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test binary sensor."""
with patch("homeassistant.components.qbus.PLATFORMS", [Platform.BINARY_SENSOR]):
await setup_integration_deferred()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)