Initialize docker stack repo
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
"""The custom component for local network access to Midea appliances"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
import ipaddress
|
||||
from itertools import chain, cycle
|
||||
import logging
|
||||
from typing import Any, Iterator, cast
|
||||
|
||||
from homeassistant.core import CALLBACK_TYPE
|
||||
from homeassistant.components.network import async_get_ipv4_broadcast_addresses
|
||||
from homeassistant.const import (
|
||||
CONF_API_VERSION,
|
||||
CONF_BROADCAST_ADDRESS,
|
||||
CONF_DEVICES,
|
||||
CONF_DISCOVERY,
|
||||
CONF_ID,
|
||||
CONF_IP_ADDRESS,
|
||||
CONF_NAME,
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_TOKEN,
|
||||
CONF_TYPE,
|
||||
CONF_UNIQUE_ID,
|
||||
)
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from midea_beautiful.lan import LanDevice
|
||||
|
||||
from custom_components.midea_dehumidifier_lan.appliance_coordinator import (
|
||||
ApplianceUpdateCoordinator,
|
||||
)
|
||||
from custom_components.midea_dehumidifier_lan.const import (
|
||||
CONF_TOKEN_KEY,
|
||||
DEFAULT_DISCOVERY_MODE,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DISCOVERY_BATCH_SIZE,
|
||||
DISCOVERY_IGNORE,
|
||||
DISCOVERY_LAN,
|
||||
DISCOVERY_MODE_EXPLANATION,
|
||||
DISCOVERY_WAIT,
|
||||
LOCAL_BROADCAST,
|
||||
NAME,
|
||||
UNKNOWN_IP,
|
||||
)
|
||||
from custom_components.midea_dehumidifier_lan.util import (
|
||||
AbstractHub,
|
||||
RedactedConf,
|
||||
address_ok,
|
||||
supported_appliance,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def empty_address_iterator():
|
||||
"""No addresses to iterate"""
|
||||
yield from ()
|
||||
|
||||
|
||||
def _add_if_discoverable(conf_addresses: list[str], device: dict[str, Any]):
|
||||
if device.get(CONF_DISCOVERY) != DISCOVERY_LAN:
|
||||
if address_ok(device[CONF_IP_ADDRESS]):
|
||||
conf_addresses.append(device[CONF_IP_ADDRESS])
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ChangedDevice:
|
||||
device: LanDevice
|
||||
coordinator: ApplianceUpdateCoordinator
|
||||
|
||||
|
||||
class ApplianceDiscoveryHelper: # pylint: disable=too-many-instance-attributes
|
||||
"""Utility class to discover Midea appliances on local network"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hub: AbstractHub,
|
||||
) -> None:
|
||||
self.hass = hub.hass
|
||||
self.hub = hub
|
||||
self.new_devices: list[LanDevice] = []
|
||||
self.changed_devices: list[_ChangedDevice] = []
|
||||
self.broadcast_addresses: list[str] = []
|
||||
self.address_iterator: Iterator[list[str]] = empty_address_iterator()
|
||||
self.notifed_addresses: set[str] = set()
|
||||
self.remove_discovery: CALLBACK_TYPE | None = None
|
||||
self.conf_addresses: list[str] = []
|
||||
|
||||
def _admit_new(self) -> bool:
|
||||
"""Admits new devices into configurations"""
|
||||
need_reload = False
|
||||
added_devices: list[dict[str, Any]] = []
|
||||
dev_confs = self.hub.config[CONF_DEVICES]
|
||||
for new in self.new_devices:
|
||||
for known in dev_confs:
|
||||
if self._admitted_known_device(known, new):
|
||||
need_reload = True
|
||||
break
|
||||
else:
|
||||
added_devices.append(self._admit_not_known_device(new))
|
||||
need_reload = True
|
||||
|
||||
if added_devices:
|
||||
dev_confs += added_devices
|
||||
return need_reload
|
||||
|
||||
def _admit_not_known_device(self, new: LanDevice) -> dict[str, Any]:
|
||||
name = f"{new.model} {new.mac[-4] if new.mac else new.serial_number}"
|
||||
new_device = {
|
||||
CONF_DISCOVERY: DISCOVERY_IGNORE,
|
||||
CONF_API_VERSION: new.version,
|
||||
CONF_ID: new.appliance_id,
|
||||
CONF_IP_ADDRESS: new.address,
|
||||
CONF_NAME: name,
|
||||
CONF_TOKEN_KEY: new.key,
|
||||
CONF_TOKEN: new.token,
|
||||
CONF_TYPE: new.type,
|
||||
CONF_UNIQUE_ID: new.serial_number,
|
||||
}
|
||||
|
||||
_LOGGER.debug("Found unknown device %s at %s.", name, new.address)
|
||||
msg = (
|
||||
f"Found previously unknown device {name} found on {new.address}."
|
||||
f" [Check it out.](/config/integrations)"
|
||||
)
|
||||
self.hass.components.persistent_notification.async_create(
|
||||
title=NAME,
|
||||
message=msg,
|
||||
notification_id=f"midea_unknown_{new.serial_number}",
|
||||
)
|
||||
return new_device
|
||||
|
||||
def _admitted_known_device(self, known: dict[str, Any], new: LanDevice) -> bool:
|
||||
need_reload = False
|
||||
if known[CONF_UNIQUE_ID] == new.serial_number:
|
||||
if known[CONF_DISCOVERY] == DISCOVERY_WAIT:
|
||||
update = {
|
||||
CONF_DISCOVERY: DISCOVERY_LAN,
|
||||
CONF_API_VERSION: new.version,
|
||||
CONF_ID: new.appliance_id,
|
||||
CONF_IP_ADDRESS: new.address,
|
||||
CONF_TOKEN_KEY: new.key,
|
||||
CONF_TOKEN: new.token,
|
||||
CONF_TYPE: new.type,
|
||||
CONF_UNIQUE_ID: new.serial_number,
|
||||
}
|
||||
_LOGGER.debug(
|
||||
"Updating discovered device %s, previous conf %s, conf update %s",
|
||||
new,
|
||||
known,
|
||||
update,
|
||||
)
|
||||
|
||||
msg = (
|
||||
"Device %(name)s,"
|
||||
" which was waiting to be discovered,"
|
||||
" was found on address %(address)s."
|
||||
" It will now be activated."
|
||||
) % {
|
||||
"name": known[CONF_NAME],
|
||||
"address": new.address,
|
||||
}
|
||||
self.hass.components.persistent_notification.async_create(
|
||||
title=NAME,
|
||||
message=msg,
|
||||
notification_id=f"midea_wait_discovery_{new.serial_number}",
|
||||
)
|
||||
known |= update
|
||||
need_reload = True
|
||||
elif new.address and known[CONF_DISCOVERY] != DISCOVERY_LAN:
|
||||
self._possible_lan_notification(new, known, new.address)
|
||||
|
||||
return need_reload
|
||||
|
||||
def _possible_lan_notification(
|
||||
self, device: LanDevice, known: dict[str, Any], address: str
|
||||
):
|
||||
if address not in self.notifed_addresses:
|
||||
_LOGGER.warning(
|
||||
"Device %s in mode %s found on address %s. "
|
||||
" It can be configured for local network access.",
|
||||
known[CONF_NAME],
|
||||
known[CONF_DISCOVERY],
|
||||
address,
|
||||
)
|
||||
self.notifed_addresses.add(address)
|
||||
|
||||
discovery_label = DISCOVERY_MODE_EXPLANATION.get(
|
||||
known[CONF_DISCOVERY], known[CONF_DISCOVERY]
|
||||
)
|
||||
msg = (
|
||||
"Device %(name)s,"
|
||||
" which is %(discovery_label)s,"
|
||||
" was found on address %(address)s."
|
||||
" It can be configured for local network access."
|
||||
" [Check it out.](/config/integrations)"
|
||||
) % {
|
||||
"name": known[CONF_NAME],
|
||||
"discovery_label": discovery_label,
|
||||
"address": address,
|
||||
}
|
||||
|
||||
self.hass.components.persistent_notification.async_create(
|
||||
title=NAME,
|
||||
message=msg,
|
||||
notification_id=f"midea_non_lan_discovery_{device.serial_number}",
|
||||
)
|
||||
|
||||
def _address_generator(self, batch_size: int = DISCOVERY_BATCH_SIZE):
|
||||
"""Generator for one batch of ip addresses to scan"""
|
||||
net_addrs = []
|
||||
addr_count = 0
|
||||
for addr in self.conf_addresses:
|
||||
# If local broadcast address we don't need to expand it
|
||||
if addr == LOCAL_BROADCAST:
|
||||
continue
|
||||
# Get network corresponding to address
|
||||
net = ipaddress.IPv4Network(addr)
|
||||
# If network references a block:
|
||||
if net.num_addresses > 1:
|
||||
_LOGGER.debug("Block %s with %d addresses", net, net.num_addresses)
|
||||
# collect all hosts from the block
|
||||
net_addrs.append(net.hosts())
|
||||
addr_count += net.num_addresses
|
||||
|
||||
# If we do have addresses to scan
|
||||
if net_addrs:
|
||||
# we will iterate over all of available addresses in batches
|
||||
# having batch_size items
|
||||
all_addrs = chain(*net_addrs)
|
||||
for _ in range(0, addr_count, batch_size):
|
||||
yield list(
|
||||
# We use filter to remove empty addresses
|
||||
filter(
|
||||
None,
|
||||
map(
|
||||
(lambda _: (x := next(all_addrs)) and str(x)),
|
||||
range(batch_size),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
async def _async_run_discovery(self, devices: list[LanDevice]) -> None:
|
||||
"""Trigger config flows for discovered devices."""
|
||||
|
||||
dev_confs: list[dict[str, Any]] = self.hub.config[CONF_DEVICES]
|
||||
for dev_conf in dev_confs:
|
||||
dev_conf.setdefault(CONF_DISCOVERY, DEFAULT_DISCOVERY_MODE)
|
||||
dev_conf.setdefault(CONF_IP_ADDRESS, UNKNOWN_IP)
|
||||
|
||||
self._iterate_devices(devices)
|
||||
|
||||
need_reload = self._admit_new()
|
||||
devices_changed = self._merge_with_configuration()
|
||||
|
||||
if devices_changed or need_reload:
|
||||
_LOGGER.debug("Config entry needs to be updated")
|
||||
self.hass.config_entries.async_update_entry(
|
||||
entry=self.hub.config_entry,
|
||||
data=self.hub.config,
|
||||
)
|
||||
if need_reload:
|
||||
_LOGGER.debug("Config entry needs to be reloaded")
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.async_reload(self.hub.config_entry.entry_id)
|
||||
)
|
||||
|
||||
def _iterate_devices(self, devices: list[LanDevice]):
|
||||
self.new_devices.clear()
|
||||
self.changed_devices.clear()
|
||||
for device in devices:
|
||||
if not device.address:
|
||||
continue
|
||||
coordinator = next(
|
||||
(
|
||||
cast(ApplianceUpdateCoordinator, coord)
|
||||
for coord in self.hub.coordinators
|
||||
if coord.appliance.serial_number == device.serial_number
|
||||
),
|
||||
None,
|
||||
)
|
||||
if coordinator:
|
||||
# If address changed, we need to handle it
|
||||
if device.address and device.address != coordinator.appliance.address:
|
||||
_LOGGER.debug(
|
||||
"Device %s changed address to %s",
|
||||
coordinator.name,
|
||||
device.address,
|
||||
)
|
||||
self.changed_devices.append(_ChangedDevice(device, coordinator))
|
||||
elif supported_appliance(self.hub.config, device):
|
||||
_LOGGER.debug("Discovered new device %s", device)
|
||||
self.new_devices.append(device)
|
||||
|
||||
def _merge_with_configuration(self: ApplianceDiscoveryHelper) -> bool:
|
||||
"""Merges list of changed devices with existing config entry configuration"""
|
||||
dev_confs: list[dict[str, Any]] = self.hub.config[CONF_DEVICES]
|
||||
updated_conf = False
|
||||
for changed in self.changed_devices:
|
||||
for known in dev_confs:
|
||||
coordinator = changed.coordinator
|
||||
device = changed.device
|
||||
if known[CONF_UNIQUE_ID] == coordinator.appliance.serial_number:
|
||||
coordinator.appliance.address = device.address
|
||||
known[CONF_IP_ADDRESS] = device.address
|
||||
updated_conf = True
|
||||
if device.address and known[CONF_DISCOVERY] != DISCOVERY_LAN:
|
||||
self._possible_lan_notification(
|
||||
coordinator.appliance,
|
||||
known,
|
||||
device.address,
|
||||
)
|
||||
break
|
||||
|
||||
return updated_conf
|
||||
|
||||
def _setup(self) -> None:
|
||||
"""Initializes address iterator.
|
||||
Address iterator allows iterating over adresses to broadcast to.
|
||||
It will iterate over all addresses in specified ranges.
|
||||
"""
|
||||
self.notifed_addresses.clear()
|
||||
self.conf_addresses.clear()
|
||||
has_discoverable = False
|
||||
device: dict[str, Any]
|
||||
for device in self.hub.config[CONF_DEVICES]:
|
||||
if _add_if_discoverable(self.conf_addresses, device):
|
||||
has_discoverable = True
|
||||
for coordinator in self.hub.coordinators:
|
||||
if not coordinator.available:
|
||||
if _add_if_discoverable(self.conf_addresses, coordinator.device):
|
||||
has_discoverable = True
|
||||
self.conf_addresses += [
|
||||
item
|
||||
for item in self.hub.config.get(CONF_BROADCAST_ADDRESS, []) or []
|
||||
if item and item != LOCAL_BROADCAST
|
||||
]
|
||||
self.broadcast_addresses = [LOCAL_BROADCAST]
|
||||
for addr in self.conf_addresses:
|
||||
net = ipaddress.IPv4Network(addr)
|
||||
self.broadcast_addresses.append(str(net.broadcast_address))
|
||||
|
||||
if has_discoverable and self.conf_addresses:
|
||||
_LOGGER.debug("Discovery via configured addresses %s", self.conf_addresses)
|
||||
self.address_iterator = cycle(self._address_generator())
|
||||
else:
|
||||
self.address_iterator = empty_address_iterator()
|
||||
|
||||
def start(self) -> None:
|
||||
"""Starts periodic disovery of devices"""
|
||||
self.stop()
|
||||
try:
|
||||
self._setup()
|
||||
scan_interval = self.hub.config.get(
|
||||
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
|
||||
)
|
||||
if scan_interval:
|
||||
_LOGGER.debug(
|
||||
"Starting periodic discovery with interval %s minute(s),"
|
||||
" broadcast %s, configured %s",
|
||||
scan_interval,
|
||||
self.broadcast_addresses,
|
||||
self.conf_addresses,
|
||||
)
|
||||
self.remove_discovery = async_track_time_interval(
|
||||
self.hass, self._async_discover, timedelta(minutes=scan_interval)
|
||||
)
|
||||
except Exception as ex:
|
||||
_LOGGER.error(
|
||||
"Unable to setup up periodic discovery."
|
||||
" Please remove integration and then reinstall it to check if problem"
|
||||
" can be fixed."
|
||||
" Cause: %s"
|
||||
" Configuration: %s",
|
||||
ex,
|
||||
RedactedConf(self.hub.config),
|
||||
)
|
||||
self.stop()
|
||||
raise ex
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stops periodic disovery of devices"""
|
||||
if self.remove_discovery:
|
||||
_LOGGER.debug("Stopping periodic discovery")
|
||||
|
||||
self.remove_discovery()
|
||||
self.remove_discovery = None
|
||||
|
||||
async def _async_discover(self, _: datetime) -> None:
|
||||
"""Discover Midea appliances on configured network interfaces."""
|
||||
|
||||
addresses = list(address for address in self.broadcast_addresses)
|
||||
if new_addresses := next(self.address_iterator, None):
|
||||
addresses += new_addresses
|
||||
if not addresses:
|
||||
iface_broadcast = await async_get_ipv4_broadcast_addresses(self.hass)
|
||||
addresses += [str(address) for address in iface_broadcast]
|
||||
_LOGGER.debug("Initiated discovery via %s", addresses)
|
||||
result = self.hub.client.find_appliances(None, addresses, retries=1, timeout=1)
|
||||
if result:
|
||||
await self._async_run_discovery(result)
|
||||
Reference in New Issue
Block a user