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 + "
"
+ 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/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'"