Add Refoss integration (#100573)

* refoss

* refoss

* refoss

* refoss

* refoss modify

* ip

* 8.22

* format

* format

* format

* bugfix

* test

* test

* test

* test

* test

* test

* 9.1

* refosss

* refoss

* refoss

* refoss

* refoss

* refoss

* refoss

* refoss

* test

* requirements_test_all.txt

* codeowners

* refoss

* Review feedback repair

* strings

* refoss

* refoss

* refoss

* 1.1.1

* 1.1.2

* refoss

* refoss

* refoss.1.1.7

* refoss-gree

* 1.1.7

* test

* refoss

* test refoss

* test refoss

* refoss-test

* refoss

* refoss

* test

* test

* refoss

* CODEOWNERS

* fix

* Update homeassistant/components/refoss/__init__.py

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
ashionky 2023-12-22 20:18:32 +08:00 committed by GitHub
parent f536bc1d0c
commit 102c7f1959
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 524 additions and 0 deletions

View File

@ -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/*

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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,
)

View File

@ -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"]
}

View File

@ -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%]"
}
}
}

View File

@ -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()

View File

@ -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

View File

@ -398,6 +398,7 @@ FLOWS = {
"rapt_ble",
"rdw",
"recollect_waste",
"refoss",
"renault",
"renson",
"reolink",

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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