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"
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.COVER,
Platform.LIGHT,

View File

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

View File

@@ -7,7 +7,7 @@ from collections.abc import Callable
import re
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.state import QbusMqttState
@@ -44,11 +44,15 @@ def determine_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
]
new_outputs = (
[
output
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:
added_outputs.extend(new_outputs)
@@ -64,9 +68,14 @@ 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))
def create_device_identifier(mqtt_device: QbusMqttDevice) -> tuple[str, str]:
"""Create the device identifier."""
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):
@@ -95,16 +104,18 @@ class QbusEntity(Entity, Generic[StateT], ABC):
)
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:
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:
self._attr_device_info = DeviceInfo(
identifiers={create_main_device_identifier(mqtt_output)}
identifiers={create_device_identifier(mqtt_output.device)}
)
else:
self._attr_device_info = DeviceInfo(
@@ -112,7 +123,7 @@ class QbusEntity(Entity, Generic[StateT], ABC):
manufacturer=MANUFACTURER,
identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")},
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:

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": {
"binary_sensor": {
"raining": {
"name": "Raining"
},
"twilight": {
"name": "Twilight"
}
},
"sensor": {
"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)