diff --git a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py index bd58feee6..efb6b4398 100644 --- a/IoTuring/Entity/Deployments/AppInfo/AppInfo.py +++ b/IoTuring/Entity/Deployments/AppInfo/AppInfo.py @@ -1,49 +1,52 @@ 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_VERSION = 'version' +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_UPDATE_LATEST = 'Latest version' EXTRA_ATTRIBUTE_UPDATE_ERROR = 'Check error' +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" def Initialize(self): - self.RegisterEntitySensor(EntitySensor(self, KEY_NAME)) - self.RegisterEntitySensor(EntitySensor(self, KEY_VERSION)) - self.RegisterEntitySensor(EntitySensor(self, KEY_UPDATE, supportsExtraAttributes=True)) + 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.SetEntitySensorValue(KEY_NAME, App.getName()) - self.SetEntitySensorValue(KEY_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_CURRENT_VERSION, EXTRA_ATTRIBUTE_UPDATE_ERROR, GET_UPDATE_ERROR_MESSAGE) def GetUpdateInformation(self): @@ -70,6 +73,53 @@ def GetUpdateInformation(self): else: raise UpdateCheckException() + def UpdateCommandCustomPayload(self): + return { + "title": App.getName(), + "name": App.getName(), + "release_url": App.getUrlReleases(), + "release_summary": + self.getReleaseNotes() + } + + def getReleaseNotes(self): + release_notes = NO_REMOTE_INSTALL_AVAILABLE_MSG + "
" + 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/Entity/Entity.py b/IoTuring/Entity/Entity.py index 1ee44d46d..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,11 +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]: - """ 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: + """ 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/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/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py index 5e34bbaa6..572d677bd 100644 --- a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py +++ b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py @@ -46,6 +46,8 @@ 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 """ @@ -240,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 @@ -271,41 +280,49 @@ def __init__(self, entityData: EntityCommand, wh: "HomeAssistantWarehouse") -> N 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 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") self.command_callback = self.GenerateCommandCallback() - def GetConnectedSensor(self) -> HomeAssistantSensor | None: + def GetConnectedSensors(self) -> list[HomeAssistantSensor]: """ Get the connected sensor of this command """ - if self.entityCommand.SupportsState(): - return HomeAssistantSensor( - entityData=self.entityCommand.GetConnectedEntitySensor(), - wh=self.wh) - else: - return None + 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: + # First sensor, update state from callback: + # Only set value if it was already set, to exclude optimistic switches - if self.connected_sensor.entitySensor.HasValue(): + if self.connected_sensors[0].entitySensor.HasValue(): self.Log(self.LOG_DEBUG, "Switch callback: sending state to " + - self.connected_sensor.state_topic) + self.connected_sensors[0].state_topic) self.SendTopicData( - self.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 @@ -368,7 +385,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 +393,14 @@ 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: - self.homeAssistantEntities["connected_sensors"].append( - hasscommand.connected_sensor) + 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: @@ -434,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 @@ -442,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", diff --git a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml index c5afe11e0..f5163b87d 100644 --- a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml +++ b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml @@ -66,14 +66,17 @@ 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: +AppInfo - name: icon: mdi:information-outline +AppInfo - update: + custom_type: update + device_class: firmware + 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 diff --git a/pyproject.toml b/pyproject.toml index de49fafd2..3b595f30f 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"} @@ -27,6 +27,7 @@ dependencies = [ "PyYAML", "requests", "InquirerPy", + "beautifulsoup4", "PyObjC; sys_platform == 'darwin'", "IoTuring-applesmc; sys_platform == 'darwin'", "tinyWinToast; sys_platform == 'win32'"