diff --git a/IoTuring/Entity/Deployments/Battery/Battery.py b/IoTuring/Entity/Deployments/Battery/Battery.py index 7da9f72b..bbc167ea 100644 --- a/IoTuring/Entity/Deployments/Battery/Battery.py +++ b/IoTuring/Entity/Deployments/Battery/Battery.py @@ -12,7 +12,11 @@ class Battery(Entity): def Initialize(self): self.RegisterEntitySensor(EntitySensor(self, KEY_PERCENTAGE)) - self.RegisterEntitySensor(EntitySensor(self, KEY_CHARGING_STATUS)) + + # Check if charging state working: + batteryInfo = self.GetBatteryInformation() + if isinstance(batteryInfo['charging'], bool): + self.RegisterEntitySensor(EntitySensor(self, KEY_CHARGING_STATUS)) def PostInitialize(self): # Check if battery infomration are present @@ -23,8 +27,9 @@ def Update(self): batteryInfo = self.GetBatteryInformation() self.SetEntitySensorValue(KEY_PERCENTAGE, int( batteryInfo['level']), ValueFormatter.Options(ValueFormatter.TYPE_PERCENTAGE)) - self.SetEntitySensorValue( - KEY_CHARGING_STATUS, str(batteryInfo['charging'])) + if isinstance(batteryInfo['charging'], bool): + self.SetEntitySensorValue( + KEY_CHARGING_STATUS, str(batteryInfo['charging'])) def GetBatteryInformation(self): battery = psutil.sensors_battery() diff --git a/IoTuring/Entity/Deployments/FileSwitch/FileSwitch.py b/IoTuring/Entity/Deployments/FileSwitch/FileSwitch.py new file mode 100644 index 00000000..42aa12f1 --- /dev/null +++ b/IoTuring/Entity/Deployments/FileSwitch/FileSwitch.py @@ -0,0 +1,55 @@ +from pathlib import Path + +from IoTuring.Entity.Entity import Entity +from IoTuring.Entity.EntityData import EntityCommand, EntitySensor +from IoTuring.Configurator.MenuPreset import MenuPreset + +KEY_STATE = 'fileswitch_state' +KEY_CMD = 'fileswitch' + +CONFIG_KEY_PATH = 'path' + + +class FileSwitch(Entity): + NAME = "FileSwitch" + ALLOW_MULTI_INSTANCE = True + + def Initialize(self): + + try: + self.config_path = self.GetConfigurations()[CONFIG_KEY_PATH] + except Exception as e: + raise Exception("Configuration error: " + str(e)) + + self.RegisterEntitySensor(EntitySensor(self, KEY_STATE, True)) + self.RegisterEntityCommand(EntityCommand( + self, KEY_CMD, self.Callback, KEY_STATE)) + + def PostInitialize(self): + pass + + def Callback(self, message): + payloadString = message.payload.decode('utf-8') + + if payloadString == "True": + Path(self.config_path).touch() + + elif payloadString == "False": + Path(self.config_path).unlink(missing_ok=True) + + else: + raise Exception('Incorrect payload!') + + def Update(self): + self.SetEntitySensorValue(KEY_STATE, + str(Path(self.config_path).exists())) + + extra = {} + extra["Path"] = str(self.config_path) + self.SetEntitySensorExtraAttributes(KEY_STATE, extra) + + @classmethod + def ConfigurationPreset(self): + preset = MenuPreset() + preset.AddEntry("Path to file?", CONFIG_KEY_PATH, mandatory=True) + return preset diff --git a/IoTuring/Entity/Deployments/Monitor/Monitor.py b/IoTuring/Entity/Deployments/Monitor/Monitor.py index 254a3a77..3df0930d 100644 --- a/IoTuring/Entity/Deployments/Monitor/Monitor.py +++ b/IoTuring/Entity/Deployments/Monitor/Monitor.py @@ -1,13 +1,16 @@ import subprocess import ctypes -import os as sys_os +import os +import re + from IoTuring.Entity.Entity import Entity -from ctypes import * +from IoTuring.Entity.EntityData import EntityCommand, EntitySensor +from IoTuring.Entity import consts +from IoTuring.Logger.consts import STATE_OFF, STATE_ON -from IoTuring.Entity.EntityData import EntityCommand -KEY_TURN_ALL_OFF = 'turn_all_off' -KEY_TURN_ALL_ON = 'turn_all_on' +KEY_STATE = 'monitor_state' +KEY_CMD = 'monitor' class Monitor(Entity): @@ -19,27 +22,54 @@ def Initialize(self): def PostInitialize(self): self.os = self.GetDependentEntitySensorValue('Os', "operating_system") + + supports_linux = False + if self.os == consts.OS_FIXED_VALUE_LINUX: + # Check if xset is working: + p = subprocess.run( + ['xset', 'dpms'], capture_output=True, shell=False) + if p.stderr: + raise Exception(f"Xset dpms error: {p.stderr.decode()}") + elif not os.getenv('DISPLAY'): + raise Exception('No $DISPLAY environment variable!') + else: + supports_linux = True - if self.os == 'Windows' or (self.os == 'Linux' and sys_os.environ.get('DISPLAY')): + if self.os == consts.OS_FIXED_VALUE_WINDOWS: self.RegisterEntityCommand(EntityCommand( - self, KEY_TURN_ALL_OFF, self.CallbackTurnAllOff)) + self, KEY_CMD, self.Callback)) + elif supports_linux: + # Support for sending state on linux + self.RegisterEntitySensor(EntitySensor(self, KEY_STATE)) self.RegisterEntityCommand(EntityCommand( - self, KEY_TURN_ALL_ON, self.CallbackTurnAllOn)) - - def CallbackTurnAllOff(self, message): - if self.os == 'Windows': - ctypes.windll.user32.SendMessageA(0xFFFF, 0x0112, 0xF170, 2) - elif self.os == 'Linux': - # Check if X11 or something else - if sys_os.environ.get('DISPLAY'): - command = 'xset dpms force off' - subprocess.Popen(command.split(), stdout=subprocess.PIPE) + self, KEY_CMD, self.Callback, KEY_STATE)) + + def Callback(self, message): + payloadString = message.payload.decode('utf-8') - def CallbackTurnAllOn(self, message): - if self.os == 'Windows': - ctypes.windll.user32.SendMessageA(0xFFFF, 0x0112, 0xF170, -1) - elif self.os == 'Linux': - # Check if X11 or something else - if sys_os.environ.get('DISPLAY'): + if payloadString == STATE_ON: + if self.os == consts.OS_FIXED_VALUE_WINDOWS: + ctypes.windll.user32.SendMessageA(0xFFFF, 0x0112, 0xF170, -1) + elif self.os == consts.OS_FIXED_VALUE_LINUX: command = 'xset dpms force on' subprocess.Popen(command.split(), stdout=subprocess.PIPE) + + elif payloadString == STATE_OFF: + if self.os == consts.OS_FIXED_VALUE_WINDOWS: + ctypes.windll.user32.SendMessageA(0xFFFF, 0x0112, 0xF170, 2) + elif self.os == consts.OS_FIXED_VALUE_LINUX: + command = 'xset dpms force off' + subprocess.Popen(command.split(), stdout=subprocess.PIPE) + else: + raise Exception('Incorrect payload!') + + def Update(self): + if self.os == consts.OS_FIXED_VALUE_LINUX: + p = subprocess.run(['xset', 'q'], capture_output=True, shell=False) + outputString = p.stdout.decode() + monitorState = re.findall( + 'Monitor is (.{2,3})', outputString)[0].upper() + if monitorState in [STATE_OFF, STATE_ON]: + self.SetEntitySensorValue(KEY_STATE, monitorState) + else: + raise Exception(f'Incorrect monitor state: {monitorState}') diff --git a/IoTuring/Entity/EntityData.py b/IoTuring/Entity/EntityData.py index 39ee3517..65636e65 100644 --- a/IoTuring/Entity/EntityData.py +++ b/IoTuring/Entity/EntityData.py @@ -8,6 +8,10 @@ def __init__(self, entity, key): self.entityId = entity.GetEntityId() self.id = self.entityId + "." + key self.key = key + self.entity = entity + + def GetEntity(self): + return self.entity def GetId(self): return self.id @@ -62,17 +66,33 @@ def SetExtraAttributes(self, _dict): class EntityCommand(EntityData): - def __init__(self, entity, key, callbackFunction): + def __init__(self, entity, key, callbackFunction, connectedEntitySensorKey = None): + """ + 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. + """ EntityData.__init__(self, entity, key) self.callbackFunction = callbackFunction + self.connectedEntitySensorKey = connectedEntitySensorKey + + def SupportsState(self): + return self.connectedEntitySensorKey is not None + + def GetConnectedEntitySensor(self): + """ Returns the entity sensor connected to this command, if this command supports state. + Otherwise returns None. """ + return self.GetEntity().GetEntitySensorByKey(self.connectedEntitySensorKey) def CallCallback(self, message): - """ Safely run callback for this command, passing the message (a paho.mqtt.client.MQTTMessage) """ + """ Safely run callback for this command, passing the message (a paho.mqtt.client.MQTTMessage). + Reutrns True if callback was run correctly, False if an error occurred.""" self.Log(self.LOG_DEBUG, "Callback") try: self.RunCallback(message) + return True except Exception as e: self.Log(self.LOG_ERROR, "Error while running callback: " + str(e)) + return False def RunCallback(self, message): """ Called only by CallCallback. diff --git a/IoTuring/Logger/consts.py b/IoTuring/Logger/consts.py index 5081b970..947e736c 100644 --- a/IoTuring/Logger/consts.py +++ b/IoTuring/Logger/consts.py @@ -11,6 +11,10 @@ LOG_DEVELOPMENT = 5 +# On/off states as strings: +STATE_ON = "ON" +STATE_OFF = "OFF" + # Fill start of string with spaces to jusitfy the message (0: no padding) # First for type, second for source STRINGS_LENGTH = [8, 30] diff --git a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py index 63357730..dceb85a5 100644 --- a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py +++ b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py @@ -4,10 +4,12 @@ from IoTuring.Protocols.MQTTClient.MQTTClient import MQTTClient from IoTuring.Warehouse.Warehouse import Warehouse from IoTuring.MyApp.App import App +from IoTuring.Logger import consts + import json import yaml import re -import time +import time SLEEP_TIME_NOT_CONNECTED_WHILE = 1 @@ -31,8 +33,10 @@ EXTERNAL_ENTITY_DATA_CONFIGURATION_KEY_CUSTOM_TYPE = "custom_type" LWT_TOPIC_SUFFIX = "LWT" -LWT_PAYLOAD_ONLINE = "ON" -LWT_PAYLOAD_OFFLINE = "OFF" +LWT_PAYLOAD_ONLINE = "ONLINE" +LWT_PAYLOAD_OFFLINE = "OFFLINE" +PAYLOAD_ON = consts.STATE_ON +PAYLOAD_OFF = consts.STATE_OFF class HomeAssistantWarehouse(Warehouse): @@ -66,10 +70,27 @@ def RegisterEntityCommands(self): for entity in self.GetEntities(): for entityCommand in entity.GetEntityCommands(): self.client.AddNewTopicToSubscribeTo( - self.MakeEntityDataTopic(entityCommand), entityCommand.CallCallback) + self.MakeEntityDataTopic(entityCommand), self.GenerateCommandCallback(entityCommand)) self.Log(self.LOG_DEBUG, entityCommand.GetId( ) + " subscribed to " + self.MakeEntityDataTopic(entityCommand)) + def GenerateCommandCallback(self, entityCommand): + """ Generates a lambda function that will become the command callback. + Obviously this lamda will call the default callback of the command. + But will also send the state to the state topic of the sensor relative to the command, + in case the command has a sensor connected to it (= is a switch). + This is needed to avoid status blinking on HA.""" + def CommandCallback(message): + status = entityCommand.CallCallback( + message) # True: success, False: error + if status and self.client.IsConnected(): + if entityCommand.SupportsState(): + self.Log(self.LOG_DEBUG, "Switch callback: sending state to " + + self.MakeEntityDataTopicForSensorByCommandIfSwitch(entityCommand)) + self.client.SendTopicData(self.MakeEntityDataTopicForSensorByCommandIfSwitch( + entityCommand), message.payload.decode('utf-8')) + return CommandCallback + def Loop(self): # Send online state self.client.SendTopicData(self.MakeValuesTopic( @@ -94,26 +115,44 @@ def SendSensorsValues(self): for entity in self.GetEntities(): for entitySensor in entity.GetEntitySensors(): if (entitySensor.HasValue()): - self.client.SendTopicData(self.MakeEntityDataTopic( - entitySensor), entitySensor.GetValue()) - if (entitySensor.HasExtraAttributes()): # send as json + topic = self.MakeEntityDataTopic(entitySensor) + self.client.SendTopicData( + topic, entitySensor.GetValue()) # send + if (entitySensor.HasExtraAttributes()): self.client.SendTopicData(self.MakeEntityDataExtraAttributesTopic(entitySensor), json.dumps(entitySensor.GetExtraAttributes())) def SendEntityDataConfigurations(self): self.SendLwtSensorConfiguration() for entity in self.GetEntities(): + + # Get sensors entity data linked to commands so they are not configured as sensor but + # will be set in command state which will be a switch + keys_of_sensors_connected_to_commands = \ + [command.GetConnectedEntitySensor().GetKey() + for command in entity.GetEntityCommands() + if command.SupportsState()] + for entityData in entity.GetAllEntityData(): + + # if I found a sensor data that will be configured only after, together with + # its command (as a switch) + if entityData.GetKey() in keys_of_sensors_connected_to_commands: + continue # it's a sensor linked to a command, so skip + + entitycommand_supports_state = False data_type = "" autoDiscoverySendTopic = "" payload = {} payload['name'] = entity.GetEntityNameWithTag() + " - " + \ entityData.GetKey() - # check the data type: can be edited from custom configurations if entityData in entity.GetEntitySensors(): # it's an EntitySensorData data_type = "sensor" - else: # it's a EntityCommandData + elif entityData.SupportsState(): # it's a EntityCommandData: has it a state ? + data_type = "switch" + entitycommand_supports_state = True + else: data_type = "button" # add custom info to the entity data, reading it from external file and accessing the information using the entity data name @@ -132,23 +171,45 @@ def SendEntityDataConfigurations(self): payload['unique_id'] = self.clientName + \ "." + entityData.GetId() - if entityData in entity.GetEntitySensors(): # it's an EntitySensorData + # add configurations about sensors or switches (both have a state) + if entityData in entity.GetEntitySensors() or entitycommand_supports_state: # it's an EntitySensorData # If the sensor supports extra attributes, send them as JSON. - # So here I have to specify also the topic for those attrbitues - if entityData.DoesSupportExtraAttributes(): + # So here I have to specify also the topic for those attributes + # - for sensors that became switches: entityData is the command + # so I need to retrieve the sensor and check there if supports extra attributes + # and get from there the topic + if entitycommand_supports_state and entityData.GetConnectedEntitySensor().DoesSupportExtraAttributes(): + payload["json_attributes_topic"] = self.MakeEntityDataExtraAttributesTopic( + entityData.GetConnectedEntitySensor()) + + # - for real sensors - + elif entityData.DoesSupportExtraAttributes(): payload["json_attributes_topic"] = self.MakeEntityDataExtraAttributesTopic( entityData) payload['expire_after'] = 600 # TODO Improve - payload['state_topic'] = self.MakeEntityDataTopic( - entityData) - autoDiscoverySendTopic = TOPIC_AUTODISCOVERY_FORMAT.format( - data_type, App.getName(), payload['unique_id'].replace(".", "_")) - else: # it's a EntityCommandData + + if entitycommand_supports_state: # it has a state, so the key of the sensor to generate the topic is found in the sensor + payload['state_topic'] = self.MakeEntityDataTopicForSensorByCommandIfSwitch( + entityData) + else: # it's a real sensor: + payload['state_topic'] = self.MakeEntityDataTopic( + entityData) + + # Add default payloads (only for ON/OFF entities) + if data_type in ["binary_sensor", "switch"]: + if not 'payload_on' in payload: + payload['payload_on'] = PAYLOAD_ON + if not 'payload_off' in payload: + payload['payload_off'] = PAYLOAD_OFF + + # if it's a command (so button or switch), configure the topic where the command will be called + if entityData in entity.GetEntityCommands(): payload['command_topic'] = self.MakeEntityDataTopic( entityData) - autoDiscoverySendTopic = TOPIC_AUTODISCOVERY_FORMAT.format( - data_type, App.getName(), payload['unique_id'].replace(".", "_")) + + autoDiscoverySendTopic = TOPIC_AUTODISCOVERY_FORMAT.format( + data_type, App.getName(), payload['unique_id'].replace(".", "_")) # Add availability configuration payload["availability_topic"] = self.MakeValuesTopic( @@ -196,6 +257,14 @@ def AddEntityDataCustomConfigurations(self, entityDataName, payload): return {**payload, **entityDataConfiguration} return payload # if nothing found + def MakeEntityDataTopicForSensorByCommandIfSwitch(self, entityData): + """ If the entityData is a command, returns the topic of the sensor connected to it """ + if entityData.SupportsState(): + return self.MakeEntityDataTopic(entityData.GetConnectedEntitySensor()) + else: + raise Exception(entityData.GetID() + + " is not a switch, can't get its sensor topic") + def MakeEntityDataTopic(self, entityData): """ Uses MakeValuesTopic but receives an EntityData to manage itself its id""" return self.MakeValuesTopic(entityData.GetId()) diff --git a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml index c9448050..5717e83e 100644 --- a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml +++ b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml @@ -50,12 +50,14 @@ Cpu: # if no matches with above CPU patterns, set this icon: "mdi:calculator-variant" Time: icon: "mdi:clock" -Monitor - turn_all_on: - icon: "mdi:monitor-star" -Monitor - turn_all_off: - icon: "mdi:monitor-off" +Monitor: + icon: "mdi:monitor-shimmer" AppInfo: icon: "mdi:information-outline" Temperature: icon: "mdi:thermometer-lines" unit_of_measurement: "°C" +FileSwitch: + icon: mdi:file-star + payload_on: "True" + payload_off: "False" \ No newline at end of file