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>
This commit is contained in:
pglab-electronics 2025-02-10 08:19:42 +01:00 committed by GitHub
parent 15223b3679
commit 8c602d74f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1378 additions and 0 deletions

2
CODEOWNERS generated
View File

@ -1138,6 +1138,8 @@ build.json @home-assistant/supervisor
/tests/components/permobil/ @IsakNyberg
/homeassistant/components/persistent_notification/ @home-assistant/core
/tests/components/persistent_notification/ @home-assistant/core
/homeassistant/components/pglab/ @pglab-electronics
/tests/components/pglab/ @pglab-electronics
/homeassistant/components/philips_js/ @elupus
/tests/components/philips_js/ @elupus
/homeassistant/components/pi_hole/ @shenxn

View File

@ -0,0 +1,85 @@
"""PG LAB Electronics integration."""
from __future__ import annotations
from pypglab.mqtt import (
Client as PyPGLabMqttClient,
Sub_State as PyPGLabSubState,
Subcribe_CallBack as PyPGLabSubscribeCallBack,
)
from homeassistant.components import mqtt
from homeassistant.components.mqtt import (
ReceiveMessage,
async_prepare_subscribe_topics,
async_subscribe_topics,
async_unsubscribe_topics,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN, LOGGER
from .discovery import PGLabDiscovery
type PGLABConfigEntry = ConfigEntry[PGLabDiscovery]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup_entry(hass: HomeAssistant, entry: PGLABConfigEntry) -> bool:
"""Set up PG LAB Electronics integration from a config entry."""
async def mqtt_publish(topic: str, payload: str, qos: int, retain: bool) -> None:
"""Publish an MQTT message using the Home Assistant MQTT client."""
await mqtt.async_publish(hass, topic, payload, qos, retain)
async def mqtt_subscribe(
sub_state: PyPGLabSubState, topic: str, callback_func: PyPGLabSubscribeCallBack
) -> PyPGLabSubState:
"""Subscribe to MQTT topics using the Home Assistant MQTT client."""
@callback
def mqtt_message_received(msg: ReceiveMessage) -> None:
"""Handle PGLab mqtt messages."""
callback_func(msg.topic, msg.payload)
topics = {
"pglab_subscribe_topic": {
"topic": topic,
"msg_callback": mqtt_message_received,
}
}
sub_state = async_prepare_subscribe_topics(hass, sub_state, topics)
await async_subscribe_topics(hass, sub_state)
return sub_state
async def mqtt_unsubscribe(sub_state: PyPGLabSubState) -> None:
async_unsubscribe_topics(hass, sub_state)
if not await mqtt.async_wait_for_mqtt_client(hass):
LOGGER.error("MQTT integration not available")
raise ConfigEntryNotReady("MQTT integration not available")
# Create an MQTT client for PGLab used for PGLab python module.
pglab_mqtt = PyPGLabMqttClient(mqtt_publish, mqtt_subscribe, mqtt_unsubscribe)
# Setup PGLab device discovery.
entry.runtime_data = PGLabDiscovery()
# Start to discovery PG Lab devices.
await entry.runtime_data.start(hass, pglab_mqtt, entry)
return True
async def async_unload_entry(hass: HomeAssistant, entry: PGLABConfigEntry) -> bool:
"""Unload a config entry."""
# Stop PGLab device discovery.
pglab_discovery = entry.runtime_data
await pglab_discovery.stop(hass, entry)
return True

View File

@ -0,0 +1,73 @@
"""Config flow for PG LAB Electronics integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components import mqtt
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
from .const import DISCOVERY_TOPIC, DOMAIN
class PGLabFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
VERSION = 1
async def async_step_mqtt(
self, discovery_info: MqttServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by MQTT discovery."""
await self.async_set_unique_id(DOMAIN)
# Validate the message, abort if it fails.
if not discovery_info.topic.endswith("/config"):
# Not a PGLab Electronics discovery message.
return self.async_abort(reason="invalid_discovery_info")
if not discovery_info.payload:
# Empty payload, unexpected payload.
return self.async_abort(reason="invalid_discovery_info")
return await self.async_step_confirm_from_mqtt()
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
try:
if not mqtt.is_connected(self.hass):
return self.async_abort(reason="mqtt_not_connected")
except KeyError:
return self.async_abort(reason="mqtt_not_configured")
return await self.async_step_confirm_from_user()
def step_confirm(
self, step_id: str, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the setup."""
if user_input is not None:
return self.async_create_entry(
title="PG LAB Electronics",
data={
"discovery_prefix": DISCOVERY_TOPIC,
},
)
return self.async_show_form(step_id=step_id)
async def async_step_confirm_from_mqtt(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the setup from MQTT discovered."""
return self.step_confirm(step_id="confirm_from_mqtt", user_input=user_input)
async def async_step_confirm_from_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the setup from user add integration."""
return self.step_confirm(step_id="confirm_from_user", user_input=user_input)

View File

@ -0,0 +1,12 @@
"""Constants used by PG LAB Electronics integration."""
import logging
# The domain of the integration.
DOMAIN = "pglab"
# The message logger.
LOGGER = logging.getLogger(__package__)
# The MQTT message used to subscribe to get a new PG LAB device.
DISCOVERY_TOPIC = "pglab/discovery"

View File

@ -0,0 +1,277 @@
"""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)

View File

@ -0,0 +1,70 @@
"""Entity for PG LAB Electronics."""
from __future__ import annotations
from pypglab.device import Device as PyPGLabDevice
from pypglab.entity import Entity as PyPGLabEntity
from homeassistant.core import callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
from .discovery import PGLabDiscovery
class PGLabEntity(Entity):
"""Representation of a PGLab entity in Home Assistant."""
_attr_has_entity_name = True
def __init__(
self,
discovery: PGLabDiscovery,
device: PyPGLabDevice,
entity: PyPGLabEntity,
) -> None:
"""Initialize the class."""
self._id = entity.id
self._device_id = device.id
self._entity = entity
self._discovery = discovery
# Information about the device that is partially visible in the UI.
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},
name=device.name,
sw_version=device.firmware_version,
hw_version=device.hardware_version,
model=device.type,
manufacturer=device.manufactor,
configuration_url=f"http://{device.ip}/",
connections={(CONNECTION_NETWORK_MAC, device.mac)},
)
async def async_added_to_hass(self) -> None:
"""Update the device discovery info."""
self._entity.set_on_state_callback(self.state_updated)
await self._entity.subscribe_topics()
await super().async_added_to_hass()
# Inform PGLab discovery instance that a new entity is available.
# This is important to know in case the device needs to be reconfigured
# and the entity can be potentially destroyed.
await self._discovery.add_entity(self, self._device_id)
async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe when removed."""
await super().async_will_remove_from_hass()
await self._entity.unsubscribe_topics()
self._entity.set_on_state_callback(None)
@callback
def state_updated(self, payload: str) -> None:
"""Handle state updates."""
self.async_write_ha_state()

View File

@ -0,0 +1,14 @@
{
"domain": "pglab",
"name": "PG LAB Electronics",
"codeowners": ["@pglab-electronics"],
"config_flow": true,
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/pglab",
"iot_class": "local_push",
"loggers": ["pglab"],
"mqtt": ["pglab/discovery/#"],
"quality_scale": "bronze",
"requirements": ["pypglab==0.0.3"],
"single_config_entry": true
}

View File

@ -0,0 +1,80 @@
rules:
# Bronze
action-setup:
status: exempt
comment: The integration does not provide any additional actions.
appropriate-polling:
status: exempt
comment: The integration does not poll.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: The integration does not provide any additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure:
status: exempt
comment: The integration relies solely on auto-discovery.
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: The integration does not provide any additional actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No options flow.
docs-installation-parameters:
status: exempt
comment: There are no parameters.
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow:
status: exempt
comment: The integration does not require authentication.
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: done
discovery: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow:
status: exempt
comment: The integration has no settings.
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: The integration does not make HTTP requests.
strict-typing: todo

View File

@ -0,0 +1,24 @@
{
"config": {
"step": {
"confirm_from_user": {
"description": "In order to be found PG LAB Electronics devices need to be connected to the same broker as the Home Assistant MQTT integration client. Do you want to continue?"
},
"confirm_from_mqtt": {
"description": "Do you want to set up PG LAB Electronics?"
}
},
"abort": {
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"mqtt_not_connected": "Home Assistant MQTT integration not connected to MQTT broker.",
"mqtt_not_configured": "Home Assistant MQTT integration not configured."
}
},
"entity": {
"switch": {
"relay": {
"name": "Relay {relay_id}"
}
}
}
}

View File

@ -0,0 +1,76 @@
"""Switch for PG LAB Electronics."""
from __future__ import annotations
from typing import Any
from pypglab.device import Device as PyPGLabDevice
from pypglab.relay import Relay as PyPGLabRelay
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import PGLABConfigEntry
from .discovery import PGLabDiscovery
from .entity import PGLabEntity
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: PGLABConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up switches for device."""
@callback
def async_discover(pglab_device: PyPGLabDevice, pglab_relay: PyPGLabRelay) -> None:
"""Discover and add a PGLab Relay."""
pglab_discovery = config_entry.runtime_data
pglab_switch = PGLabSwitch(pglab_discovery, pglab_device, pglab_relay)
async_add_entities([pglab_switch])
# Register the callback to create the switch entity when discovered.
pglab_discovery = config_entry.runtime_data
await pglab_discovery.register_platform(hass, Platform.SWITCH, async_discover)
class PGLabSwitch(PGLabEntity, SwitchEntity):
"""A PGLab switch."""
_attr_translation_key = "relay"
def __init__(
self,
pglab_discovery: PGLabDiscovery,
pglab_device: PyPGLabDevice,
pglab_relay: PyPGLabRelay,
) -> None:
"""Initialize the Switch class."""
super().__init__(
discovery=pglab_discovery,
device=pglab_device,
entity=pglab_relay,
)
self._attr_unique_id = f"{pglab_device.id}_relay{pglab_relay.id}"
self._attr_translation_placeholders = {"relay_id": pglab_relay.id}
self._relay = pglab_relay
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
await self._relay.turn_on()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
await self._relay.turn_off()
@property
def is_on(self) -> bool:
"""Return true if device is on."""
return self._relay.state

View File

@ -467,6 +467,7 @@ FLOWS = {
"peco",
"pegel_online",
"permobil",
"pglab",
"philips_js",
"pi_hole",
"picnic",

View File

@ -4739,6 +4739,13 @@
"integration_type": "virtual",
"supported_by": "opower"
},
"pglab": {
"name": "PG LAB Electronics",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push",
"single_config_entry": true
},
"philips": {
"name": "Philips",
"integrations": {

View File

@ -16,6 +16,9 @@ MQTT = {
"fully_kiosk": [
"fully/deviceInfo/+",
],
"pglab": [
"pglab/discovery/#",
],
"qbus": [
"cloudapp/QBUSMQTTGW/state",
"cloudapp/QBUSMQTTGW/config",

3
requirements_all.txt generated
View File

@ -2207,6 +2207,9 @@ pypca==0.0.7
# homeassistant.components.lcn
pypck==0.8.5
# homeassistant.components.pglab
pypglab==0.0.3
# homeassistant.components.pjlink
pypjlink2==1.2.1

View File

@ -1800,6 +1800,9 @@ pypalazzetti==0.1.19
# homeassistant.components.lcn
pypck==0.8.5
# homeassistant.components.pglab
pypglab==0.0.3
# homeassistant.components.pjlink
pypjlink2==1.2.1

View File

@ -0,0 +1 @@
"""Tests for the PG LAB Electronics integration."""

View File

@ -0,0 +1,41 @@
"""Common fixtures for the PG LAB Electronics tests."""
import pytest
from homeassistant.components.pglab.const import DISCOVERY_TOPIC, DOMAIN
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, mock_device_registry, mock_registry
CONF_DISCOVERY_PREFIX = "discovery_prefix"
@pytest.fixture
def device_reg(hass: HomeAssistant):
"""Return an empty, loaded, registry."""
return mock_device_registry(hass)
@pytest.fixture
def entity_reg(hass: HomeAssistant):
"""Return an empty, loaded, registry."""
return mock_registry(hass)
@pytest.fixture
async def setup_pglab(hass: HomeAssistant):
"""Set up PG LAB Electronics."""
hass.config.components.add("pglab")
entry = MockConfigEntry(
data={CONF_DISCOVERY_PREFIX: DISCOVERY_TOPIC},
domain=DOMAIN,
title="PG LAB Electronics",
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert "pglab" in hass.config.components

View File

@ -0,0 +1,133 @@
"""Test the PG LAB Electronics config flow."""
from homeassistant.components.mqtt import MQTT_CONNECTION_STATE
from homeassistant.components.pglab.const import DOMAIN
from homeassistant.config_entries import SOURCE_MQTT, SOURCE_USER
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
from tests.common import MockConfigEntry
from tests.typing import MqttMockHAClient
async def test_mqtt_config_single_instance(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient
) -> None:
"""Test MQTT flow aborts when an entry already exist."""
MockConfigEntry(domain=DOMAIN).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_MQTT}
)
# Be sure that result is abort. Only single instance is allowed.
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "single_instance_allowed"
async def test_mqtt_setup(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None:
"""Test we can finish a config flow through MQTT with custom prefix."""
discovery_info = MqttServiceInfo(
topic="pglab/discovery/E-Board-DD53AC85/config",
payload=(
'{"ip":"192.168.1.16", "mac":"80:34:28:1B:18:5A", "name":"e-board-office",'
'"hw":"255.255.255", "fw":"255.255.255", "type":"E-Board", "id":"E-Board-DD53AC85",'
'"manufacturer":"PG LAB Electronics", "params":{"shutters":0, "boards":"10000000" } }'
),
qos=0,
retain=False,
subscribed_topic="pglab/discovery/#",
timestamp=None,
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["result"].data == {"discovery_prefix": "pglab/discovery"}
async def test_mqtt_abort_invalid_topic(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient
) -> None:
"""Check MQTT flow aborts if discovery topic is invalid."""
discovery_info = MqttServiceInfo(
topic="pglab/discovery/E-Board-DD53AC85/wrong_topic",
payload=(
'{"ip":"192.168.1.16", "mac":"80:34:28:1B:18:5A", "name":"e-board-office",'
'"hw":"255.255.255", "fw":"255.255.255", "type":"E-Board", "id":"E-Board-DD53AC85",'
'"manufacturer":"PG LAB Electronics", "params":{"shutters":0, "boards":"10000000" } }'
),
qos=0,
retain=False,
subscribed_topic="pglab/discovery/#",
timestamp=None,
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "invalid_discovery_info"
discovery_info = MqttServiceInfo(
topic="pglab/discovery/E-Board-DD53AC85/config",
payload="",
qos=0,
retain=False,
subscribed_topic="pglab/discovery/#",
timestamp=None,
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "invalid_discovery_info"
async def test_user_setup(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None:
"""Test if the user can finish a config flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].data == {
"discovery_prefix": "pglab/discovery",
}
async def test_user_setup_mqtt_not_connected(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient
) -> None:
"""Test that the user setup is aborted when MQTT is not connected."""
mqtt_mock.connected = False
async_dispatcher_send(hass, MQTT_CONNECTION_STATE, False)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "mqtt_not_connected"
async def test_user_setup_mqtt_not_configured(hass: HomeAssistant) -> None:
"""Test that the user setup is aborted when MQTT is not configured."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "mqtt_not_configured"

View File

@ -0,0 +1,154 @@
"""The tests for the PG LAB Electronics discovery device."""
import json
from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from tests.common import async_fire_mqtt_message
from tests.typing import MqttMockHAClient
async def test_device_discover(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
device_reg,
entity_reg,
setup_pglab,
) -> None:
"""Test setting up a device."""
topic = "pglab/discovery/E-Board-DD53AC85/config"
payload = {
"ip": "192.168.1.16",
"mac": "80:34:28:1B:18:5A",
"name": "test",
"hw": "1.0.7",
"fw": "1.0.0",
"type": "E-Board",
"id": "E-Board-DD53AC85",
"manufacturer": "PG LAB Electronics",
"params": {"shutters": 0, "boards": "11000000"},
}
async_fire_mqtt_message(
hass,
topic,
json.dumps(payload),
)
await hass.async_block_till_done()
# Verify device and registry entries are created
device_entry = device_reg.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, payload["mac"])}
)
assert device_entry is not None
assert device_entry.configuration_url == f"http://{payload['ip']}/"
assert device_entry.manufacturer == "PG LAB Electronics"
assert device_entry.model == payload["type"]
assert device_entry.name == payload["name"]
assert device_entry.sw_version == payload["fw"]
async def test_device_update(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
device_reg,
entity_reg,
setup_pglab,
snapshot: SnapshotAssertion,
) -> None:
"""Test update a device."""
topic = "pglab/discovery/E-Board-DD53AC85/config"
payload = {
"ip": "192.168.1.16",
"mac": "80:34:28:1B:18:5A",
"name": "test",
"hw": "1.0.7",
"fw": "1.0.0",
"type": "E-Board",
"id": "E-Board-DD53AC85",
"manufacturer": "PG LAB Electronics",
"params": {"shutters": 0, "boards": "11000000"},
}
async_fire_mqtt_message(
hass,
topic,
json.dumps(payload),
)
await hass.async_block_till_done()
# Verify device is created
device_entry = device_reg.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, payload["mac"])}
)
assert device_entry is not None
# update device
payload["fw"] = "1.0.1"
payload["hw"] = "1.0.8"
async_fire_mqtt_message(
hass,
topic,
json.dumps(payload),
)
await hass.async_block_till_done()
# Verify device is created
device_entry = device_reg.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, payload["mac"])}
)
assert device_entry is not None
assert device_entry.sw_version == "1.0.1"
assert device_entry.hw_version == "1.0.8"
async def test_device_remove(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
device_reg,
entity_reg,
setup_pglab,
) -> None:
"""Test remove a device."""
topic = "pglab/discovery/E-Board-DD53AC85/config"
payload = {
"ip": "192.168.1.16",
"mac": "80:34:28:1B:18:5A",
"name": "test",
"hw": "1.0.7",
"fw": "1.0.0",
"type": "E-Board",
"id": "E-Board-DD53AC85",
"manufacturer": "PG LAB Electronics",
"params": {"shutters": 0, "boards": "11000000"},
}
async_fire_mqtt_message(
hass,
topic,
json.dumps(payload),
)
await hass.async_block_till_done()
# Verify device is created
device_entry = device_reg.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, payload["mac"])}
)
assert device_entry is not None
async_fire_mqtt_message(
hass,
topic,
"",
)
await hass.async_block_till_done()
# Verify device entry is removed
device_entry = device_reg.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, payload["mac"])}
)
assert device_entry is None

View File

@ -0,0 +1 @@
"""Test the PG LAB Electronics integration."""

View File

@ -0,0 +1,318 @@
"""The tests for the PG LAB Electronics switch."""
from datetime import timedelta
import json
from homeassistant import config_entries
from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_ENTITY_ID,
STATE_OFF,
STATE_ON,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from tests.common import async_fire_mqtt_message, async_fire_time_changed
from tests.typing import MqttMockHAClient
async def call_service(hass: HomeAssistant, entity_id, service, **kwargs):
"""Call a service."""
await hass.services.async_call(
SWITCH_DOMAIN,
service,
{ATTR_ENTITY_ID: entity_id, **kwargs},
blocking=True,
)
async def test_available_relay(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab
) -> None:
"""Check if relay are properly created when two E-Relay boards are connected."""
topic = "pglab/discovery/E-Board-DD53AC85/config"
payload = {
"ip": "192.168.1.16",
"mac": "80:34:28:1B:18:5A",
"name": "test",
"hw": "1.0.7",
"fw": "1.0.0",
"type": "E-Board",
"id": "E-Board-DD53AC85",
"manufacturer": "PG LAB Electronics",
"params": {"shutters": 0, "boards": "11000000"},
}
async_fire_mqtt_message(
hass,
topic,
json.dumps(payload),
)
await hass.async_block_till_done()
for i in range(16):
state = hass.states.get(f"switch.test_relay_{i}")
assert state.state == STATE_UNKNOWN
assert not state.attributes.get(ATTR_ASSUMED_STATE)
async def test_change_state_via_mqtt(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab
) -> None:
"""Test state update via MQTT."""
topic = "pglab/discovery/E-Board-DD53AC85/config"
payload = {
"ip": "192.168.1.16",
"mac": "80:34:28:1B:18:5A",
"name": "test",
"hw": "1.0.7",
"fw": "1.0.0",
"type": "E-Board",
"id": "E-Board-DD53AC85",
"manufacturer": "PG LAB Electronics",
"params": {"shutters": 0, "boards": "10000000"},
}
async_fire_mqtt_message(
hass,
topic,
json.dumps(payload),
)
await hass.async_block_till_done()
# Simulate response from the device
state = hass.states.get("switch.test_relay_0")
assert state.state == STATE_UNKNOWN
assert not state.attributes.get(ATTR_ASSUMED_STATE)
# Turn relay OFF
async_fire_mqtt_message(hass, "pglab/test/relay/0/state", "OFF")
await hass.async_block_till_done()
state = hass.states.get("switch.test_relay_0")
assert not state.attributes.get(ATTR_ASSUMED_STATE)
assert state.state == STATE_OFF
# Turn relay ON
async_fire_mqtt_message(hass, "pglab/test/relay/0/state", "ON")
await hass.async_block_till_done()
state = hass.states.get("switch.test_relay_0")
assert state.state == STATE_ON
# Turn relay OFF
async_fire_mqtt_message(hass, "pglab/test/relay/0/state", "OFF")
await hass.async_block_till_done()
state = hass.states.get("switch.test_relay_0")
assert state.state == STATE_OFF
# Turn relay ON
async_fire_mqtt_message(hass, "pglab/test/relay/0/state", "ON")
await hass.async_block_till_done()
state = hass.states.get("switch.test_relay_0")
assert state.state == STATE_ON
async def test_mqtt_state_by_calling_service(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab
) -> None:
"""Calling service to turn ON/OFF relay and check mqtt state."""
topic = "pglab/discovery/E-Board-DD53AC85/config"
payload = {
"ip": "192.168.1.16",
"mac": "80:34:28:1B:18:5A",
"name": "test",
"hw": "1.0.7",
"fw": "1.0.0",
"type": "E-Board",
"id": "E-Board-DD53AC85",
"manufacturer": "PG LAB Electronics",
"params": {"shutters": 0, "boards": "10000000"},
}
async_fire_mqtt_message(
hass,
topic,
json.dumps(payload),
)
await hass.async_block_till_done()
# Turn relay ON
await call_service(hass, "switch.test_relay_0", SERVICE_TURN_ON)
mqtt_mock.async_publish.assert_called_once_with(
"pglab/test/relay/0/set", "ON", 0, False
)
mqtt_mock.async_publish.reset_mock()
# Turn relay OFF
await call_service(hass, "switch.test_relay_0", SERVICE_TURN_OFF)
mqtt_mock.async_publish.assert_called_once_with(
"pglab/test/relay/0/set", "OFF", 0, False
)
mqtt_mock.async_publish.reset_mock()
# Turn relay ON
await call_service(hass, "switch.test_relay_3", SERVICE_TURN_ON)
mqtt_mock.async_publish.assert_called_once_with(
"pglab/test/relay/3/set", "ON", 0, False
)
mqtt_mock.async_publish.reset_mock()
# Turn relay OFF
await call_service(hass, "switch.test_relay_3", SERVICE_TURN_OFF)
mqtt_mock.async_publish.assert_called_once_with(
"pglab/test/relay/3/set", "OFF", 0, False
)
mqtt_mock.async_publish.reset_mock()
async def test_discovery_update(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab
) -> None:
"""Update discovery message and check if relay are property updated."""
# publish the first discovery message
topic = "pglab/discovery/E-Board-DD53AC85/config"
payload = {
"ip": "192.168.1.16",
"mac": "80:34:28:1B:18:5A",
"name": "first_test",
"hw": "1.0.7",
"fw": "1.0.0",
"type": "E-Board",
"id": "E-Board-DD53AC85",
"manufacturer": "PG LAB Electronics",
"params": {"shutters": 0, "boards": "10000000"},
}
async_fire_mqtt_message(
hass,
topic,
json.dumps(payload),
)
await hass.async_block_till_done()
# test the available relay in the first configuration
for i in range(8):
state = hass.states.get(f"switch.first_test_relay_{i}")
assert state.state == STATE_UNKNOWN
assert not state.attributes.get(ATTR_ASSUMED_STATE)
# prepare a new message ... the same device but renamed
# and with different relay configuration
topic = "pglab/discovery/E-Board-DD53AC85/config"
payload = {
"ip": "192.168.1.16",
"mac": "80:34:28:1B:18:5A",
"name": "second_test",
"hw": "1.0.7",
"fw": "1.0.0",
"type": "E-Board",
"id": "E-Board-DD53AC85",
"manufacturer": "PG LAB Electronics",
"params": {"shutters": 0, "boards": "11000000"},
}
async_fire_mqtt_message(
hass,
topic,
json.dumps(payload),
)
await hass.async_block_till_done()
# be sure that old relay are been removed
for i in range(8):
assert not hass.states.get(f"switch.first_test_relay_{i}")
# check new relay
for i in range(16):
state = hass.states.get(f"switch.second_test_relay_{i}")
assert state.state == STATE_UNKNOWN
assert not state.attributes.get(ATTR_ASSUMED_STATE)
async def test_disable_entity_state_change_via_mqtt(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mqtt_mock: MqttMockHAClient,
setup_pglab,
) -> None:
"""Test state update via MQTT of disable entity."""
topic = "pglab/discovery/E-Board-DD53AC85/config"
payload = {
"ip": "192.168.1.16",
"mac": "80:34:28:1B:18:5A",
"name": "test",
"hw": "1.0.7",
"fw": "1.0.0",
"type": "E-Board",
"id": "E-Board-DD53AC85",
"manufacturer": "PG LAB Electronics",
"params": {"shutters": 0, "boards": "10000000"},
}
async_fire_mqtt_message(
hass,
topic,
json.dumps(payload),
)
await hass.async_block_till_done()
# Be sure that the entity relay_0 is available
state = hass.states.get("switch.test_relay_0")
assert state.state == STATE_UNKNOWN
assert not state.attributes.get(ATTR_ASSUMED_STATE)
# Disable entity relay_0
new_status = entity_registry.async_update_entity(
"switch.test_relay_0", disabled_by=er.RegistryEntryDisabler.USER
)
# Be sure that the entity is disabled
assert new_status.disabled is True
# Try to change the state of the disabled relay_0
async_fire_mqtt_message(hass, "pglab/test/relay/0/state", "ON")
await hass.async_block_till_done()
# Enable entity relay_0
new_status = entity_registry.async_update_entity(
"switch.test_relay_0", disabled_by=None
)
# Be sure that the entity is enabled
assert new_status.disabled is False
async_fire_time_changed(
hass,
dt_util.utcnow()
+ timedelta(seconds=config_entries.RELOAD_AFTER_UPDATE_DELAY + 1),
)
await hass.async_block_till_done()
# Re-send the discovery message
async_fire_mqtt_message(
hass,
topic,
json.dumps(payload),
)
await hass.async_block_till_done()
# Be sure that the state is not changed
state = hass.states.get("switch.test_relay_0")
assert state.state == STATE_UNKNOWN
# Try again to change the state of the disabled relay_0
async_fire_mqtt_message(hass, "pglab/test/relay/0/state", "ON")
await hass.async_block_till_done()
# Be sure that the state is been updated
state = hass.states.get("switch.test_relay_0")
assert state.state == STATE_ON