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
_PLATFORMS: list[Platform] = [Platform.SENSOR]
_PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.SENSOR,
]
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
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: todo
comment: prepared binary_sensors will provide this
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations:

View File

@@ -26,6 +26,23 @@
}
},
"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": {
"host_cpuload": {
"name": "CPU load"

View File

@@ -1,13 +1,19 @@
"""Tests for the Ubiquity airOS integration."""
from homeassistant.const import Platform
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."""
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.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 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.helpers import entity_registry as er
@@ -31,7 +31,7 @@ async def test_all_entities(
entity_registry: er.EntityRegistry,
) -> None:
"""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)
@@ -53,7 +53,7 @@ async def test_sensor_update_exception_handling(
freezer: FrozenDateTimeFactory,
) -> None:
"""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"
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
freezer.tick(timedelta(seconds=SCAN_INTERVAL.total_seconds() + 1))
freezer.tick(timedelta(seconds=SCAN_INTERVAL.total_seconds()))
async_fire_time_changed(hass)
await hass.async_block_till_done()