Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Redesign AppInfo: Update sensor #96

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
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
Loading