Discover new devices at runtime in onewire (#135199)

pull/135197/head
epenet 2025-01-10 11:53:31 +01:00 committed by GitHub
parent 24c70caf33
commit 475a2fb828
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 299 additions and 45 deletions

View File

@ -37,6 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> b
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
onewire_hub.schedule_scan_for_new_devices()
entry.async_on_unload(entry.add_update_listener(options_update_listener))
return True

View File

@ -13,11 +13,17 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_BOOL
from .entity import OneWireEntity, OneWireEntityDescription
from .onewirehub import OneWireConfigEntry, OneWireHub
from .onewirehub import (
SIGNAL_NEW_DEVICE_CONNECTED,
OneWireConfigEntry,
OneWireHub,
OWDeviceDescription,
)
# the library uses non-persistent connections
# and concurrent access to the bus is managed by the server
@ -98,16 +104,28 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up 1-Wire platform."""
async_add_entities(get_entities(config_entry.runtime_data), True)
async def _add_entities(
hub: OneWireHub, devices: list[OWDeviceDescription]
) -> None:
"""Add 1-Wire entities for all devices."""
if not devices:
return
async_add_entities(get_entities(hub, devices), True)
hub = config_entry.runtime_data
await _add_entities(hub, hub.devices)
config_entry.async_on_unload(
async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities)
)
def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]:
def get_entities(
onewire_hub: OneWireHub, devices: list[OWDeviceDescription]
) -> list[OneWireBinarySensor]:
"""Get a list of entities."""
if not onewire_hub.devices:
return []
entities: list[OneWireBinarySensor] = []
for device in onewire_hub.devices:
for device in devices:
family = device.family
device_id = device.id
device_type = device.type

View File

@ -2,6 +2,7 @@
from __future__ import annotations
from datetime import datetime, timedelta
import logging
import os
@ -9,9 +10,12 @@ from pyownet import protocol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_VIA_DEVICE, CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util.signal_type import SignalType
from .const import (
DEVICE_SUPPORT,
@ -32,10 +36,15 @@ DEVICE_MANUFACTURER = {
"EF": MANUFACTURER_HOBBYBOARDS,
}
_DEVICE_SCAN_INTERVAL = timedelta(minutes=5)
_LOGGER = logging.getLogger(__name__)
type OneWireConfigEntry = ConfigEntry[OneWireHub]
SIGNAL_NEW_DEVICE_CONNECTED = SignalType["OneWireHub", list[OWDeviceDescription]](
f"{DOMAIN}_new_device_connected"
)
def _is_known_device(device_family: str, device_type: str | None) -> bool:
"""Check if device family/type is known to the library."""
@ -69,14 +78,42 @@ class OneWireHub:
async def initialize(self) -> None:
"""Initialize a config entry."""
await self._hass.async_add_executor_job(self._initialize)
# Populate the device registry
self._populate_device_registry(self.devices)
@callback
def _populate_device_registry(self, devices: list[OWDeviceDescription]) -> None:
"""Populate the device registry."""
device_registry = dr.async_get(self._hass)
for device in self.devices:
for device in devices:
device_registry.async_get_or_create(
config_entry_id=self._config_entry.entry_id,
**device.device_info,
)
def schedule_scan_for_new_devices(self) -> None:
"""Schedule a regular scan of the bus for new devices."""
self._config_entry.async_on_unload(
async_track_time_interval(
self._hass, self._scan_for_new_devices, _DEVICE_SCAN_INTERVAL
)
)
async def _scan_for_new_devices(self, _: datetime) -> None:
"""Scan the bus for new devices."""
devices = await self._hass.async_add_executor_job(
_discover_devices, self.owproxy
)
existing_device_ids = [device.id for device in self.devices]
new_devices = [
device for device in devices if device.id not in existing_device_ids
]
if new_devices:
self.devices.extend(new_devices)
self._populate_device_registry(new_devices)
async_dispatcher_send(
self._hass, SIGNAL_NEW_DEVICE_CONNECTED, self, new_devices
)
def _discover_devices(
owproxy: protocol._Proxy, path: str = "/", parent_id: str | None = None

View File

@ -84,8 +84,8 @@ rules:
comment: It doesn't make sense to override defaults
reconfiguration-flow: done
dynamic-devices:
status: todo
comment: Not yet implemented
status: done
comment: The bus is scanned for new devices at regular interval
discovery-update-info:
status: todo
comment: Under review

View File

@ -9,11 +9,17 @@ import os
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import READ_MODE_INT
from .entity import OneWireEntity, OneWireEntityDescription
from .onewirehub import OneWireConfigEntry, OneWireHub
from .onewirehub import (
SIGNAL_NEW_DEVICE_CONNECTED,
OneWireConfigEntry,
OneWireHub,
OWDeviceDescription,
)
# the library uses non-persistent connections
# and concurrent access to the bus is managed by the server
@ -45,17 +51,29 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up 1-Wire platform."""
async_add_entities(get_entities(config_entry.runtime_data), True)
async def _add_entities(
hub: OneWireHub, devices: list[OWDeviceDescription]
) -> None:
"""Add 1-Wire entities for all devices."""
if not devices:
return
async_add_entities(get_entities(hub, devices), True)
hub = config_entry.runtime_data
await _add_entities(hub, hub.devices)
config_entry.async_on_unload(
async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities)
)
def get_entities(onewire_hub: OneWireHub) -> list[OneWireSelectEntity]:
def get_entities(
onewire_hub: OneWireHub, devices: list[OWDeviceDescription]
) -> list[OneWireSelectEntity]:
"""Get a list of entities."""
if not onewire_hub.devices:
return []
entities: list[OneWireSelectEntity] = []
for device in onewire_hub.devices:
for device in devices:
family = device.family
device_id = device.id
device_info = device.device_info

View File

@ -26,6 +26,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
@ -39,7 +40,12 @@ from .const import (
READ_MODE_INT,
)
from .entity import OneWireEntity, OneWireEntityDescription
from .onewirehub import OneWireConfigEntry, OneWireHub
from .onewirehub import (
SIGNAL_NEW_DEVICE_CONNECTED,
OneWireConfigEntry,
OneWireHub,
OWDeviceDescription,
)
# the library uses non-persistent connections
# and concurrent access to the bus is managed by the server
@ -357,23 +363,35 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up 1-Wire platform."""
# note: we have to go through the executor as SENSOR platform
# makes extra calls to the hub during device listing
entities = await hass.async_add_executor_job(
get_entities, config_entry.runtime_data, config_entry.options
async def _add_entities(
hub: OneWireHub, devices: list[OWDeviceDescription]
) -> None:
"""Add 1-Wire entities for all devices."""
if not devices:
return
# note: we have to go through the executor as SENSOR platform
# makes extra calls to the hub during device listing
entities = await hass.async_add_executor_job(
get_entities, hub, devices, config_entry.options
)
async_add_entities(entities, True)
hub = config_entry.runtime_data
await _add_entities(hub, hub.devices)
config_entry.async_on_unload(
async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities)
)
async_add_entities(entities, True)
def get_entities(
onewire_hub: OneWireHub, options: MappingProxyType[str, Any]
onewire_hub: OneWireHub,
devices: list[OWDeviceDescription],
options: MappingProxyType[str, Any],
) -> list[OneWireSensor]:
"""Get a list of entities."""
if not onewire_hub.devices:
return []
entities: list[OneWireSensor] = []
for device in onewire_hub.devices:
for device in devices:
family = device.family
device_type = device.type
device_id = device.id

View File

@ -10,11 +10,17 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_BOOL
from .entity import OneWireEntity, OneWireEntityDescription
from .onewirehub import OneWireConfigEntry, OneWireHub
from .onewirehub import (
SIGNAL_NEW_DEVICE_CONNECTED,
OneWireConfigEntry,
OneWireHub,
OWDeviceDescription,
)
# the library uses non-persistent connections
# and concurrent access to the bus is managed by the server
@ -158,17 +164,29 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up 1-Wire platform."""
async_add_entities(get_entities(config_entry.runtime_data), True)
async def _add_entities(
hub: OneWireHub, devices: list[OWDeviceDescription]
) -> None:
"""Add 1-Wire entities for all devices."""
if not devices:
return
async_add_entities(get_entities(hub, devices), True)
hub = config_entry.runtime_data
await _add_entities(hub, hub.devices)
config_entry.async_on_unload(
async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities)
)
def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]:
def get_entities(
onewire_hub: OneWireHub, devices: list[OWDeviceDescription]
) -> list[OneWireSwitch]:
"""Get a list of entities."""
if not onewire_hub.devices:
return []
entities: list[OneWireSwitch] = []
for device in onewire_hub.devices:
for device in devices:
family = device.family
device_type = device.type
device_id = device.id

View File

@ -3,9 +3,11 @@
from collections.abc import Generator
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@ -13,7 +15,7 @@ from homeassistant.helpers import entity_registry as er
from . import setup_owproxy_mock_devices
from .const import MOCK_OWPROXY_DEVICES
from tests.common import MockConfigEntry, snapshot_platform
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.fixture(autouse=True)
@ -31,8 +33,35 @@ async def test_binary_sensors(
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test for 1-Wire binary sensors."""
"""Test for 1-Wire binary sensor entities."""
setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys())
await hass.config_entries.async_setup(config_entry.entry_id)
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
@pytest.mark.parametrize("device_id", ["29.111111111111"])
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_binary_sensors_delayed(
hass: HomeAssistant,
config_entry: MockConfigEntry,
owproxy: MagicMock,
device_id: str,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test for delayed 1-Wire binary sensor entities."""
setup_owproxy_mock_devices(owproxy, [])
await hass.config_entries.async_setup(config_entry.entry_id)
assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)
setup_owproxy_mock_devices(owproxy, [device_id])
freezer.tick(_DEVICE_SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert (
len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id))
== 8
)

View File

@ -3,11 +3,13 @@
from copy import deepcopy
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from pyownet import protocol
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.onewire.const import DOMAIN
from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
@ -17,7 +19,7 @@ from homeassistant.setup import async_setup_component
from . import setup_owproxy_mock_devices
from .const import MOCK_OWPROXY_DEVICES
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.typing import WebSocketGenerator
@ -102,6 +104,31 @@ async def test_registry(
assert device_entry == snapshot(name=f"{device_entry.name}-entry")
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_registry_delayed(
hass: HomeAssistant,
config_entry: MockConfigEntry,
owproxy: MagicMock,
device_registry: dr.DeviceRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test device are correctly registered."""
setup_owproxy_mock_devices(owproxy, [])
await hass.config_entries.async_setup(config_entry.entry_id)
assert not dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)
setup_owproxy_mock_devices(owproxy, ["1F.111111111111"])
freezer.tick(_DEVICE_SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert (
len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id))
== 2
)
@patch("homeassistant.components.onewire._PLATFORMS", [Platform.SENSOR])
async def test_registry_cleanup(
hass: HomeAssistant,

View File

@ -3,9 +3,11 @@
from collections.abc import Generator
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL
from homeassistant.components.select import (
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
@ -17,7 +19,7 @@ from homeassistant.helpers import entity_registry as er
from . import setup_owproxy_mock_devices
from .const import MOCK_OWPROXY_DEVICES
from tests.common import MockConfigEntry, snapshot_platform
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.fixture(autouse=True)
@ -42,6 +44,33 @@ async def test_selects(
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
@pytest.mark.parametrize("device_id", ["28.111111111111"])
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_selects_delayed(
hass: HomeAssistant,
config_entry: MockConfigEntry,
owproxy: MagicMock,
device_id: str,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test for delayed 1-Wire select entities."""
setup_owproxy_mock_devices(owproxy, [])
await hass.config_entries.async_setup(config_entry.entry_id)
assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)
setup_owproxy_mock_devices(owproxy, [device_id])
freezer.tick(_DEVICE_SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert (
len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id))
== 1
)
@pytest.mark.parametrize("device_id", ["28.111111111111"])
async def test_selection_option_service(
hass: HomeAssistant,

View File

@ -5,10 +5,12 @@ from copy import deepcopy
import logging
from unittest.mock import MagicMock, _patch_dict, patch
from freezegun.api import FrozenDateTimeFactory
from pyownet.protocol import OwnetError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@ -16,7 +18,7 @@ from homeassistant.helpers import entity_registry as er
from . import setup_owproxy_mock_devices
from .const import ATTR_INJECT_READS, MOCK_OWPROXY_DEVICES
from tests.common import MockConfigEntry, snapshot_platform
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.fixture(autouse=True)
@ -34,13 +36,40 @@ async def test_sensors(
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test for 1-Wire sensors."""
"""Test for 1-Wire sensor entities."""
setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys())
await hass.config_entries.async_setup(config_entry.entry_id)
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
@pytest.mark.parametrize("device_id", ["12.111111111111"])
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensors_delayed(
hass: HomeAssistant,
config_entry: MockConfigEntry,
owproxy: MagicMock,
device_id: str,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test for delayed 1-Wire sensor entities."""
setup_owproxy_mock_devices(owproxy, [])
await hass.config_entries.async_setup(config_entry.entry_id)
assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)
setup_owproxy_mock_devices(owproxy, [device_id])
freezer.tick(_DEVICE_SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert (
len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id))
== 2
)
@pytest.mark.parametrize("device_id", ["12.111111111111"])
async def test_tai8570_sensors(
hass: HomeAssistant,

View File

@ -3,9 +3,11 @@
from collections.abc import Generator
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
@ -20,7 +22,7 @@ from homeassistant.helpers import entity_registry as er
from . import setup_owproxy_mock_devices
from .const import MOCK_OWPROXY_DEVICES
from tests.common import MockConfigEntry, snapshot_platform
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.fixture(autouse=True)
@ -38,13 +40,40 @@ async def test_switches(
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test for 1-Wire switches."""
"""Test for 1-Wire switch entities."""
setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys())
await hass.config_entries.async_setup(config_entry.entry_id)
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
@pytest.mark.parametrize("device_id", ["05.111111111111"])
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_switches_delayed(
hass: HomeAssistant,
config_entry: MockConfigEntry,
owproxy: MagicMock,
device_id: str,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test for delayed 1-Wire switch entities."""
setup_owproxy_mock_devices(owproxy, [])
await hass.config_entries.async_setup(config_entry.entry_id)
assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)
setup_owproxy_mock_devices(owproxy, [device_id])
freezer.tick(_DEVICE_SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert (
len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id))
== 1
)
@pytest.mark.parametrize("device_id", ["05.111111111111"])
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_switch_toggle(