From ef9491be74c076766349dce2c09a432da5e6608f Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sat, 7 Sep 2024 04:18:52 +0200 Subject: [PATCH] Support multiple connected sensors --- IoTuring/Entity/Entity.py | 14 ++- IoTuring/Entity/EntityData.py | 39 +++++--- .../HomeAssistantWarehouse.py | 94 ++++++++++--------- pyproject.toml | 4 +- 4 files changed, 85 insertions(+), 66 deletions(-) diff --git a/IoTuring/Entity/Entity.py b/IoTuring/Entity/Entity.py index 06369058f..743764846 100644 --- a/IoTuring/Entity/Entity.py +++ b/IoTuring/Entity/Entity.py @@ -19,6 +19,9 @@ class Entity(ConfiguratorObject, LogObject): + entitySensors: list[EntitySensor] + entityCommands: list[EntityCommand] + def __init__(self, single_configuration: SingleConfiguration) -> None: super().__init__(single_configuration) @@ -128,11 +131,12 @@ def GetAllEntityData(self) -> list: """ safe - Return list of entity sensors and commands """ return self.entityCommands.copy() + self.entitySensors.copy() # Safe return: nobody outside can change the callback ! - def GetAllUnconnectedEntityData(self) -> list[EntityData]: - """ safe - Return All EntityCommands and EntitySensors without connected command """ - connected_sensors = [command.GetConnectedEntitySensor() - for command in self.entityCommands - if command.SupportsState()] + def GetAllUnconnectedEntityData(self) -> list[EntityCommand|EntitySensor]: + """ safe - Return All EntityCommands and EntitySensors without connected sensors """ + connected_sensors = [] + for command in self.entityCommands: + connected_sensors.extend(command.GetConnectedEntitySensors()) + unconnected_sensors = [sensor for sensor in self.entitySensors if sensor not in connected_sensors] return self.entityCommands.copy() + unconnected_sensors.copy() diff --git a/IoTuring/Entity/EntityData.py b/IoTuring/Entity/EntityData.py index 701ebae59..f43cb3b50 100644 --- a/IoTuring/Entity/EntityData.py +++ b/IoTuring/Entity/EntityData.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Callable if TYPE_CHECKING: from IoTuring.Entity.Entity import Entity @@ -117,24 +117,35 @@ def SetExtraAttribute(self, attribute_name, attribute_value, valueFormatterOptio class EntityCommand(EntityData): - def __init__(self, entity, key, callbackFunction, - connectedEntitySensorKey=None, customPayload={}): - """ - If a key for the entity sensor is passed, warehouses that support it use this command as a switch with state. - Better to register the sensor before this command to avoud unexpected behaviours. - CustomPayload overrides HomeAssistant discovery configuration + def __init__(self, entity: Entity, key: str, callbackFunction: Callable, + connectedEntitySensorKeys: str | list = [], + customPayload={}): + """Create a new EntityCommand. + + If key or keys for the entity sensor is passed, warehouses that support it can use this command as a switch with state. + Order of sensors matter, first sensors state topic will be used. + Better to register the sensors before this command to avoid unexpected behaviours. + + Args: + entity (Entity): The entity this command belongs to. + key (str): The KEY of this command + callbackFunction (Callable): Function to be called + connectedEntitySensorKeys (str | list, optional): A key to a sensor or a list of keys. Defaults to []. + customPayload (dict, optional): Overrides HomeAssistant discovery configuration. Defaults to {}. """ + EntityData.__init__(self, entity, key, customPayload) self.callbackFunction = callbackFunction - self.connectedEntitySensorKey = connectedEntitySensorKey + self.connectedEntitySensorKeys = connectedEntitySensorKeys if isinstance( + connectedEntitySensorKeys, list) else [connectedEntitySensorKeys] - def SupportsState(self): - return self.connectedEntitySensorKey is not None + def SupportsState(self) -> bool: + """ True if this command supports state (has a connected sensors) """ + return bool(self.connectedEntitySensorKeys) - def GetConnectedEntitySensor(self) -> EntitySensor: - """ Returns the entity sensor connected to this command, if this command supports state. - Otherwise returns None. """ - return self.GetEntity().GetEntitySensorByKey(self.connectedEntitySensorKey) + def GetConnectedEntitySensors(self) -> list[EntitySensor]: + """ Returns the entity sensors connected to this command. Returns empty list if none found. """ + return [self.GetEntity().GetEntitySensorByKey(key) for key in self.connectedEntitySensorKeys] def CallCallback(self, message): """ Safely run callback for this command, passing the message (a paho.mqtt.client.MQTTMessage). diff --git a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py index c3182bbcd..353d78f12 100644 --- a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py +++ b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py @@ -39,7 +39,6 @@ CONFIGURATION_SEND_LOOP_SKIP_NUMBER = 10 -EXTERNAL_ENTITY_DATA_CONFIGURATION_FILE_FILENAME = "entities.yaml" LWT_TOPIC_SUFFIX = "LWT" LWT_PAYLOAD_ONLINE = "ONLINE" @@ -47,6 +46,12 @@ PAYLOAD_ON = consts.STATE_ON PAYLOAD_OFF = consts.STATE_OFF +# Entity configuration file for HAWH: +EXTERNAL_ENTITY_DATA_CONFIGURATION_FILE_FILENAME = "entities.yaml" +# Set HA entity type, e.g. number, light, switch: +ENTITY_CONFIG_CUSTOM_TYPE_KEY = "custom_type" +# Custom topic keys for discovery. Use list for multiple topics: +ENTITY_CONFIG_CUSTOM_TOPIC_SUFFIX = "_key" class HomeAssistantEntityBase(LogObject): """ Base class for all entities in HomeAssistantWarehouse """ @@ -76,10 +81,7 @@ def __init__(self, self.GetEntityDataCustomConfigurations(self.name) # Get data type: - if "custom_type" in self.discovery_payload: - self.data_type = self.discovery_payload.pop("custom_type") - else: - self.data_type = "" + self.data_type = self.discovery_payload.pop(ENTITY_CONFIG_CUSTOM_TYPE_KEY, "") # Set name: self.SetDiscoveryPayloadName() @@ -125,8 +127,16 @@ def AddTopic(self, topic_name: str, topic_path: str = ""): # Add as an attribute: setattr(self, topic_name, topic_path) - # Add to the discovery payload: - self.discovery_payload[topic_name] = topic_path + + # Check for custom topic: + discovery_keys = self.discovery_payload.pop(topic_name + ENTITY_CONFIG_CUSTOM_TOPIC_SUFFIX, topic_name) + if not isinstance(discovery_keys, list): + discovery_keys = [discovery_keys] + + # Add to discovery payload: + for discovery_key in discovery_keys: + self.discovery_payload[discovery_key] = topic_path + def SendTopicData(self, topic, data) -> None: self.wh.client.SendTopicData(topic, data) @@ -181,7 +191,7 @@ def __init__(self, entityData: EntityData, wh: "HomeAssistantWarehouse") -> None self.default_topics = { "availability_topic": self.wh.MakeValuesTopic(LWT_TOPIC_SUFFIX), "state_topic": self.MakeEntityDataTopic(self.entityData), - "json_attributes_topic": self.MakeEntityDataExtraAttributesTopic(self.entityData), + "json_attributes_topic": self.MakeEntityDataTopic(self.entityData, TOPIC_DATA_EXTRA_ATTRIBUTES_SUFFIX), "command_topic": self.MakeEntityDataTopic(self.entityData) } @@ -214,14 +224,10 @@ def SetDiscoveryPayloadName(self) -> None: return super().SetDiscoveryPayloadName() - def MakeEntityDataTopic(self, entityData: EntityData) -> str: + def MakeEntityDataTopic(self, entityData: EntityData, suffix:str = "") -> str: """ Uses MakeValuesTopic but receives an EntityData to manage itself its id""" - return self.wh.MakeValuesTopic(entityData.GetId()) + return self.wh.MakeValuesTopic(entityData.GetId() + suffix) - def MakeEntityDataExtraAttributesTopic(self, entityData: EntityData) -> str: - """ Uses MakeValuesTopic but receives an EntityData to manage itself its id, appending a suffix to distinguish - the extra attrbiutes from the original value """ - return self.wh.MakeValuesTopic(entityData.GetId() + TOPIC_DATA_EXTRA_ATTRIBUTES_SUFFIX) class HomeAssistantSensor(HomeAssistantEntity): @@ -254,11 +260,13 @@ def SendValues(self, callback_value:str|None= None): """ if self.entitySensor.HasValue(): - if callback_value is not None: - sensor_value = callback_value + if callback_value is None: + value = self.entitySensor.GetValue() else: - sensor_value = ValueFormatter.FormatValue( - self.entitySensor.GetValue(), + value = callback_value + + sensor_value = ValueFormatter.FormatValue( + value, self.entitySensor.GetValueFormatterOptions(), INCLUDE_UNITS_IN_SENSORS) @@ -283,47 +291,44 @@ def __init__(self, entityData: EntityCommand, wh: "HomeAssistantWarehouse") -> N self.entityCommand = entityData - self.AddTopic("availability_topic") self.AddTopic("command_topic") - self.connected_sensor = self.GetConnectedSensor() + self.connected_sensors = self.GetConnectedSensors() - if self.connected_sensor: + if self.connected_sensors: self.SetDefaultDataType("switch") - # Get discovery payload from connected sensor? - for payload_key in self.connected_sensor.discovery_payload: - if payload_key not in self.discovery_payload: - self.discovery_payload[payload_key] = self.connected_sensor.discovery_payload[payload_key] + # Get discovery payload from connected sensors + for sensor in self.connected_sensors: + for payload_key in sensor.discovery_payload: + if payload_key not in self.discovery_payload: + self.discovery_payload[payload_key] = sensor.discovery_payload[payload_key] else: # Button as default data type: self.SetDefaultDataType("button") self.command_callback = self.GenerateCommandCallback() - def GetConnectedSensor(self) -> HomeAssistantSensor | None: - """ Get the connected sensor of this command """ - if self.entityCommand.SupportsState(): - return HomeAssistantSensor( - entityData=self.entityCommand.GetConnectedEntitySensor(), - wh=self.wh) - else: - return None + + def GetConnectedSensors(self) -> list[HomeAssistantSensor]: + """ Get the connected sensors of this command """ + return [HomeAssistantSensor(entityData=sensor, wh=self.wh) + for sensor in self.entityCommand.GetConnectedEntitySensors()] + def GenerateCommandCallback(self) -> Callable: """ Generate the callback function """ def CommandCallback(message): status = self.entityCommand.CallCallback(message) if status and self.wh.client.IsConnected(): - if self.connected_sensor: + if self.connected_sensors: # Only set value if it was already set, to exclude optimistic switches - if self.connected_sensor.entitySensor.HasValue(): - self.Log(self.LOG_DEBUG, "Switch callback: sending state to " + - self.connected_sensor.state_topic) - self.connected_sensor.SendValues(callback_value = message.payload.decode('utf-8')) + for sensor in self.connected_sensors: + if sensor.entitySensor.HasValue(): + sensor.SendValues(callback_value = message.payload.decode('utf-8')) - # Optimistic switches with extra attributes: - elif self.connected_sensor.supports_extra_attributes: - self.connected_sensor.SendExtraAttributes() + # Optimistic switches with extra attributes: + elif sensor.supports_extra_attributes: + sensor.SendExtraAttributes() return CommandCallback @@ -387,7 +392,7 @@ def Start(self): super().Start() # Then run other inits (start the Loop method for example) def CollectEntityData(self) -> None: - """ Collect entities and save them ass hass entities """ + """ Collect entities and save them as hass entities """ # Add the Lwt sensor: self.homeAssistantEntities["sensors"].append(LwtSensor(self)) @@ -399,9 +404,8 @@ def CollectEntityData(self) -> None: # It's a command: if isinstance(entityData, EntityCommand): hasscommand = HomeAssistantCommand(entityData, self) - if hasscommand.connected_sensor: - self.homeAssistantEntities["connected_sensors"].append( - hasscommand.connected_sensor) + if hasscommand.connected_sensors: + self.homeAssistantEntities["connected_sensors"].extend(hasscommand.connected_sensors) self.homeAssistantEntities["commands"].append(hasscommand) # It's a sensor: diff --git a/pyproject.toml b/pyproject.toml index cec873bb7..cf8540444 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "IoTuring" version = "2024.6.1" -description = "Simple and powerful cross-platform script to control your pc and share statistics using communication protocols like MQTT and home control hubs like HomeAssistant." +description = "Your Windows, Linux, macOS computer as MQTT and HomeAssistant integration." readme = "README.md" requires-python = ">=3.8" license = {file = "COPYING"} -keywords = ["iot","mqtt","monitor"] +keywords = ["iot","mqtt","monitor","homeassistant"] authors = [ {name = "richibrics", email = "riccardo.briccola.dev@gmail.com"}, {name = "infeeeee", email = "gyetpet@mailbox.org"}