core/homeassistant/components/pglab/discovery.py

278 lines
9.9 KiB
Python
Raw Normal View History

Add pglab integration (#109725) * Add PG LAB Electronics integration * Add time from last boot sensor diagnostic * Limit the initial new pglab integration to only one platform * Update FlowHandler with the new return type ConfigFlowResult * Fix docstring file with the right integration name to PG LAB. * There is no need for default value in the callback definition. * Move all mqtt callbacks to be global and also renamed with a better name. * Removed unused member variables. * Renaming functions with a better name. * Adding miss docstring to __build_device. * Renamed CreateDiscovery with a better name. * Removing not so meaning comment. * Avoid to populate hass.data with pglab discovery information. Use hass.data[DOMAIN] instead. * Revert "Removed unused member variables." This reverts commit 4193c491ec3c31d5c589abac59028ee9be898785. * Removed unused member variables. * Refactoring of const. Be sure to have in const.py constant that are used in at least two other modules * Restoring back the process to unregister the plaform when unload the integration. * fix spelling mistake * Revert "Move all mqtt callbacks to be global and also renamed with a better name." This reverts commit d94d8010d5d11d3febfcb075859483d9e2beae3c. * Main refactoring to avoid to store PG Lab discovery in hass.data * Change class name BaseEntity in PGLabEntity. And named PyPGLab... what imported from external python module pypglab. * Avoid to use dict to create DeviceInfo * Removing unused parameter * Removing not necessary call to base class * Update entity name/id to be compatible with the new integration policy. * Upate test to new entity id * Add new line after file description * avoid to store in local variable data for calling function * Move PGLABConfigEntry in __init__.py * change function to pure callback * to avoid hang, dont' trust the split of the discovery topic... introduce a max split count * rename method with a more meaning name * use assignment operator * rename variable with a better name * removing unecessary test * Raise exception in case of unexpected error during discovery * Review comments all other the intergration. * Rename classes to be consistent in integration * Using new feature single_config_entry to allow single instance integration * rename class FlowHandler to PGLabFlowHandler * using __package__ to initialize integration logger * missing to catch the exception when for some reason is not possible to create the discovery instance. This can happen when the discovery MQTT message is not in valid json format. * using ATTR_ENTITY_ID instead of the string * using SOURCE_MQTT, SOURCE_USER instead of config_entries.SOURCE_MQTT, config_entries.SOURCE_USER * Using FlowResultType.ABORT instead of the string value * Code refactoring for tests of configuration from USER and MQTT * Remove to the user the possibility to add PGLab integration manually, and remove not needed tests. * Change test_device_update to use snapshot to check test result * Raise exeception in case of unexpected device and entity_id * Avoid to log on info channel. * Renamed _LOGGER in LOGGER * Propage the call to the base class * Remove not needed code because from the manifest it's only allows a single instance * Using specific type for result test instead of string value * Code refactoring, avoid not necessary function * update to the new way to import mqtt components * Avoid runtime check * add err variable for catching the exception * add doc string to mqtt_publish * add doc string to mqtt_subscribe * Rename DiscoverDeviceInfo.add_entity_id in add_entity * add doc string * removing not meaning documentation string * fix spelling * fix wrong case in docstring * fix spelling mistake in PyPGLab callback name * rename mqtt message received callback * Avoid to store hard coded discovery_prefix * Removing unused strings from strings.json * Give to the user more information during config_flow, and add the possibility to add manually the integration * Fix to avoid fails of auto test * update discovery test * Be sure to always subscribe to MQTT topic when entity is added to HA * Update codeowner of PGLAB integration and test * Add control to check if mqtt is available during integration setup * New test for check no state change for disable entity switch * Remore not more used file * update pypglab to version 0.0.3 and improve the symmetry to subscribe/unsubscribe to mqtt entity topic and to register/deregister the status update callback * Update codeowner of pglab integration * Adding quality_scale * removing async_setup * Fix spelling mistake * Added test to cover config_flow.async_step_user --------- Co-authored-by: Pierluigi <p.garaventa@gmail.com>
2025-02-10 07:19:42 +00:00
"""Discovery PG LAB Electronics devices."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import json
from typing import TYPE_CHECKING, Any
from pypglab.device import Device as PyPGLabDevice
from pypglab.mqtt import Client as PyPGLabMqttClient
from homeassistant.components.mqtt import (
EntitySubscription,
ReceiveMessage,
async_prepare_subscribe_topics,
async_subscribe_topics,
async_unsubscribe_topics,
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
from .const import DISCOVERY_TOPIC, DOMAIN, LOGGER
if TYPE_CHECKING:
from . import PGLABConfigEntry
# Supported platforms.
PLATFORMS = [
Platform.SWITCH,
]
# Used to create a new component entity.
CREATE_NEW_ENTITY = {
Platform.SWITCH: "pglab_create_new_entity_switch",
}
class PGLabDiscoveryError(Exception):
"""Raised when a discovery has failed."""
def get_device_id_from_discovery_topic(topic: str) -> str | None:
"""From the discovery topic get the PG LAB Electronics device id."""
# The discovery topic has the following format "pglab/discovery/[Device ID]/config"
split_topic = topic.split("/", 5)
# Do a sanity check on the string.
if len(split_topic) != 4:
return None
if split_topic[3] != "config":
return None
return split_topic[2]
class DiscoverDeviceInfo:
"""Keeps information of the PGLab discovered device."""
def __init__(self, pglab_device: PyPGLabDevice) -> None:
"""Initialize the device discovery info."""
# Hash string represents the devices actual configuration,
# it depends on the number of available relays and shutters.
# When the hash string changes the devices entities must be rebuilt.
self._hash = pglab_device.hash
self._entities: list[tuple[str, str]] = []
def add_entity(self, entity: Entity) -> None:
"""Add an entity."""
# PGLabEntity always have unique IDs
if TYPE_CHECKING:
assert entity.unique_id is not None
self._entities.append((entity.platform.domain, entity.unique_id))
@property
def hash(self) -> int:
"""Return the hash for this configuration."""
return self._hash
@property
def entities(self) -> list[tuple[str, str]]:
"""Return array of entities available."""
return self._entities
@dataclass
class PGLabDiscovery:
"""Discovery a PGLab device with the following MQTT topic format pglab/discovery/[device]/config."""
def __init__(self) -> None:
"""Initialize the discovery class."""
self._substate: dict[str, EntitySubscription] = {}
self._discovery_topic = DISCOVERY_TOPIC
self._mqtt_client = None
self._discovered: dict[str, DiscoverDeviceInfo] = {}
self._disconnect_platform: list = []
async def __build_device(
self, mqtt: PyPGLabMqttClient, msg: ReceiveMessage
) -> PyPGLabDevice:
"""Build a PGLab device."""
# Check if the discovery message is in valid json format.
try:
payload = json.loads(msg.payload)
except ValueError as err:
raise PGLabDiscoveryError(
f"Can't decode discovery payload: {msg.payload!r}"
) from err
device_id = "id"
# Check if the key id is present in the payload. It must always be present.
if device_id not in payload:
raise PGLabDiscoveryError(
"Unexpected discovery payload format, id key not present"
)
# Do a sanity check: the id must match the discovery topic /pglab/discovery/[id]/config
topic = msg.topic
if not topic.endswith(f"{payload[device_id]}/config"):
raise PGLabDiscoveryError("Unexpected discovery topic format")
# Build and configure the PGLab device.
pglab_device = PyPGLabDevice()
if not await pglab_device.config(mqtt, payload):
raise PGLabDiscoveryError("Error during setup of a new discovered device")
return pglab_device
def __clean_discovered_device(self, hass: HomeAssistant, device_id: str) -> None:
"""Destroy the device and any entities connected to the device."""
if device_id not in self._discovered:
return
discovery_info = self._discovered[device_id]
# Destroy all entities connected to the device.
entity_registry = er.async_get(hass)
for platform, unique_id in discovery_info.entities:
if entity_id := entity_registry.async_get_entity_id(
platform, DOMAIN, unique_id
):
entity_registry.async_remove(entity_id)
# Destroy the device.
device_registry = dr.async_get(hass)
if device_entry := device_registry.async_get_device(
identifiers={(DOMAIN, device_id)}
):
device_registry.async_remove_device(device_entry.id)
# Clean the discovery info.
del self._discovered[device_id]
async def start(
self, hass: HomeAssistant, mqtt: PyPGLabMqttClient, entry: PGLABConfigEntry
) -> None:
"""Start discovering a PGLab devices."""
async def discovery_message_received(msg: ReceiveMessage) -> None:
"""Received a new discovery message."""
# Create a PGLab device and add entities.
try:
pglab_device = await self.__build_device(mqtt, msg)
except PGLabDiscoveryError as err:
LOGGER.warning("Can't create PGLabDiscovery instance(%s) ", str(err))
# For some reason it's not possible to create the device with the discovery message,
# be sure that any previous device with the same topic is now destroyed.
device_id = get_device_id_from_discovery_topic(msg.topic)
# If there is a valid topic device_id clean everything relative to the device.
if device_id:
self.__clean_discovered_device(hass, device_id)
return
# Create a new device.
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
configuration_url=f"http://{pglab_device.ip}/",
connections={(CONNECTION_NETWORK_MAC, pglab_device.mac)},
identifiers={(DOMAIN, pglab_device.id)},
manufacturer=pglab_device.manufactor,
model=pglab_device.type,
name=pglab_device.name,
sw_version=pglab_device.firmware_version,
hw_version=pglab_device.hardware_version,
)
# Do some checking if previous entities must be updated.
if pglab_device.id in self._discovered:
# The device is already been discovered,
# get the old discovery info data.
discovery_info = self._discovered[pglab_device.id]
if discovery_info.hash == pglab_device.hash:
# Best case, there is nothing to do.
# The device is still in the same configuration. Same name, same shutters, same relay etc.
return
LOGGER.warning(
"Changed internal configuration of device(%s). Rebuilding all entities",
pglab_device.id,
)
# Something has changed, all previous entities must be destroyed and re-created.
self.__clean_discovered_device(hass, pglab_device.id)
# Add a new device.
discovery_info = DiscoverDeviceInfo(pglab_device)
self._discovered[pglab_device.id] = discovery_info
# Create all new relay entities.
for r in pglab_device.relays:
# The HA entity is not yet created, send a message to create it.
async_dispatcher_send(
hass, CREATE_NEW_ENTITY[Platform.SWITCH], pglab_device, r
)
topics = {
"discovery_topic": {
"topic": f"{self._discovery_topic}/#",
"msg_callback": discovery_message_received,
}
}
# Forward setup all HA supported platforms.
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
self._mqtt_client = mqtt
self._substate = async_prepare_subscribe_topics(hass, self._substate, topics)
await async_subscribe_topics(hass, self._substate)
async def register_platform(
self, hass: HomeAssistant, platform: Platform, target: Callable[..., Any]
):
"""Register a callback to create entity of a specific HA platform."""
disconnect_callback = async_dispatcher_connect(
hass, CREATE_NEW_ENTITY[platform], target
)
self._disconnect_platform.append(disconnect_callback)
async def stop(self, hass: HomeAssistant, entry: PGLABConfigEntry) -> None:
"""Stop to discovery PG LAB devices."""
await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
# Disconnect all registered platforms.
for disconnect_callback in self._disconnect_platform:
disconnect_callback()
async_unsubscribe_topics(hass, self._substate)
async def add_entity(self, entity: Entity, device_id: str):
"""Save a new PG LAB device entity."""
# Be sure that the device is been discovered.
if device_id not in self._discovered:
raise PGLabDiscoveryError("Unknown device, device_id not discovered")
discovery_info = self._discovered[device_id]
discovery_info.add_entity(entity)