Skip to content
Draft
82 changes: 66 additions & 16 deletions IoTuring/Entity/Deployments/AppInfo/AppInfo.py
Original file line number Diff line number Diff line change
@@ -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 = "<b>⚠️ Currently the Install process cannot be started from HomeAssistant. Please update it manually. ⚠️</b>"

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):
Expand All @@ -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 + "<br><ul>"
notes = App.crawlReleaseNotes().split("\n")
notes = ["<li>" + note + "</li>" for note in notes if len(note) > 0]
# Sort by length
notes.sort(key=len)
list_end = "</ul>"
cannot_complete_msg = "<li>...</li>"

# 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()]))

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

Expand Down Expand Up @@ -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()
Expand Down
39 changes: 25 additions & 14 deletions IoTuring/Entity/EntityData.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Callable
if TYPE_CHECKING:
from IoTuring.Entity.Entity import Entity

Expand Down Expand Up @@ -117,24 +117,35 @@ def SetExtraAttribute(self, attribute_name, attribute_value, valueFormatterOptio

class EntityCommand(EntityData):

def __init__(self, entity, key, callbackFunction,
connectedEntitySensorKey=None, customPayload={}):
"""
If a key for the entity sensor is passed, warehouses that support it use this command as a switch with state.
Better to register the sensor before this command to avoud unexpected behaviours.
CustomPayload overrides HomeAssistant discovery configuration
def __init__(self, entity: Entity, key: str, callbackFunction: Callable,
connectedEntitySensorKeys: str | list = [],
customPayload={}):
"""Create a new EntityCommand.

If key or keys for the entity sensor is passed, warehouses that support it can use this command as a switch with state.
Order of sensors matter, first sensors state topic will be used.
Better to register the sensors before this command to avoid unexpected behaviours.

Args:
entity (Entity): The entity this command belongs to.
key (str): The KEY of this command
callbackFunction (Callable): Function to be called
connectedEntitySensorKeys (str | list, optional): A key to a sensor or a list of keys. Defaults to [].
customPayload (dict, optional): Overrides HomeAssistant discovery configuration. Defaults to {}.
"""

EntityData.__init__(self, entity, key, customPayload)
self.callbackFunction = callbackFunction
self.connectedEntitySensorKey = connectedEntitySensorKey
self.connectedEntitySensorKeys = connectedEntitySensorKeys if isinstance(
connectedEntitySensorKeys, list) else [connectedEntitySensorKeys]

def SupportsState(self):
return self.connectedEntitySensorKey is not None
def SupportsState(self) -> bool:
""" True if this command supports state (has a connected sensors) """
return bool(self.connectedEntitySensorKeys)

def GetConnectedEntitySensor(self) -> EntitySensor:
""" Returns the entity sensor connected to this command, if this command supports state.
Otherwise returns None. """
return self.GetEntity().GetEntitySensorByKey(self.connectedEntitySensorKey)
def GetConnectedEntitySensors(self) -> list[EntitySensor]:
""" Returns the entity sensors connected to this command. Returns empty list if none found. """
return [self.GetEntity().GetEntitySensorByKey(key) for key in self.connectedEntitySensorKeys]

def CallCallback(self, message):
""" Safely run callback for this command, passing the message (a paho.mqtt.client.MQTTMessage).
Expand Down
20 changes: 20 additions & 0 deletions IoTuring/MyApp/App.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from importlib.metadata import metadata
from pathlib import Path
import requests
from bs4 import BeautifulSoup

class App():
METADATA = metadata('IoTuring')
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 """
Expand Down Expand Up @@ -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

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


Expand Down Expand Up @@ -368,21 +385,22 @@ 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))

# 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:
Expand Down Expand Up @@ -434,18 +452,20 @@ 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
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",
Expand Down
Loading