Skip to content

Commit

Permalink
Support multiple connected sensors
Browse files Browse the repository at this point in the history
  • Loading branch information
infeeeee committed Sep 7, 2024
1 parent df3f3a6 commit 461af29
Show file tree
Hide file tree
Showing 4 changed files with 85 additions and 66 deletions.
14 changes: 9 additions & 5 deletions IoTuring/Entity/Entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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()
Expand Down
39 changes: 25 additions & 14 deletions IoTuring/Entity/EntityData.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,19 @@

CONFIGURATION_SEND_LOOP_SKIP_NUMBER = 10

EXTERNAL_ENTITY_DATA_CONFIGURATION_FILE_FILENAME = "entities.yaml"

LWT_TOPIC_SUFFIX = "LWT"
LWT_PAYLOAD_ONLINE = "ONLINE"
LWT_PAYLOAD_OFFLINE = "OFFLINE"
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 """
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)

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

Expand Down Expand Up @@ -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))
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = "[email protected]"},
{name = "infeeeee", email = "[email protected]"}
Expand Down

0 comments on commit 461af29

Please sign in to comment.