From 97585982bd5d10f78bbc8b54084ed728f7918a4b Mon Sep 17 00:00:00 2001 From: Riccardo Briccola Date: Mon, 12 Feb 2024 21:09:32 +0100 Subject: [PATCH 01/13] Remove version sensor --- IoTuring/Entity/Deployments/AppInfo/AppInfo.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py index bd58feee6..9041c779a 100644 --- a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py +++ b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py @@ -4,12 +4,14 @@ from IoTuring.MyApp.App import App KEY_NAME = 'name' -KEY_VERSION = 'version' KEY_UPDATE = 'update' PYPI_URL = 'https://pypi.org/pypi/ioturing/json' GET_UPDATE_ERROR_MESSAGE = "Error while checking, try to update to solve this problem. Alert the developers if the problem persists." +EXTRA_ATTRIBUTE_NAME_VERSION = 'Version' +EXTRA_ATTRIBUTE_UPDATE_CURRENT_VERSION = 'Current version' + EXTRA_ATTRIBUTE_UPDATE_LATEST = 'Latest version' EXTRA_ATTRIBUTE_UPDATE_ERROR = 'Check error' @@ -17,12 +19,12 @@ class AppInfo(Entity): NAME = "AppInfo" def Initialize(self): - self.RegisterEntitySensor(EntitySensor(self, KEY_NAME)) - self.RegisterEntitySensor(EntitySensor(self, KEY_VERSION)) + self.RegisterEntitySensor(EntitySensor(self, KEY_NAME, supportsExtraAttributes=True)) self.RegisterEntitySensor(EntitySensor(self, KEY_UPDATE, supportsExtraAttributes=True)) self.SetEntitySensorValue(KEY_NAME, App.getName()) - self.SetEntitySensorValue(KEY_VERSION, App.getVersion()) + self.SetEntitySensorExtraAttribute(KEY_NAME, EXTRA_ATTRIBUTE_NAME_VERSION, App.getVersion()) + self.SetEntitySensorExtraAttribute(KEY_UPDATE, EXTRA_ATTRIBUTE_UPDATE_CURRENT_VERSION, App.getVersion()) self.SetUpdateTimeout(600) def Update(self): From 2b58cfa9db36696d6bb56e4264b7d7240e80afb1 Mon Sep 17 00:00:00 2001 From: Riccardo Briccola Date: Sat, 9 Mar 2024 10:40:26 +0100 Subject: [PATCH 02/13] secondary sensor for command --- .../Entity/Deployments/AppInfo/AppInfo.py | 31 +++++++++---------- IoTuring/Entity/Entity.py | 6 ++-- IoTuring/Entity/EntityData.py | 24 +++++++++----- .../HomeAssistantWarehouse.py | 2 +- .../HomeAssistantWarehouse/entities.yaml | 9 ++---- 5 files changed, 38 insertions(+), 34 deletions(-) diff --git a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py index 9041c779a..7bb5762a6 100644 --- a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py +++ b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py @@ -1,18 +1,17 @@ import requests from IoTuring.Entity.Entity import Entity -from IoTuring.Entity.EntityData import EntitySensor +from IoTuring.Entity.EntityData import EntityCommand, EntitySensor from IoTuring.MyApp.App import App KEY_NAME = 'name' +KEY_CURRENT_VERSION = 'current_version' +KEY_LATEST_VERSION = 'latest_version' KEY_UPDATE = 'update' + PYPI_URL = 'https://pypi.org/pypi/ioturing/json' GET_UPDATE_ERROR_MESSAGE = "Error while checking, try to update to solve this problem. Alert the developers if the problem persists." -EXTRA_ATTRIBUTE_NAME_VERSION = 'Version' -EXTRA_ATTRIBUTE_UPDATE_CURRENT_VERSION = 'Current version' - -EXTRA_ATTRIBUTE_UPDATE_LATEST = 'Latest version' EXTRA_ATTRIBUTE_UPDATE_ERROR = 'Check error' class AppInfo(Entity): @@ -20,32 +19,30 @@ class AppInfo(Entity): def Initialize(self): self.RegisterEntitySensor(EntitySensor(self, KEY_NAME, supportsExtraAttributes=True)) - self.RegisterEntitySensor(EntitySensor(self, KEY_UPDATE, supportsExtraAttributes=True)) + self.RegisterEntitySensor(EntitySensor(self, KEY_CURRENT_VERSION)) + self.RegisterEntitySensor(EntitySensor(self, KEY_LATEST_VERSION, supportsExtraAttributes=True)) + self.RegisterEntityCommand(EntityCommand(self, KEY_UPDATE, self.InstallUpdate, KEY_CURRENT_VERSION, [KEY_LATEST_VERSION])) self.SetEntitySensorValue(KEY_NAME, App.getName()) - self.SetEntitySensorExtraAttribute(KEY_NAME, EXTRA_ATTRIBUTE_NAME_VERSION, App.getVersion()) - self.SetEntitySensorExtraAttribute(KEY_UPDATE, EXTRA_ATTRIBUTE_UPDATE_CURRENT_VERSION, App.getVersion()) + self.SetEntitySensorValue(KEY_CURRENT_VERSION, App.getVersion()) self.SetUpdateTimeout(600) + def InstallUpdate(self, message): + raise NotImplementedError("InstallUpdate not implemented") + def Update(self): # VERSION UPDATE CHECK try: new_version = self.GetUpdateInformation() if not new_version: # signal no update and current version (as its the latest) - self.SetEntitySensorValue( - KEY_UPDATE, "False") - self.SetEntitySensorExtraAttribute(KEY_UPDATE, EXTRA_ATTRIBUTE_UPDATE_LATEST, App.getVersion()) + self.SetEntitySensorValue(KEY_LATEST_VERSION, App.getVersion()) else: # signal update and latest version - self.SetEntitySensorValue( - KEY_UPDATE, "True") - self.SetEntitySensorExtraAttribute(KEY_UPDATE, EXTRA_ATTRIBUTE_UPDATE_LATEST, new_version) + self.SetEntitySensorValue(KEY_LATEST_VERSION, new_version) except Exception as e: # connection error or pypi name changed or something else - self.SetEntitySensorValue( - KEY_UPDATE, False) # add extra attribute to show error message - self.SetEntitySensorExtraAttribute(KEY_UPDATE, EXTRA_ATTRIBUTE_UPDATE_ERROR, GET_UPDATE_ERROR_MESSAGE) + self.SetEntitySensorExtraAttribute(KEY_LATEST_VERSION, EXTRA_ATTRIBUTE_UPDATE_ERROR, GET_UPDATE_ERROR_MESSAGE) def GetUpdateInformation(self): diff --git a/IoTuring/Entity/Entity.py b/IoTuring/Entity/Entity.py index 1ee44d46d..8eec89c2a 100644 --- a/IoTuring/Entity/Entity.py +++ b/IoTuring/Entity/Entity.py @@ -122,10 +122,12 @@ def GetAllEntityData(self) -> list: 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() + """ safe - Return All EntityCommands and EntitySensors without connected sensors """ + connected_sensors = [command.GetConnectedPrimaryEntitySensor() for command in self.entityCommands if command.SupportsState()] + connected_sensors += [command.GetConnectedSecondaryEntitySensors() + for command in self.entityCommands] 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..fae355260 100644 --- a/IoTuring/Entity/EntityData.py +++ b/IoTuring/Entity/EntityData.py @@ -118,23 +118,31 @@ def SetExtraAttribute(self, attribute_name, attribute_value, valueFormatterOptio class EntityCommand(EntityData): def __init__(self, entity, key, callbackFunction, - connectedEntitySensorKey=None, customPayload={}): + primaryConnectedEntitySensorKey=None, secondaryConnectedEntitySensorKeys=[], 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 + Better to register the sensor before this command to avoid unexpected behaviours. + CustomPayload overrides HomeAssistant discovery configuration. + Secondary connected sensors can be used for additional data of the commands. """ EntityData.__init__(self, entity, key, customPayload) self.callbackFunction = callbackFunction - self.connectedEntitySensorKey = connectedEntitySensorKey + self.primaryConnectedEntitySensorKey = primaryConnectedEntitySensorKey + self.secondaryConnectedEntitySensorKeys = secondaryConnectedEntitySensorKeys def SupportsState(self): - return self.connectedEntitySensorKey is not None + """ True if this command supports state (has a primary connected sensor) """ + return self.primaryConnectedEntitySensorKey is not None - def GetConnectedEntitySensor(self) -> EntitySensor: - """ Returns the entity sensor connected to this command, if this command supports state. + def GetConnectedPrimaryEntitySensor(self) -> EntitySensor: + """ Returns the entity sensor connected to this command as primary sensor, if this command supports state. Otherwise returns None. """ - return self.GetEntity().GetEntitySensorByKey(self.connectedEntitySensorKey) + return self.GetEntity().GetEntitySensorByKey(self.primaryConnectedEntitySensorKey) + + def GetConnectedSecondaryEntitySensors(self) -> list[EntitySensor]: + """ Returns the entity sensors connected to this command as secondary sensors. + If none, returns an empty list. """ + return [self.GetEntity().GetEntitySensorByKey(key) for key in self.secondaryConnectedEntitySensorKeys] 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 5e34bbaa6..6171a1749 100644 --- a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py +++ b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py @@ -289,7 +289,7 @@ def GetConnectedSensor(self) -> HomeAssistantSensor | None: """ Get the connected sensor of this command """ if self.entityCommand.SupportsState(): return HomeAssistantSensor( - entityData=self.entityCommand.GetConnectedEntitySensor(), + entityData=self.entityCommand.GetConnectedPrimaryEntitySensor(), wh=self.wh) else: return None diff --git a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml index c5afe11e0..b3b4cbf7e 100644 --- a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml +++ b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml @@ -66,14 +66,11 @@ Time: Monitor: icon: mdi:monitor-shimmer custom_type: switch -AppInfo - update: - custom_type: binary_sensor - icon: mdi:package-up - device_class: update - payload_on: "True" - payload_off: "False" AppInfo: icon: mdi:information-outline +AppInfo - update: + custom_type: update + device_class: firmware Temperature: icon: mdi:thermometer-lines unit_of_measurement: °C From d632c1fb27768b94d8d55ff2ef7ea67d73fda0e3 Mon Sep 17 00:00:00 2001 From: Riccardo Briccola Date: Sat, 9 Mar 2024 10:47:30 +0100 Subject: [PATCH 03/13] Reversed some edits --- .../Entity/Deployments/AppInfo/AppInfo.py | 13 +++++----- IoTuring/Entity/Entity.py | 6 ++--- IoTuring/Entity/EntityData.py | 24 +++++++------------ .../HomeAssistantWarehouse.py | 2 +- 4 files changed, 17 insertions(+), 28 deletions(-) diff --git a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py index 7bb5762a6..2aa208016 100644 --- a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py +++ b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py @@ -5,13 +5,13 @@ KEY_NAME = 'name' KEY_CURRENT_VERSION = 'current_version' -KEY_LATEST_VERSION = 'latest_version' KEY_UPDATE = 'update' PYPI_URL = 'https://pypi.org/pypi/ioturing/json' GET_UPDATE_ERROR_MESSAGE = "Error while checking, try to update to solve this problem. Alert the developers if the problem persists." +EXTRA_ATTRIBUTE_LATEST_VERSION = 'latest_version' EXTRA_ATTRIBUTE_UPDATE_ERROR = 'Check error' class AppInfo(Entity): @@ -19,9 +19,8 @@ class AppInfo(Entity): def Initialize(self): self.RegisterEntitySensor(EntitySensor(self, KEY_NAME, supportsExtraAttributes=True)) - self.RegisterEntitySensor(EntitySensor(self, KEY_CURRENT_VERSION)) - self.RegisterEntitySensor(EntitySensor(self, KEY_LATEST_VERSION, supportsExtraAttributes=True)) - self.RegisterEntityCommand(EntityCommand(self, KEY_UPDATE, self.InstallUpdate, KEY_CURRENT_VERSION, [KEY_LATEST_VERSION])) + self.RegisterEntitySensor(EntitySensor(self, KEY_CURRENT_VERSION, supportsExtraAttributes=True)) + self.RegisterEntityCommand(EntityCommand(self, KEY_UPDATE, self.InstallUpdate, KEY_CURRENT_VERSION)) self.SetEntitySensorValue(KEY_NAME, App.getName()) self.SetEntitySensorValue(KEY_CURRENT_VERSION, App.getVersion()) @@ -36,13 +35,13 @@ def Update(self): new_version = self.GetUpdateInformation() if not new_version: # signal no update and current version (as its the latest) - self.SetEntitySensorValue(KEY_LATEST_VERSION, App.getVersion()) + self.SetEntitySensorExtraAttribute(KEY_CURRENT_VERSION,EXTRA_ATTRIBUTE_LATEST_VERSION, App.getVersion()) else: # signal update and latest version - self.SetEntitySensorValue(KEY_LATEST_VERSION, new_version) + self.SetEntitySensorExtraAttribute(KEY_CURRENT_VERSION,EXTRA_ATTRIBUTE_LATEST_VERSION, new_version) except Exception as e: # connection error or pypi name changed or something else # add extra attribute to show error message - self.SetEntitySensorExtraAttribute(KEY_LATEST_VERSION, EXTRA_ATTRIBUTE_UPDATE_ERROR, GET_UPDATE_ERROR_MESSAGE) + self.SetEntitySensorExtraAttribute(KEY_CURRENT_VERSION, EXTRA_ATTRIBUTE_UPDATE_ERROR, GET_UPDATE_ERROR_MESSAGE) def GetUpdateInformation(self): diff --git a/IoTuring/Entity/Entity.py b/IoTuring/Entity/Entity.py index 8eec89c2a..c89e5d29a 100644 --- a/IoTuring/Entity/Entity.py +++ b/IoTuring/Entity/Entity.py @@ -51,7 +51,7 @@ def CallUpdate(self): # Call the Update method safely except Exception as exc: # TODO I need an exception manager self.Log(self.LOG_ERROR, 'Error occured during update: ' + str(exc)) - #  self.entityManager.UnloadEntity(self) # TODO Think how to improve this + # self.entityManager.UnloadEntity(self) # TODO Think how to improve this def Update(self): """ Must be implemented in sub-classes """ @@ -123,11 +123,9 @@ def GetAllEntityData(self) -> list: def GetAllUnconnectedEntityData(self) -> list[EntityData]: """ safe - Return All EntityCommands and EntitySensors without connected sensors """ - connected_sensors = [command.GetConnectedPrimaryEntitySensor() + connected_sensors = [command.GetConnectedEntitySensor() for command in self.entityCommands if command.SupportsState()] - connected_sensors += [command.GetConnectedSecondaryEntitySensors() - for command in self.entityCommands] 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 fae355260..701ebae59 100644 --- a/IoTuring/Entity/EntityData.py +++ b/IoTuring/Entity/EntityData.py @@ -118,31 +118,23 @@ def SetExtraAttribute(self, attribute_name, attribute_value, valueFormatterOptio class EntityCommand(EntityData): def __init__(self, entity, key, callbackFunction, - primaryConnectedEntitySensorKey=None, secondaryConnectedEntitySensorKeys=[], customPayload={}): + 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 avoid unexpected behaviours. - CustomPayload overrides HomeAssistant discovery configuration. - Secondary connected sensors can be used for additional data of the commands. + Better to register the sensor before this command to avoud unexpected behaviours. + CustomPayload overrides HomeAssistant discovery configuration """ EntityData.__init__(self, entity, key, customPayload) self.callbackFunction = callbackFunction - self.primaryConnectedEntitySensorKey = primaryConnectedEntitySensorKey - self.secondaryConnectedEntitySensorKeys = secondaryConnectedEntitySensorKeys + self.connectedEntitySensorKey = connectedEntitySensorKey def SupportsState(self): - """ True if this command supports state (has a primary connected sensor) """ - return self.primaryConnectedEntitySensorKey is not None + return self.connectedEntitySensorKey is not None - def GetConnectedPrimaryEntitySensor(self) -> EntitySensor: - """ Returns the entity sensor connected to this command as primary sensor, if this command supports state. + 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.primaryConnectedEntitySensorKey) - - def GetConnectedSecondaryEntitySensors(self) -> list[EntitySensor]: - """ Returns the entity sensors connected to this command as secondary sensors. - If none, returns an empty list. """ - return [self.GetEntity().GetEntitySensorByKey(key) for key in self.secondaryConnectedEntitySensorKeys] + return self.GetEntity().GetEntitySensorByKey(self.connectedEntitySensorKey) 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 6171a1749..5e34bbaa6 100644 --- a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py +++ b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py @@ -289,7 +289,7 @@ def GetConnectedSensor(self) -> HomeAssistantSensor | None: """ Get the connected sensor of this command """ if self.entityCommand.SupportsState(): return HomeAssistantSensor( - entityData=self.entityCommand.GetConnectedPrimaryEntitySensor(), + entityData=self.entityCommand.GetConnectedEntitySensor(), wh=self.wh) else: return None From 20836e95dac3142d7fba1d1c4ede8e65d58bdeae Mon Sep 17 00:00:00 2001 From: Riccardo Briccola Date: Sat, 9 Mar 2024 11:01:36 +0100 Subject: [PATCH 04/13] Latest version as extra attribute --- IoTuring/Entity/Deployments/AppInfo/AppInfo.py | 10 ++++++++-- .../Deployments/HomeAssistantWarehouse/entities.yaml | 2 ++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py index 2aa208016..72aeed4bb 100644 --- a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py +++ b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py @@ -18,9 +18,9 @@ class AppInfo(Entity): NAME = "AppInfo" def Initialize(self): - self.RegisterEntitySensor(EntitySensor(self, KEY_NAME, supportsExtraAttributes=True)) + self.RegisterEntitySensor(EntitySensor(self, KEY_NAME)) self.RegisterEntitySensor(EntitySensor(self, KEY_CURRENT_VERSION, supportsExtraAttributes=True)) - self.RegisterEntityCommand(EntityCommand(self, KEY_UPDATE, self.InstallUpdate, KEY_CURRENT_VERSION)) + self.RegisterEntityCommand(EntityCommand(self, KEY_UPDATE, self.InstallUpdate, KEY_CURRENT_VERSION, self.UpdateCustomDiscoveryPayload())) self.SetEntitySensorValue(KEY_NAME, App.getName()) self.SetEntitySensorValue(KEY_CURRENT_VERSION, App.getVersion()) @@ -68,6 +68,12 @@ def GetUpdateInformation(self): else: raise UpdateCheckException() + def UpdateCustomDiscoveryPayload(self): + return { + "name": App.getName(), + "latest_version_topic": self.GetEntitySensorByKey(KEY_CURRENT_VERSION).Get + } + def versionToInt(version: str): return int(''.join([i for i in version if i.isdigit()])) diff --git a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml index b3b4cbf7e..eb23fb234 100644 --- a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml +++ b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml @@ -71,6 +71,8 @@ AppInfo: AppInfo - update: custom_type: update device_class: firmware + latest_version_topic: IoTuring/LenovoYogaHomeAssistant/Entity/AppInfo/current_version_extraattributes + latest_version_template: value_json['update'].latest_version Temperature: icon: mdi:thermometer-lines unit_of_measurement: °C From 04cea9da0d0e0a9ee0455128ee34453a1b4d2c92 Mon Sep 17 00:00:00 2001 From: Riccardo Briccola Date: Sat, 9 Mar 2024 13:29:06 +0100 Subject: [PATCH 05/13] AppInfo Update --- .../Entity/Deployments/AppInfo/AppInfo.py | 19 ++--- IoTuring/Entity/Entity.py | 7 +- IoTuring/Entity/EntityData.py | 24 ++++-- .../HomeAssistantWarehouse.py | 85 +++++++++++++++---- .../HomeAssistantWarehouse/entities.yaml | 10 ++- 5 files changed, 107 insertions(+), 38 deletions(-) diff --git a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py index 72aeed4bb..d1bd34432 100644 --- a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py +++ b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py @@ -5,22 +5,23 @@ KEY_NAME = 'name' KEY_CURRENT_VERSION = 'current_version' +KEY_LATEST_VERSION = 'latest_version' KEY_UPDATE = 'update' PYPI_URL = 'https://pypi.org/pypi/ioturing/json' GET_UPDATE_ERROR_MESSAGE = "Error while checking, try to update to solve this problem. Alert the developers if the problem persists." -EXTRA_ATTRIBUTE_LATEST_VERSION = 'latest_version' EXTRA_ATTRIBUTE_UPDATE_ERROR = 'Check error' class AppInfo(Entity): NAME = "AppInfo" def Initialize(self): - self.RegisterEntitySensor(EntitySensor(self, KEY_NAME)) - self.RegisterEntitySensor(EntitySensor(self, KEY_CURRENT_VERSION, supportsExtraAttributes=True)) - self.RegisterEntityCommand(EntityCommand(self, KEY_UPDATE, self.InstallUpdate, KEY_CURRENT_VERSION, self.UpdateCustomDiscoveryPayload())) + self.RegisterEntitySensor(EntitySensor(self, KEY_NAME, supportsExtraAttributes=True)) + self.RegisterEntitySensor(EntitySensor(self, KEY_CURRENT_VERSION)) + self.RegisterEntitySensor(EntitySensor(self, KEY_LATEST_VERSION, supportsExtraAttributes=True)) + self.RegisterEntityCommand(EntityCommand(self, KEY_UPDATE, self.InstallUpdate, KEY_CURRENT_VERSION, [KEY_LATEST_VERSION], self.UpdateCommandCustomPayload())) self.SetEntitySensorValue(KEY_NAME, App.getName()) self.SetEntitySensorValue(KEY_CURRENT_VERSION, App.getVersion()) @@ -34,10 +35,7 @@ def Update(self): try: new_version = self.GetUpdateInformation() - if not new_version: # signal no update and current version (as its the latest) - self.SetEntitySensorExtraAttribute(KEY_CURRENT_VERSION,EXTRA_ATTRIBUTE_LATEST_VERSION, App.getVersion()) - else: # signal update and latest version - self.SetEntitySensorExtraAttribute(KEY_CURRENT_VERSION,EXTRA_ATTRIBUTE_LATEST_VERSION, new_version) + self.SetEntitySensorValue(KEY_LATEST_VERSION, "2025.1.1") except Exception as e: # connection error or pypi name changed or something else # add extra attribute to show error message @@ -68,10 +66,11 @@ def GetUpdateInformation(self): else: raise UpdateCheckException() - def UpdateCustomDiscoveryPayload(self): + def UpdateCommandCustomPayload(self): return { + "title": App.getName(), "name": App.getName(), - "latest_version_topic": self.GetEntitySensorByKey(KEY_CURRENT_VERSION).Get + "release_url": App.getUrlReleases() } def versionToInt(version: str): diff --git a/IoTuring/Entity/Entity.py b/IoTuring/Entity/Entity.py index c89e5d29a..6ccf84432 100644 --- a/IoTuring/Entity/Entity.py +++ b/IoTuring/Entity/Entity.py @@ -51,7 +51,7 @@ def CallUpdate(self): # Call the Update method safely except Exception as exc: # TODO I need an exception manager self.Log(self.LOG_ERROR, 'Error occured during update: ' + str(exc)) - # self.entityManager.UnloadEntity(self) # TODO Think how to improve this + #  self.entityManager.UnloadEntity(self) # TODO Think how to improve this def Update(self): """ Must be implemented in sub-classes """ @@ -123,9 +123,12 @@ def GetAllEntityData(self) -> list: def GetAllUnconnectedEntityData(self) -> list[EntityData]: """ safe - Return All EntityCommands and EntitySensors without connected sensors """ - connected_sensors = [command.GetConnectedEntitySensor() + connected_sensors = [command.GetConnectedPrimaryEntitySensor() for command in self.entityCommands if command.SupportsState()] + connected_sensors += [secondary + for command in self.entityCommands + for secondary in command.GetConnectedSecondaryEntitySensors()] 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..fae355260 100644 --- a/IoTuring/Entity/EntityData.py +++ b/IoTuring/Entity/EntityData.py @@ -118,23 +118,31 @@ def SetExtraAttribute(self, attribute_name, attribute_value, valueFormatterOptio class EntityCommand(EntityData): def __init__(self, entity, key, callbackFunction, - connectedEntitySensorKey=None, customPayload={}): + primaryConnectedEntitySensorKey=None, secondaryConnectedEntitySensorKeys=[], 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 + Better to register the sensor before this command to avoid unexpected behaviours. + CustomPayload overrides HomeAssistant discovery configuration. + Secondary connected sensors can be used for additional data of the commands. """ EntityData.__init__(self, entity, key, customPayload) self.callbackFunction = callbackFunction - self.connectedEntitySensorKey = connectedEntitySensorKey + self.primaryConnectedEntitySensorKey = primaryConnectedEntitySensorKey + self.secondaryConnectedEntitySensorKeys = secondaryConnectedEntitySensorKeys def SupportsState(self): - return self.connectedEntitySensorKey is not None + """ True if this command supports state (has a primary connected sensor) """ + return self.primaryConnectedEntitySensorKey is not None - def GetConnectedEntitySensor(self) -> EntitySensor: - """ Returns the entity sensor connected to this command, if this command supports state. + def GetConnectedPrimaryEntitySensor(self) -> EntitySensor: + """ Returns the entity sensor connected to this command as primary sensor, if this command supports state. Otherwise returns None. """ - return self.GetEntity().GetEntitySensorByKey(self.connectedEntitySensorKey) + return self.GetEntity().GetEntitySensorByKey(self.primaryConnectedEntitySensorKey) + + def GetConnectedSecondaryEntitySensors(self) -> list[EntitySensor]: + """ Returns the entity sensors connected to this command as secondary sensors. + If none, returns an empty list. """ + return [self.GetEntity().GetEntitySensorByKey(key) for key in self.secondaryConnectedEntitySensorKeys] 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 5e34bbaa6..c35813f10 100644 --- a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py +++ b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py @@ -46,6 +46,7 @@ PAYLOAD_ON = consts.STATE_ON PAYLOAD_OFF = consts.STATE_OFF +SECONDARY_SENSOR_STATE_TOPIC_DISCOVERY_CONFIGURATION_KEY = "state_topic_key" class HomeAssistantEntityBase(LogObject): """ Base class for all entities in HomeAssistantWarehouse """ @@ -271,41 +272,58 @@ def __init__(self, entityData: EntityCommand, wh: "HomeAssistantWarehouse") -> N self.AddTopic("availability_topic") self.AddTopic("command_topic") - self.connected_sensor = self.GetConnectedSensor() + # Primary sensors are used to retrieve the state of the command and to use it as a switch if present + self.primary_connected_sensor = self.GetPrimaryConnectedSensor() + # Secondary sensors are used to provide additional information to the command and they will be configured + # in the command discovery payload in a custom topic specified in the yaml file (like for Update latest version) + self.secondary_connected_sensors = self.GetSecondaryConnectedSensors() - if self.connected_sensor: + if self.primary_connected_sensor: self.SetDefaultDataType("switch") - # Get discovery payload from connected sensor? - for payload_key in self.connected_sensor.discovery_payload: + # Get discovery payload from primary connected sensor + for payload_key in self.primary_connected_sensor.discovery_payload: if payload_key not in self.discovery_payload: - self.discovery_payload[payload_key] = self.connected_sensor.discovery_payload[payload_key] + self.discovery_payload[payload_key] = self.primary_connected_sensor.discovery_payload[payload_key] else: # Button as default data type: self.SetDefaultDataType("button") + + # Get the discovery payload from the secondary connected sensors + for secondary_sensor in self.secondary_connected_sensors: + for payload_key in secondary_sensor.discovery_payload: + if payload_key not in self.discovery_payload: + self.discovery_payload[payload_key] = secondary_sensor.discovery_payload[payload_key] self.command_callback = self.GenerateCommandCallback() - def GetConnectedSensor(self) -> HomeAssistantSensor | None: + def GetPrimaryConnectedSensor(self) -> HomeAssistantSensor | None: """ Get the connected sensor of this command """ if self.entityCommand.SupportsState(): return HomeAssistantSensor( - entityData=self.entityCommand.GetConnectedEntitySensor(), + entityData=self.entityCommand.GetConnectedPrimaryEntitySensor(), wh=self.wh) else: return None + def GetSecondaryConnectedSensors(self) -> list[HomeAssistantSensor]: + """ Get the connected sensor of this command """ + return [SecondarySensor( + entityData=entityData, + wh=self.wh) + for entityData in self.entityCommand.GetConnectedSecondaryEntitySensors()] + 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.primary_connected_sensor: # Only set value if it was already set, to exclude optimistic switches - if self.connected_sensor.entitySensor.HasValue(): + if self.primary_connected_sensor.entitySensor.HasValue(): self.Log(self.LOG_DEBUG, "Switch callback: sending state to " + - self.connected_sensor.state_topic) + self.primary_connected_sensor.state_topic) self.SendTopicData( - self.connected_sensor.state_topic, message.payload.decode('utf-8')) + self.primary_connected_sensor.state_topic, message.payload.decode('utf-8')) return CommandCallback @@ -329,6 +347,41 @@ def SendValues(self): self.SendTopicData(self.state_topic, LWT_PAYLOAD_ONLINE) +class SecondarySensor(HomeAssistantEntity): + # All secondary sensors won't have a discovery payload that will be sent alone + # but joined with the one of the command they are connected to. + # The state topic is set to a topic, but published in discovery payload not as state topic + # but with that MUST be present in the YAML file: look at the Update latest version sensor + def __init__(self, entityData: EntitySensor, wh: "HomeAssistantWarehouse") -> None: + super().__init__(entityData=entityData, wh=wh) + + self.entitySensor = entityData + + # Default data type: + self.SetDefaultDataType("sensor") + + self.AddTopic("state_topic") + + # The state topic to use is in the discovery payload as SECONDARY_SENSOR_STATE_TOPIC_DISCOVERY_CONFIGURATION_KEY + if not SECONDARY_SENSOR_STATE_TOPIC_DISCOVERY_CONFIGURATION_KEY in self.discovery_payload: + raise Exception(f"Secondary sensor {self.id} must define the discovery state topic key in entity data configuration") + + self.key_for_state_topic = self.discovery_payload.pop(SECONDARY_SENSOR_STATE_TOPIC_DISCOVERY_CONFIGURATION_KEY) + + self.discovery_payload[self.key_for_state_topic] = self.state_topic + + + def SendValues(self): + """ Send values of the sensor to the custom topic """ + if self.entitySensor.HasValue(): + sensor_value = ValueFormatter.FormatValue( + self.entitySensor.GetValue(), + self.entitySensor.GetValueFormatterOptions(), + INCLUDE_UNITS_IN_SENSORS) + + self.SendTopicData(self.state_topic, sensor_value) + + class HomeAssistantWarehouse(Warehouse): NAME = "HomeAssistant" @@ -368,7 +421,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)) @@ -376,13 +429,15 @@ def CollectEntityData(self) -> None: # Add real entities: for entity in self.GetEntities(): for entityData in entity.GetAllUnconnectedEntityData(): - # It's a command: if isinstance(entityData, EntityCommand): hasscommand = HomeAssistantCommand(entityData, self) - if hasscommand.connected_sensor: + if hasscommand.primary_connected_sensor: + self.homeAssistantEntities["connected_sensors"].append( + hasscommand.primary_connected_sensor) + for secondary in hasscommand.secondary_connected_sensors: self.homeAssistantEntities["connected_sensors"].append( - hasscommand.connected_sensor) + secondary) self.homeAssistantEntities["commands"].append(hasscommand) # It's a sensor: diff --git a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml index eb23fb234..f5163b87d 100644 --- a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml +++ b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml @@ -66,13 +66,17 @@ Time: Monitor: icon: mdi:monitor-shimmer custom_type: switch -AppInfo: +AppInfo - name: icon: mdi:information-outline AppInfo - update: custom_type: update device_class: firmware - latest_version_topic: IoTuring/LenovoYogaHomeAssistant/Entity/AppInfo/current_version_extraattributes - latest_version_template: value_json['update'].latest_version + payload_install: "" +AppInfo - latest_version: + # This sensor is set as secondary sensor of the update command + # A secondary sensor will not provide its value as state_topic in discovery of the command + # but in another key, so we specify that key here + state_topic_key: latest_version_topic Temperature: icon: mdi:thermometer-lines unit_of_measurement: °C From 0d889e8570c2187c3dc21395961d4bffae1a0a29 Mon Sep 17 00:00:00 2001 From: Riccardo Briccola Date: Sat, 9 Mar 2024 13:30:14 +0100 Subject: [PATCH 06/13] Restored version check --- IoTuring/Entity/Deployments/AppInfo/AppInfo.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py index d1bd34432..3d2e6d042 100644 --- a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py +++ b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py @@ -35,7 +35,10 @@ def Update(self): try: new_version = self.GetUpdateInformation() - self.SetEntitySensorValue(KEY_LATEST_VERSION, "2025.1.1") + if not new_version: # signal no update and current version (as its the latest) + self.SetEntitySensorValue(KEY_LATEST_VERSION, App.getVersion()) + else: # signal update and latest version + self.SetEntitySensorValue(KEY_LATEST_VERSION, new_version) except Exception as e: # connection error or pypi name changed or something else # add extra attribute to show error message From 27675e96aed30903b2f3ea1cd16d34bf31d079ae Mon Sep 17 00:00:00 2001 From: Riccardo Briccola Date: Sun, 10 Mar 2024 12:33:33 +0100 Subject: [PATCH 07/13] Fixed sensor extra attribute --- IoTuring/Entity/Deployments/AppInfo/AppInfo.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py index 3d2e6d042..b275ed791 100644 --- a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py +++ b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py @@ -19,8 +19,8 @@ class AppInfo(Entity): def Initialize(self): self.RegisterEntitySensor(EntitySensor(self, KEY_NAME, supportsExtraAttributes=True)) - self.RegisterEntitySensor(EntitySensor(self, KEY_CURRENT_VERSION)) - self.RegisterEntitySensor(EntitySensor(self, KEY_LATEST_VERSION, supportsExtraAttributes=True)) + self.RegisterEntitySensor(EntitySensor(self, KEY_CURRENT_VERSION, supportsExtraAttributes=True)) + self.RegisterEntitySensor(EntitySensor(self, KEY_LATEST_VERSION)) self.RegisterEntityCommand(EntityCommand(self, KEY_UPDATE, self.InstallUpdate, KEY_CURRENT_VERSION, [KEY_LATEST_VERSION], self.UpdateCommandCustomPayload())) self.SetEntitySensorValue(KEY_NAME, App.getName()) From 3d91ca9c2439363857354282709463a76dacd341 Mon Sep 17 00:00:00 2001 From: Riccardo Briccola Date: Sun, 10 Mar 2024 12:55:51 +0100 Subject: [PATCH 08/13] No remote install available message --- IoTuring/Entity/Deployments/AppInfo/AppInfo.py | 6 +++++- pyproject.toml | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py index b275ed791..fec3137d0 100644 --- a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py +++ b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py @@ -14,6 +14,8 @@ EXTRA_ATTRIBUTE_UPDATE_ERROR = 'Check error' +NO_REMOTE_INSTALL_AVAILABLE_MSG = "⚠️ Currently the Install process cannot be started from HomeAssistant. Please update it manually. ⚠️" + class AppInfo(Entity): NAME = "AppInfo" @@ -51,6 +53,7 @@ def GetUpdateInformation(self): Returns False if no update is available Otherwise returns the latest version (could be set as extra attribute) """ + return "2025.1.1" latest = "" res = requests.get(PYPI_URL) if res.status_code == 200: @@ -73,7 +76,8 @@ def UpdateCommandCustomPayload(self): return { "title": App.getName(), "name": App.getName(), - "release_url": App.getUrlReleases() + "release_url": App.getUrlReleases(), + "release_summary": App.getDescription() + "

" + NO_REMOTE_INSTALL_AVAILABLE_MSG, } def versionToInt(version: str): diff --git a/pyproject.toml b/pyproject.toml index de49fafd2..15c738984 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "IoTuring" version = "2024.2.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"} From 94f575caae43b7b752d22985a53a6f39c5b526e6 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Thu, 14 Mar 2024 22:08:27 +0100 Subject: [PATCH 09/13] Fix annotation --- IoTuring/Entity/EntityData.py | 3 ++- .../HomeAssistantWarehouse/HomeAssistantWarehouse.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/IoTuring/Entity/EntityData.py b/IoTuring/Entity/EntityData.py index fae355260..332baeabf 100644 --- a/IoTuring/Entity/EntityData.py +++ b/IoTuring/Entity/EntityData.py @@ -118,7 +118,8 @@ def SetExtraAttribute(self, attribute_name, attribute_value, valueFormatterOptio class EntityCommand(EntityData): def __init__(self, entity, key, callbackFunction, - primaryConnectedEntitySensorKey=None, secondaryConnectedEntitySensorKeys=[], customPayload={}): + primaryConnectedEntitySensorKey=None, + secondaryConnectedEntitySensorKeys=[], 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 avoid unexpected behaviours. diff --git a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py index c35813f10..c8db77d73 100644 --- a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py +++ b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py @@ -305,7 +305,7 @@ def GetPrimaryConnectedSensor(self) -> HomeAssistantSensor | None: else: return None - def GetSecondaryConnectedSensors(self) -> list[HomeAssistantSensor]: + def GetSecondaryConnectedSensors(self) -> list[SecondarySensor]: """ Get the connected sensor of this command """ return [SecondarySensor( entityData=entityData, From 0f425058e209dde925a59a88d6faa6ff06a557ce Mon Sep 17 00:00:00 2001 From: Riccardo Briccola Date: Thu, 14 Mar 2024 23:56:26 +0100 Subject: [PATCH 10/13] Remove description from release summary --- IoTuring/Entity/Deployments/AppInfo/AppInfo.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py index fec3137d0..774d6ea7b 100644 --- a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py +++ b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py @@ -14,7 +14,7 @@ EXTRA_ATTRIBUTE_UPDATE_ERROR = 'Check error' -NO_REMOTE_INSTALL_AVAILABLE_MSG = "⚠️ Currently the Install process cannot be started from HomeAssistant. Please update it manually. ⚠️" +NO_REMOTE_INSTALL_AVAILABLE_MSG = "⚠️ Currently the Install process cannot be started from HomeAssistant. Please update it manually. ⚠️" class AppInfo(Entity): NAME = "AppInfo" @@ -53,7 +53,6 @@ def GetUpdateInformation(self): Returns False if no update is available Otherwise returns the latest version (could be set as extra attribute) """ - return "2025.1.1" latest = "" res = requests.get(PYPI_URL) if res.status_code == 200: @@ -77,7 +76,7 @@ def UpdateCommandCustomPayload(self): "title": App.getName(), "name": App.getName(), "release_url": App.getUrlReleases(), - "release_summary": App.getDescription() + "

" + NO_REMOTE_INSTALL_AVAILABLE_MSG, + "release_summary": NO_REMOTE_INSTALL_AVAILABLE_MSG, } def versionToInt(version: str): From c9d57ef8efbcf808c06fa021f1f99abf54e8383f Mon Sep 17 00:00:00 2001 From: Riccardo Briccola Date: Thu, 14 Mar 2024 23:59:35 +0100 Subject: [PATCH 11/13] fixed bold --- IoTuring/Entity/Deployments/AppInfo/AppInfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py index 774d6ea7b..1dac39085 100644 --- a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py +++ b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py @@ -14,7 +14,7 @@ EXTRA_ATTRIBUTE_UPDATE_ERROR = 'Check error' -NO_REMOTE_INSTALL_AVAILABLE_MSG = "⚠️ Currently the Install process cannot be started from HomeAssistant. Please update it manually. ⚠️" +NO_REMOTE_INSTALL_AVAILABLE_MSG = "⚠️ Currently the Install process cannot be started from HomeAssistant. Please update it manually. ⚠️" class AppInfo(Entity): NAME = "AppInfo" From 8b16dac19984339eaf0cddfed43fd00f2e23238b Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sat, 30 Mar 2024 20:32:33 +0100 Subject: [PATCH 12/13] Merge Primary and Secondary connected sensors --- .../Entity/Deployments/AppInfo/AppInfo.py | 2 +- IoTuring/Entity/Entity.py | 15 ++- IoTuring/Entity/EntityData.py | 52 ++++---- .../HomeAssistantWarehouse.py | 121 +++++++----------- 4 files changed, 79 insertions(+), 111 deletions(-) diff --git a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py index 1dac39085..6272ec983 100644 --- a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py +++ b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py @@ -23,7 +23,7 @@ def Initialize(self): self.RegisterEntitySensor(EntitySensor(self, KEY_NAME, supportsExtraAttributes=True)) self.RegisterEntitySensor(EntitySensor(self, KEY_CURRENT_VERSION, supportsExtraAttributes=True)) self.RegisterEntitySensor(EntitySensor(self, KEY_LATEST_VERSION)) - self.RegisterEntityCommand(EntityCommand(self, KEY_UPDATE, self.InstallUpdate, KEY_CURRENT_VERSION, [KEY_LATEST_VERSION], self.UpdateCommandCustomPayload())) + self.RegisterEntityCommand(EntityCommand(self, KEY_UPDATE, self.InstallUpdate, [KEY_CURRENT_VERSION, KEY_LATEST_VERSION], self.UpdateCommandCustomPayload())) self.SetEntitySensorValue(KEY_NAME, App.getName()) self.SetEntitySensorValue(KEY_CURRENT_VERSION, App.getVersion()) diff --git a/IoTuring/Entity/Entity.py b/IoTuring/Entity/Entity.py index 6ccf84432..306d76c66 100644 --- a/IoTuring/Entity/Entity.py +++ b/IoTuring/Entity/Entity.py @@ -14,6 +14,9 @@ class Entity(ConfiguratorObject, LogObject): + entitySensors: list[EntitySensor] + entityCommands: list[EntityCommand] + def __init__(self, single_configuration: SingleConfiguration) -> None: super().__init__(single_configuration) @@ -121,14 +124,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]: + def GetAllUnconnectedEntityData(self) -> list: """ safe - Return All EntityCommands and EntitySensors without connected sensors """ - connected_sensors = [command.GetConnectedPrimaryEntitySensor() - for command in self.entityCommands - if command.SupportsState()] - connected_sensors += [secondary - for command in self.entityCommands - for secondary in command.GetConnectedSecondaryEntitySensors()] + 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 332baeabf..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,33 +117,35 @@ def SetExtraAttribute(self, attribute_name, attribute_value, valueFormatterOptio class EntityCommand(EntityData): - def __init__(self, entity, key, callbackFunction, - primaryConnectedEntitySensorKey=None, - secondaryConnectedEntitySensorKeys=[], 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 avoid unexpected behaviours. - CustomPayload overrides HomeAssistant discovery configuration. - Secondary connected sensors can be used for additional data of the commands. + 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.primaryConnectedEntitySensorKey = primaryConnectedEntitySensorKey - self.secondaryConnectedEntitySensorKeys = secondaryConnectedEntitySensorKeys - - def SupportsState(self): - """ True if this command supports state (has a primary connected sensor) """ - return self.primaryConnectedEntitySensorKey is not None - - def GetConnectedPrimaryEntitySensor(self) -> EntitySensor: - """ Returns the entity sensor connected to this command as primary sensor, if this command supports state. - Otherwise returns None. """ - return self.GetEntity().GetEntitySensorByKey(self.primaryConnectedEntitySensorKey) - - def GetConnectedSecondaryEntitySensors(self) -> list[EntitySensor]: - """ Returns the entity sensors connected to this command as secondary sensors. - If none, returns an empty list. """ - return [self.GetEntity().GetEntitySensorByKey(key) for key in self.secondaryConnectedEntitySensorKeys] + self.connectedEntitySensorKeys = connectedEntitySensorKeys if isinstance( + connectedEntitySensorKeys, list) else [connectedEntitySensorKeys] + + def SupportsState(self) -> bool: + """ True if this command supports state (has a connected sensors) """ + return bool(self.connectedEntitySensorKeys) + + 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 c8db77d73..572d677bd 100644 --- a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py +++ b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py @@ -48,6 +48,7 @@ SECONDARY_SENSOR_STATE_TOPIC_DISCOVERY_CONFIGURATION_KEY = "state_topic_key" + class HomeAssistantEntityBase(LogObject): """ Base class for all entities in HomeAssistantWarehouse """ @@ -241,6 +242,13 @@ def __init__(self, entityData: EntitySensor, wh: "HomeAssistantWarehouse") -> N if self.supports_extra_attributes: self.AddTopic("json_attributes_topic") + # Custom state topic: + if SECONDARY_SENSOR_STATE_TOPIC_DISCOVERY_CONFIGURATION_KEY in self.discovery_payload: + self.key_for_state_topic = self.discovery_payload.pop( + SECONDARY_SENSOR_STATE_TOPIC_DISCOVERY_CONFIGURATION_KEY) + + self.discovery_payload[self.key_for_state_topic] = self.state_topic + # Extra payload for sensors: self.discovery_payload['expire_after'] = 600 # TODO Improve @@ -272,58 +280,49 @@ def __init__(self, entityData: EntityCommand, wh: "HomeAssistantWarehouse") -> N self.AddTopic("availability_topic") self.AddTopic("command_topic") - # Primary sensors are used to retrieve the state of the command and to use it as a switch if present - self.primary_connected_sensor = self.GetPrimaryConnectedSensor() - # Secondary sensors are used to provide additional information to the command and they will be configured - # in the command discovery payload in a custom topic specified in the yaml file (like for Update latest version) - self.secondary_connected_sensors = self.GetSecondaryConnectedSensors() + self.connected_sensors = self.GetConnectedSensors() - if self.primary_connected_sensor: + if self.connected_sensors: self.SetDefaultDataType("switch") - # Get discovery payload from primary connected sensor - for payload_key in self.primary_connected_sensor.discovery_payload: - if payload_key not in self.discovery_payload: - self.discovery_payload[payload_key] = self.primary_connected_sensor.discovery_payload[payload_key] + # Get discovery payload from sensors + for connected_sensor in self.connected_sensors: + for payload_key, payload_value in connected_sensor.discovery_payload.items(): + if payload_key not in self.discovery_payload: + self.discovery_payload[payload_key] = payload_value + else: # Button as default data type: self.SetDefaultDataType("button") - - # Get the discovery payload from the secondary connected sensors - for secondary_sensor in self.secondary_connected_sensors: - for payload_key in secondary_sensor.discovery_payload: - if payload_key not in self.discovery_payload: - self.discovery_payload[payload_key] = secondary_sensor.discovery_payload[payload_key] self.command_callback = self.GenerateCommandCallback() - def GetPrimaryConnectedSensor(self) -> HomeAssistantSensor | None: + def GetConnectedSensors(self) -> list[HomeAssistantSensor]: """ Get the connected sensor of this command """ - if self.entityCommand.SupportsState(): - return HomeAssistantSensor( - entityData=self.entityCommand.GetConnectedPrimaryEntitySensor(), - wh=self.wh) - else: - return None - - def GetSecondaryConnectedSensors(self) -> list[SecondarySensor]: - """ Get the connected sensor of this command """ - return [SecondarySensor( - entityData=entityData, - wh=self.wh) - for entityData in self.entityCommand.GetConnectedSecondaryEntitySensors()] + 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.primary_connected_sensor: + + if self.connected_sensors: + # First sensor, update state from callback: + # Only set value if it was already set, to exclude optimistic switches - if self.primary_connected_sensor.entitySensor.HasValue(): + if self.connected_sensors[0].entitySensor.HasValue(): self.Log(self.LOG_DEBUG, "Switch callback: sending state to " + - self.primary_connected_sensor.state_topic) + self.connected_sensors[0].state_topic) self.SendTopicData( - self.primary_connected_sensor.state_topic, message.payload.decode('utf-8')) + self.connected_sensors[0].state_topic, message.payload.decode('utf-8')) + + if len(self.connected_sensors) > 1: + # Other sensors, full update: + self.entity.CallUpdate() + for connected_sensor in self.connected_sensors[1:]: + connected_sensor.SendValues() + return CommandCallback @@ -347,41 +346,6 @@ def SendValues(self): self.SendTopicData(self.state_topic, LWT_PAYLOAD_ONLINE) -class SecondarySensor(HomeAssistantEntity): - # All secondary sensors won't have a discovery payload that will be sent alone - # but joined with the one of the command they are connected to. - # The state topic is set to a topic, but published in discovery payload not as state topic - # but with that MUST be present in the YAML file: look at the Update latest version sensor - def __init__(self, entityData: EntitySensor, wh: "HomeAssistantWarehouse") -> None: - super().__init__(entityData=entityData, wh=wh) - - self.entitySensor = entityData - - # Default data type: - self.SetDefaultDataType("sensor") - - self.AddTopic("state_topic") - - # The state topic to use is in the discovery payload as SECONDARY_SENSOR_STATE_TOPIC_DISCOVERY_CONFIGURATION_KEY - if not SECONDARY_SENSOR_STATE_TOPIC_DISCOVERY_CONFIGURATION_KEY in self.discovery_payload: - raise Exception(f"Secondary sensor {self.id} must define the discovery state topic key in entity data configuration") - - self.key_for_state_topic = self.discovery_payload.pop(SECONDARY_SENSOR_STATE_TOPIC_DISCOVERY_CONFIGURATION_KEY) - - self.discovery_payload[self.key_for_state_topic] = self.state_topic - - - def SendValues(self): - """ Send values of the sensor to the custom topic """ - if self.entitySensor.HasValue(): - sensor_value = ValueFormatter.FormatValue( - self.entitySensor.GetValue(), - self.entitySensor.GetValueFormatterOptions(), - INCLUDE_UNITS_IN_SENSORS) - - self.SendTopicData(self.state_topic, sensor_value) - - class HomeAssistantWarehouse(Warehouse): NAME = "HomeAssistant" @@ -431,13 +395,12 @@ def CollectEntityData(self) -> None: for entityData in entity.GetAllUnconnectedEntityData(): # It's a command: if isinstance(entityData, EntityCommand): + hasscommand = HomeAssistantCommand(entityData, self) - if hasscommand.primary_connected_sensor: - self.homeAssistantEntities["connected_sensors"].append( - hasscommand.primary_connected_sensor) - for secondary in hasscommand.secondary_connected_sensors: - self.homeAssistantEntities["connected_sensors"].append( - secondary) + if hasscommand.connected_sensors: + for connected_sensor in hasscommand.connected_sensors: + self.homeAssistantEntities["connected_sensors"].append( + connected_sensor) self.homeAssistantEntities["commands"].append(hasscommand) # It's a sensor: @@ -489,7 +452,7 @@ def MakeValuesTopic(self, topic_suffix: str) -> str: def NormalizeTopic(topicstring: str) -> str: """ Home assistant requires stricter topic names """ # Remove non ascii characters: - topicstring=topicstring.encode("ascii", "ignore").decode() + topicstring = topicstring.encode("ascii", "ignore").decode() return MQTTClient.NormalizeTopic(topicstring).replace(" ", "_") @classmethod @@ -497,10 +460,12 @@ def ConfigurationPreset(cls) -> MenuPreset: preset = MenuPreset() preset.AddEntry("Home assistant MQTT broker address", CONFIG_KEY_ADDRESS, mandatory=True) - preset.AddEntry("Port", CONFIG_KEY_PORT, default=1883, question_type="integer") + preset.AddEntry("Port", CONFIG_KEY_PORT, + default=1883, question_type="integer") preset.AddEntry("Client name", CONFIG_KEY_NAME, mandatory=True) preset.AddEntry("Username", CONFIG_KEY_USERNAME) - preset.AddEntry("Password", CONFIG_KEY_PASSWORD, question_type="secret") + preset.AddEntry("Password", CONFIG_KEY_PASSWORD, + question_type="secret") preset.AddEntry("Add computer name to entity name", CONFIG_KEY_ADD_NAME_TO_ENTITY, default="Y", question_type="yesno") preset.AddEntry("Use tag as entity name for multi instance entities", From f5b95402b92c95b31b64538249e2a6807fe08fde Mon Sep 17 00:00:00 2001 From: Riccardo Briccola Date: Sun, 7 Apr 2024 14:42:07 +0200 Subject: [PATCH 13/13] Release summary --- .../Entity/Deployments/AppInfo/AppInfo.py | 43 ++++++++++++++++++- IoTuring/MyApp/App.py | 20 +++++++++ pyproject.toml | 1 + 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py index 6272ec983..efb6b4398 100644 --- a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py +++ b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py @@ -16,6 +16,8 @@ NO_REMOTE_INSTALL_AVAILABLE_MSG = "⚠️ Currently the Install process cannot be started from HomeAssistant. Please update it manually. ⚠️" +UPDATE_RELEASE_SUMMARY_MAX_CHARS = 255 + class AppInfo(Entity): NAME = "AppInfo" @@ -76,9 +78,48 @@ def UpdateCommandCustomPayload(self): "title": App.getName(), "name": App.getName(), "release_url": App.getUrlReleases(), - "release_summary": NO_REMOTE_INSTALL_AVAILABLE_MSG, + "release_summary": + self.getReleaseNotes() } + def getReleaseNotes(self): + release_notes = NO_REMOTE_INSTALL_AVAILABLE_MSG + "
    " + notes = App.crawlReleaseNotes().split("\n") + notes = ["
  • " + note + "
  • " for note in notes if len(note) > 0] + # Sort by length + notes.sort(key=len) + list_end = "
" + cannot_complete_msg = "
  • ...
  • " + + # Append the list to the release notes until we have space + # If no space, append "...": take into account that we can't place a note if then the next note is too long and + # also there wouldn't be space for the "..." + noteI = 0 + end = False + while noteI < len(notes) and not end: + # Last note: don't need to take into account the possibility of adding "..." + if noteI == len(notes) - 1: + if len(release_notes) + len(notes[noteI]) + len(list_end) <= UPDATE_RELEASE_SUMMARY_MAX_CHARS: + release_notes += notes[noteI] + else: + release_notes += cannot_complete_msg + else: # not last note: can I add it ? If I add it, will I be able to add "..." if I won't be able to add the next note ? + if len(release_notes) + len(notes[noteI]) + len(notes[noteI + 1]) + len(list_end) <= UPDATE_RELEASE_SUMMARY_MAX_CHARS: + # Both this and next note can be added -> free to add this + release_notes += notes[noteI] + else: + # The next note can't be added but the three dots can (and so also this note) -> Free to add this + if len(release_notes) + len(notes[noteI]) + len(cannot_complete_msg) + len(list_end) <= UPDATE_RELEASE_SUMMARY_MAX_CHARS: + release_notes += notes[noteI] + else: + # The three dots can't be added -> end + release_notes += cannot_complete_msg + end = True + noteI += 1 + + + release_notes += list_end + return release_notes + def versionToInt(version: str): return int(''.join([i for i in version if i.isdigit()])) diff --git a/IoTuring/MyApp/App.py b/IoTuring/MyApp/App.py index b4f2f52f2..acb71349a 100644 --- a/IoTuring/MyApp/App.py +++ b/IoTuring/MyApp/App.py @@ -1,5 +1,7 @@ from importlib.metadata import metadata from pathlib import Path +import requests +from bs4 import BeautifulSoup class App(): METADATA = metadata('IoTuring') @@ -50,6 +52,24 @@ def getRootPath() -> Path: """ return Path(__file__).parents[1] + @staticmethod + def crawlReleaseNotes() -> str: + """Crawl the release notes from the Release page """ + try: + res = requests.get(App.getUrlReleases()) + if res.status_code == 200: + soup = BeautifulSoup(res.text, 'html.parser') + # take the last release release notes + release_notes = soup.find('div', class_='markdown-body') + if release_notes: + release_notes = release_notes.text.split("Changelog")[1] + release_notes = release_notes.split("Commits")[0] + return release_notes.strip() + except Exception as e: + return "Error fetching release notes" + return "No release notes found" + + def __str__(self) -> str: msg = "" msg += "Name: " + App.getName() + "\n" diff --git a/pyproject.toml b/pyproject.toml index 15c738984..3b595f30f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "PyYAML", "requests", "InquirerPy", + "beautifulsoup4", "PyObjC; sys_platform == 'darwin'", "IoTuring-applesmc; sys_platform == 'darwin'", "tinyWinToast; sys_platform == 'win32'"