diff --git a/.coveragerc b/.coveragerc index 7e1a3287a19..528ec2e3dac 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1035,6 +1035,12 @@ omit = homeassistant/components/recorder/repack.py homeassistant/components/recswitch/switch.py homeassistant/components/reddit/sensor.py + homeassistant/components/refoss/__init__.py + homeassistant/components/refoss/bridge.py + homeassistant/components/refoss/coordinator.py + homeassistant/components/refoss/entity.py + homeassistant/components/refoss/switch.py + homeassistant/components/refoss/util.py homeassistant/components/rejseplanen/sensor.py homeassistant/components/remember_the_milk/__init__.py homeassistant/components/remote_rpi_gpio/* diff --git a/CODEOWNERS b/CODEOWNERS index 05edd2c2b84..7a8b3ea1885 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1056,6 +1056,8 @@ build.json @home-assistant/supervisor /tests/components/recorder/ @home-assistant/core /homeassistant/components/recovery_mode/ @home-assistant/core /tests/components/recovery_mode/ @home-assistant/core +/homeassistant/components/refoss/ @ashionky +/tests/components/refoss/ @ashionky /homeassistant/components/rejseplanen/ @DarkFox /homeassistant/components/remote/ @home-assistant/core /tests/components/remote/ @home-assistant/core diff --git a/homeassistant/components/refoss/__init__.py b/homeassistant/components/refoss/__init__.py new file mode 100644 index 00000000000..d83ca17dd6b --- /dev/null +++ b/homeassistant/components/refoss/__init__.py @@ -0,0 +1,56 @@ +"""Refoss devices platform loader.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Final + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_track_time_interval + +from .bridge import DiscoveryService +from .const import COORDINATORS, DATA_DISCOVERY_SERVICE, DISCOVERY_SCAN_INTERVAL, DOMAIN +from .util import refoss_discovery_server + +PLATFORMS: Final = [ + Platform.SWITCH, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Refoss from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + discover = await refoss_discovery_server(hass) + refoss_discovery = DiscoveryService(hass, discover) + hass.data[DOMAIN][DATA_DISCOVERY_SERVICE] = refoss_discovery + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + async def _async_scan_update(_=None): + await refoss_discovery.discovery.broadcast_msg() + + await _async_scan_update() + + entry.async_on_unload( + async_track_time_interval( + hass, _async_scan_update, timedelta(seconds=DISCOVERY_SCAN_INTERVAL) + ) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if hass.data[DOMAIN].get(DATA_DISCOVERY_SERVICE) is not None: + refoss_discovery: DiscoveryService = hass.data[DOMAIN][DATA_DISCOVERY_SERVICE] + refoss_discovery.discovery.clean_up() + hass.data[DOMAIN].pop(DATA_DISCOVERY_SERVICE) + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + hass.data[DOMAIN].pop(COORDINATORS) + + return unload_ok diff --git a/homeassistant/components/refoss/bridge.py b/homeassistant/components/refoss/bridge.py new file mode 100644 index 00000000000..888179e8a7c --- /dev/null +++ b/homeassistant/components/refoss/bridge.py @@ -0,0 +1,45 @@ +"""Refoss integration.""" +from __future__ import annotations + +from refoss_ha.device import DeviceInfo +from refoss_ha.device_manager import async_build_base_device +from refoss_ha.discovery import Discovery, Listener + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN +from .coordinator import RefossDataUpdateCoordinator + + +class DiscoveryService(Listener): + """Discovery event handler for refoss devices.""" + + def __init__(self, hass: HomeAssistant, discovery: Discovery) -> None: + """Init discovery service.""" + self.hass = hass + + self.discovery = discovery + self.discovery.add_listener(self) + + hass.data[DOMAIN].setdefault(COORDINATORS, []) + + async def device_found(self, device_info: DeviceInfo) -> None: + """Handle new device found on the network.""" + + device = await async_build_base_device(device_info) + if device is None: + return None + + coordo = RefossDataUpdateCoordinator(self.hass, device) + self.hass.data[DOMAIN][COORDINATORS].append(coordo) + await coordo.async_refresh() + + async_dispatcher_send(self.hass, DISPATCH_DEVICE_DISCOVERED, coordo) + + async def device_update(self, device_info: DeviceInfo) -> None: + """Handle updates in device information, update if ip has changed.""" + for coordinator in self.hass.data[DOMAIN][COORDINATORS]: + if coordinator.device.device_info.mac == device_info.mac: + coordinator.device.device_info.inner_ip = device_info.inner_ip + await coordinator.async_refresh() diff --git a/homeassistant/components/refoss/config_flow.py b/homeassistant/components/refoss/config_flow.py new file mode 100644 index 00000000000..fe33cefc1bd --- /dev/null +++ b/homeassistant/components/refoss/config_flow.py @@ -0,0 +1,20 @@ +"""Config Flow for Refoss integration.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_flow + +from .const import DISCOVERY_TIMEOUT, DOMAIN +from .util import refoss_discovery_server + + +async def _async_has_devices(hass: HomeAssistant) -> bool: + """Return if there are devices that can be discovered.""" + + refoss_discovery = await refoss_discovery_server(hass) + devices = await refoss_discovery.broadcast_msg(wait_for=DISCOVERY_TIMEOUT) + return len(devices) > 0 + + +config_entry_flow.register_discovery_flow(DOMAIN, "Refoss", _async_has_devices) diff --git a/homeassistant/components/refoss/const.py b/homeassistant/components/refoss/const.py new file mode 100644 index 00000000000..dd11921c75e --- /dev/null +++ b/homeassistant/components/refoss/const.py @@ -0,0 +1,20 @@ +"""const.""" +from __future__ import annotations + +from logging import Logger, getLogger + +_LOGGER: Logger = getLogger(__package__) + +COORDINATORS = "coordinators" + +DATA_DISCOVERY_SERVICE = "refoss_discovery" + +DISCOVERY_SCAN_INTERVAL = 30 +DISCOVERY_TIMEOUT = 8 +DISPATCH_DEVICE_DISCOVERED = "refoss_device_discovered" +DISPATCHERS = "dispatchers" + +DOMAIN = "refoss" +COORDINATOR = "coordinator" + +MAX_ERRORS = 2 diff --git a/homeassistant/components/refoss/coordinator.py b/homeassistant/components/refoss/coordinator.py new file mode 100644 index 00000000000..a542f0e1ae8 --- /dev/null +++ b/homeassistant/components/refoss/coordinator.py @@ -0,0 +1,39 @@ +"""Helper and coordinator for refoss.""" +from __future__ import annotations + +from datetime import timedelta + +from refoss_ha.controller.device import BaseDevice +from refoss_ha.exceptions import DeviceTimeoutError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import _LOGGER, DOMAIN, MAX_ERRORS + + +class RefossDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Manages polling for state changes from the device.""" + + def __init__(self, hass: HomeAssistant, device: BaseDevice) -> None: + """Initialize the data update coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}-{device.device_info.dev_name}", + update_interval=timedelta(seconds=15), + ) + self.device = device + self._error_count = 0 + + async def _async_update_data(self) -> None: + """Update the state of the device.""" + try: + await self.device.async_handle_update() + self.last_update_success = True + self._error_count = 0 + except DeviceTimeoutError: + self._error_count += 1 + + if self._error_count >= MAX_ERRORS: + self.last_update_success = False diff --git a/homeassistant/components/refoss/entity.py b/homeassistant/components/refoss/entity.py new file mode 100644 index 00000000000..d3425974bb1 --- /dev/null +++ b/homeassistant/components/refoss/entity.py @@ -0,0 +1,31 @@ +"""Entity object for shared properties of Refoss entities.""" +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .bridge import RefossDataUpdateCoordinator +from .const import DOMAIN + + +class RefossEntity(CoordinatorEntity[RefossDataUpdateCoordinator]): + """Refoss entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: RefossDataUpdateCoordinator, channel: int) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + mac = coordinator.device.mac + self.channel_id = channel + if channel == 0: + self._attr_name = None + else: + self._attr_name = str(channel) + + self._attr_unique_id = f"{mac}_{channel}" + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, mac)}, + identifiers={(DOMAIN, mac)}, + manufacturer="Refoss", + name=coordinator.device.dev_name, + ) diff --git a/homeassistant/components/refoss/manifest.json b/homeassistant/components/refoss/manifest.json new file mode 100644 index 00000000000..8e5b3864bcc --- /dev/null +++ b/homeassistant/components/refoss/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "refoss", + "name": "Refoss", + "codeowners": ["@ashionky"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/refoss", + "iot_class": "local_polling", + "requirements": ["refoss-ha==1.2.0"] +} diff --git a/homeassistant/components/refoss/strings.json b/homeassistant/components/refoss/strings.json new file mode 100644 index 00000000000..ad8f0f41ae7 --- /dev/null +++ b/homeassistant/components/refoss/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + } +} diff --git a/homeassistant/components/refoss/switch.py b/homeassistant/components/refoss/switch.py new file mode 100644 index 00000000000..c51f166059e --- /dev/null +++ b/homeassistant/components/refoss/switch.py @@ -0,0 +1,69 @@ +"""Switch for Refoss.""" + +from __future__ import annotations + +from typing import Any + +from refoss_ha.controller.toggle import ToggleXMix + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN +from .entity import RefossEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Refoss device from a config entry.""" + + @callback + def init_device(coordinator): + """Register the device.""" + device = coordinator.device + if not isinstance(device, ToggleXMix): + return + + new_entities = [] + for channel in device.channels: + entity = RefossSwitch(coordinator=coordinator, channel=channel) + new_entities.append(entity) + + async_add_entities(new_entities) + + for coordinator in hass.data[DOMAIN][COORDINATORS]: + init_device(coordinator) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, DISPATCH_DEVICE_DISCOVERED, init_device) + ) + + +class RefossSwitch(RefossEntity, SwitchEntity): + """Refoss Switch Device.""" + + @property + def is_on(self) -> bool | None: + """Return true if switch is on.""" + return self.coordinator.device.is_on(channel=self.channel_id) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.coordinator.device.async_turn_on(self.channel_id) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.coordinator.device.async_turn_off(self.channel_id) + self.async_write_ha_state() + + async def async_toggle(self, **kwargs: Any) -> None: + """Toggle the switch.""" + await self.coordinator.device.async_toggle(channel=self.channel_id) + self.async_write_ha_state() diff --git a/homeassistant/components/refoss/util.py b/homeassistant/components/refoss/util.py new file mode 100644 index 00000000000..cd589022d73 --- /dev/null +++ b/homeassistant/components/refoss/util.py @@ -0,0 +1,15 @@ +"""Refoss helpers functions.""" +from __future__ import annotations + +from refoss_ha.discovery import Discovery + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import singleton + + +@singleton.singleton("refoss_discovery_server") +async def refoss_discovery_server(hass: HomeAssistant) -> Discovery: + """Get refoss Discovery server.""" + discovery_server = Discovery() + await discovery_server.initialize() + return discovery_server diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 47f7087fcc8..1deeae819a0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -398,6 +398,7 @@ FLOWS = { "rapt_ble", "rdw", "recollect_waste", + "refoss", "renault", "renson", "reolink", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index db385c5eff9..9479153dd0d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4746,6 +4746,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "refoss": { + "name": "Refoss", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "rejseplanen": { "name": "Rejseplanen", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index bcf8a8bd6a0..b903bc7daca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2355,6 +2355,9 @@ rapt-ble==0.1.2 # homeassistant.components.raspyrfm raspyrfm-client==1.2.8 +# homeassistant.components.refoss +refoss-ha==1.2.0 + # homeassistant.components.rainmachine regenmaschine==2023.06.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 739cc1db31b..88b9fa3e8d0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1773,6 +1773,9 @@ radiotherm==2.1.0 # homeassistant.components.rapt_ble rapt-ble==0.1.2 +# homeassistant.components.refoss +refoss-ha==1.2.0 + # homeassistant.components.rainmachine regenmaschine==2023.06.0 diff --git a/tests/components/refoss/__init__.py b/tests/components/refoss/__init__.py new file mode 100644 index 00000000000..34df1b41714 --- /dev/null +++ b/tests/components/refoss/__init__.py @@ -0,0 +1,107 @@ +"""Common helpers for refoss test cases.""" +import asyncio +import logging +from unittest.mock import AsyncMock, Mock + +from refoss_ha.discovery import Listener + +from homeassistant.components.refoss.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +class FakeDiscovery: + """Mock class replacing refoss device discovery.""" + + def __init__(self) -> None: + """Initialize the class.""" + self.mock_devices = {"abc": build_device_mock()} + self.last_mock_infos = {} + self._listeners = [] + + def add_listener(self, listener: Listener) -> None: + """Add an event listener.""" + self._listeners.append(listener) + + async def initialize(self) -> None: + """Initialize socket server.""" + self.sock = Mock() + + async def broadcast_msg(self, wait_for: int = 0): + """Search for devices, return mocked data.""" + + mock_infos = self.mock_devices + last_mock_infos = self.last_mock_infos + + new_infos = [] + updated_infos = [] + + for info in mock_infos.values(): + uuid = info.uuid + if uuid not in last_mock_infos: + new_infos.append(info) + else: + last_info = self.last_mock_infos[uuid] + if info.inner_ip != last_info.inner_ip: + updated_infos.append(info) + + self.last_mock_infos = mock_infos + for listener in self._listeners: + [await listener.device_found(x) for x in new_infos] + [await listener.device_update(x) for x in updated_infos] + + if wait_for: + await asyncio.sleep(wait_for) + + return new_infos + + +def build_device_mock(name="r10", ip="1.1.1.1", mac="aabbcc112233"): + """Build mock device object.""" + mock = Mock( + uuid="abc", + dev_name=name, + device_type="r10", + fmware_version="1.1.1", + hdware_version="1.1.2", + inner_ip=ip, + port="80", + mac=mac, + sub_type="eu", + channels=[0], + ) + return mock + + +def build_base_device_mock(name="r10", ip="1.1.1.1", mac="aabbcc112233"): + """Build mock base device object.""" + mock = Mock( + device_info=build_device_mock(name=name, ip=ip, mac=mac), + uuid="abc", + dev_name=name, + device_type="r10", + fmware_version="1.1.1", + hdware_version="1.1.2", + inner_ip=ip, + port="80", + mac=mac, + sub_type="eu", + channels=[0], + async_handle_update=AsyncMock(), + async_turn_on=AsyncMock(), + async_turn_off=AsyncMock(), + async_toggle=AsyncMock(), + ) + mock.status = {0: True} + return mock + + +async def async_setup_refoss(hass: HomeAssistant) -> MockConfigEntry: + """Set up the refoss platform.""" + entry = MockConfigEntry(domain=DOMAIN) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry diff --git a/tests/components/refoss/conftest.py b/tests/components/refoss/conftest.py new file mode 100644 index 00000000000..2fc695bbb2e --- /dev/null +++ b/tests/components/refoss/conftest.py @@ -0,0 +1,14 @@ +"""Pytest module configuration.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.refoss.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/refoss/test_config_flow.py b/tests/components/refoss/test_config_flow.py new file mode 100644 index 00000000000..2a5842ffe46 --- /dev/null +++ b/tests/components/refoss/test_config_flow.py @@ -0,0 +1,65 @@ +"""Tests for the refoss Integration.""" +from unittest.mock import AsyncMock, patch + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.refoss.const import DOMAIN +from homeassistant.core import HomeAssistant + +from . import FakeDiscovery, build_base_device_mock + + +@patch("homeassistant.components.refoss.config_flow.DISCOVERY_TIMEOUT", 0) +async def test_creating_entry_sets_up( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test setting up refoss.""" + with patch( + "homeassistant.components.refoss.util.Discovery", + return_value=FakeDiscovery(), + ), patch( + "homeassistant.components.refoss.bridge.async_build_base_device", + return_value=build_base_device_mock(), + ), patch( + "homeassistant.components.refoss.switch.isinstance", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Confirmation form + assert result["type"] == data_entry_flow.FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + + +@patch("homeassistant.components.refoss.config_flow.DISCOVERY_TIMEOUT", 0) +async def test_creating_entry_has_no_devices( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test setting up Refoss no devices.""" + with patch( + "homeassistant.components.refoss.util.Discovery", + return_value=FakeDiscovery(), + ) as discovery: + discovery.return_value.mock_devices = {} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Confirmation form + assert result["type"] == data_entry_flow.FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 0