Add binary_sensor to UISP airOS (#149803)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Tom
2025-08-08 20:34:40 +02:00
committed by GitHub
parent f2c9cdb09e
commit 9f1fe8a067
8 changed files with 416 additions and 13 deletions

View File

@@ -10,7 +10,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator
_PLATFORMS: list[Platform] = [Platform.SENSOR] _PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.SENSOR,
]
async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:

View File

@@ -0,0 +1,106 @@
"""AirOS Binary Sensor component for Home Assistant."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AirOSConfigEntry, AirOSData, AirOSDataUpdateCoordinator
from .entity import AirOSEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describe an AirOS binary sensor."""
value_fn: Callable[[AirOSData], bool]
BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = (
AirOSBinarySensorEntityDescription(
key="portfw",
translation_key="port_forwarding",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.portfw,
),
AirOSBinarySensorEntityDescription(
key="dhcp_client",
translation_key="dhcp_client",
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.services.dhcpc,
),
AirOSBinarySensorEntityDescription(
key="dhcp_server",
translation_key="dhcp_server",
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.services.dhcpd,
entity_registry_enabled_default=False,
),
AirOSBinarySensorEntityDescription(
key="dhcp6_server",
translation_key="dhcp6_server",
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.services.dhcp6d_stateful,
entity_registry_enabled_default=False,
),
AirOSBinarySensorEntityDescription(
key="pppoe",
translation_key="pppoe",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.services.pppoe,
entity_registry_enabled_default=False,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirOSConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the AirOS binary sensors from a config entry."""
coordinator = config_entry.runtime_data
async_add_entities(
AirOSBinarySensor(coordinator, description) for description in BINARY_SENSORS
)
class AirOSBinarySensor(AirOSEntity, BinarySensorEntity):
"""Representation of a binary sensor."""
entity_description: AirOSBinarySensorEntityDescription
def __init__(
self,
coordinator: AirOSDataUpdateCoordinator,
description: AirOSBinarySensorEntityDescription,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.host.device_id}_{description.key}"
@property
def is_on(self) -> bool:
"""Return the state of the binary sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -54,9 +54,7 @@ rules:
dynamic-devices: todo dynamic-devices: todo
entity-category: done entity-category: done
entity-device-class: done entity-device-class: done
entity-disabled-by-default: entity-disabled-by-default: done
status: todo
comment: prepared binary_sensors will provide this
entity-translations: done entity-translations: done
exception-translations: done exception-translations: done
icon-translations: icon-translations:

View File

@@ -26,6 +26,23 @@
} }
}, },
"entity": { "entity": {
"binary_sensor": {
"port_forwarding": {
"name": "Port forwarding"
},
"dhcp_client": {
"name": "DHCP client"
},
"dhcp_server": {
"name": "DHCP server"
},
"dhcp6_server": {
"name": "DHCPv6 server"
},
"pppoe": {
"name": "PPPoE link"
}
},
"sensor": { "sensor": {
"host_cpuload": { "host_cpuload": {
"name": "CPU load" "name": "CPU load"

View File

@@ -1,13 +1,19 @@
"""Tests for the Ubiquity airOS integration.""" """Tests for the Ubiquity airOS integration."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry from tests.common import MockConfigEntry, patch
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: async def setup_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
platforms: list[Platform] | None = None,
) -> None:
"""Fixture for setting up the component.""" """Fixture for setting up the component."""
config_entry.add_to_hass(hass) mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id) with patch("homeassistant.components.airos._PLATFORMS", platforms):
await hass.async_block_till_done() await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()

View File

@@ -0,0 +1,245 @@
# serializer version: 1
# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_client-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': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcp_client',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'DHCP client',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'dhcp_client',
'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp_client',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_client-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'NanoStation 5AC ap name DHCP client',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcp_client',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_server-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': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcp_server',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'DHCP server',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'dhcp_server',
'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp_server',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_server-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'NanoStation 5AC ap name DHCP server',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcp_server',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcpv6_server-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': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcpv6_server',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'DHCPv6 server',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'dhcp6_server',
'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp6_server',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcpv6_server-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'NanoStation 5AC ap name DHCPv6 server',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcpv6_server',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_port_forwarding-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': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.nanostation_5ac_ap_name_port_forwarding',
'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': 'Port forwarding',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'port_forwarding',
'unique_id': '03aa0d0b40fed0a47088293584ef5432_portfw',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_port_forwarding-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'NanoStation 5AC ap name Port forwarding',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.nanostation_5ac_ap_name_port_forwarding',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_pppoe_link-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': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.nanostation_5ac_ap_name_pppoe_link',
'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': 'PPPoE link',
'platform': 'airos',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'pppoe',
'unique_id': '03aa0d0b40fed0a47088293584ef5432_pppoe',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_pppoe_link-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'connectivity',
'friendly_name': 'NanoStation 5AC ap name PPPoE link',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.nanostation_5ac_ap_name_pppoe_link',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@@ -0,0 +1,28 @@
"""Test the Ubiquiti airOS binary sensors."""
from unittest.mock import AsyncMock
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_airos_client: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
await setup_integration(hass, mock_config_entry, [Platform.BINARY_SENSOR])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@@ -13,7 +13,7 @@ import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.components.airos.const import SCAN_INTERVAL from homeassistant.components.airos.const import SCAN_INTERVAL
from homeassistant.const import STATE_UNAVAILABLE from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
@@ -31,7 +31,7 @@ async def test_all_entities(
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
) -> None: ) -> None:
"""Test all entities.""" """Test all entities."""
await setup_integration(hass, mock_config_entry) await setup_integration(hass, mock_config_entry, [Platform.SENSOR])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@@ -53,7 +53,7 @@ async def test_sensor_update_exception_handling(
freezer: FrozenDateTimeFactory, freezer: FrozenDateTimeFactory,
) -> None: ) -> None:
"""Test entity update data handles exceptions.""" """Test entity update data handles exceptions."""
await setup_integration(hass, mock_config_entry) await setup_integration(hass, mock_config_entry, [Platform.SENSOR])
expected_entity_id = "sensor.nanostation_5ac_ap_name_antenna_gain" expected_entity_id = "sensor.nanostation_5ac_ap_name_antenna_gain"
signal_state = hass.states.get(expected_entity_id) signal_state = hass.states.get(expected_entity_id)
@@ -65,7 +65,7 @@ async def test_sensor_update_exception_handling(
mock_airos_client.login.side_effect = exception mock_airos_client.login.side_effect = exception
freezer.tick(timedelta(seconds=SCAN_INTERVAL.total_seconds() + 1)) freezer.tick(timedelta(seconds=SCAN_INTERVAL.total_seconds()))
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()