diff --git a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py index 72aeed4b..d1bd3443 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 c89e5d29..6ccf8443 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 701ebae5..fae35526 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 5e34bbaa..c35813f1 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 eb23fb23..f5163b87 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