├── .env ├── .gitignore ├── BitOptions.py ├── ClassManager.py ├── Configurator.py ├── Custom └── .gitignore ├── Entities ├── Commands │ ├── BrightnessCommand │ │ ├── BrightnessCommand.py │ │ └── settings.yaml │ ├── InboxCommand │ │ ├── InboxCommand.py │ │ └── settings.yaml │ ├── LockCommand │ │ ├── LockCommand.py │ │ └── settings.yaml │ ├── NotifyCommand │ │ ├── NotifyCommand.py │ │ └── settings.yaml │ ├── RebootCommand │ │ ├── RebootCommand.py │ │ └── settings.yaml │ ├── ShutdownCommand │ │ ├── ShutdownCommand.py │ │ └── settings.yaml │ ├── SleepCommand │ │ ├── SleepCommand.py │ │ └── settings.yaml │ ├── TerminalCommand │ │ ├── TerminalCommand.py │ │ └── settings.yaml │ ├── TurnOffMonitorsCommand │ │ ├── TurnOffMonitorsCommand.py │ │ └── settings.yaml │ └── TurnOnMonitorsCommand │ │ ├── TurnOnMonitorsCommand.py │ │ └── settings.yaml ├── Entity.py └── Sensors │ ├── ActiveWindowSensor │ ├── ActiveWindowSensor.py │ └── settings.yaml │ ├── BatterySensor │ ├── BatterySensor.py │ └── settings.yaml │ ├── BoottimeSensor │ ├── BoottimeSensor.py │ └── settings.yaml │ ├── CpuSensor │ ├── CpuSensor.py │ └── settings.yaml │ ├── CpuTemperaturesSensor │ ├── CpuTemperaturesSensor.py │ └── settings.yaml │ ├── DesktopEnvironmentSensor │ ├── DesktopEnvironmentSensor.py │ └── settings.yaml │ ├── DiskSensor │ ├── DiskSensor.py │ └── settings.yaml │ ├── FileReadSensor │ ├── FileReadSensor.py │ └── settings.yaml │ ├── HostnameSensor │ ├── HostnameSensor.py │ └── settings.yaml │ ├── MessageSensor │ ├── MessageSensor.py │ └── settings.yaml │ ├── NetworkSensor │ ├── NetworkSensor.py │ └── settings.yaml │ ├── OsSensor │ ├── OsSensor.py │ └── settings.yaml │ ├── RamSensor │ ├── RamSensor.py │ └── settings.yaml │ ├── ScreenshotSensor │ ├── ScreenshotSensor.py │ └── settings.yaml │ ├── StateSensor │ ├── StateSensor.py │ └── settings.yaml │ ├── TimeSensor │ ├── TimeSensor.py │ └── settings.yaml │ ├── UptimeSensor │ ├── UptimeSensor.py │ └── settings.yaml │ ├── UsernameSensor │ ├── UsernameSensor.py │ └── settings.yaml │ └── VolumeSensor │ ├── VolumeSensor.py │ └── settings.yaml ├── EntityManager.py ├── Home Assistant Monitors.png ├── Logger.py ├── Monitor.py ├── MqttClient.py ├── README.md ├── Settings.py ├── StartupAgents ├── Linux │ └── PyMonitorMQTT.service ├── MacOS │ └── macOS.plist ├── README.md └── Windows │ ├── PyMonitorMQTT.vbs │ └── Win_command.bat ├── ValueFormatter.py ├── configuration-homeassistant.yaml ├── configuration.yaml.example ├── consts.py ├── information.json ├── main.py ├── requirements.txt └── schemas.py /.env: -------------------------------------------------------------------------------- 1 | PYTHONPATH=${workspaceFolder}:${PYTHONPATH} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | configuration.yaml 3 | .vscode 4 | .idea 5 | venv 6 | Logs 7 | 8 | # Add this file 9 | !.gitignore -------------------------------------------------------------------------------- /BitOptions.py: -------------------------------------------------------------------------------- 1 | class BitOptions(): 2 | 3 | @staticmethod 4 | def SetOptions(list_of_entries, default_starting_options=0): 5 | options = default_starting_options 6 | for option in list_of_entries: 7 | options+=option 8 | return options 9 | 10 | @staticmethod 11 | def AddToOptions(options, list_of_entries): 12 | for option in list_of_entries: 13 | options+=option 14 | return options 15 | 16 | @staticmethod 17 | def GetBitList(options): 18 | # return bit from options number 19 | bit = "{0:b}".format(options) 20 | return [int(char) for char in bit] 21 | 22 | @staticmethod 23 | def CheckOption(options, option): # Option is the bit number from left (1=bit on the right LSB)\ 24 | bit = BitOptions.GetBitList(options) 25 | size = len(bit) 26 | if(options <= size): 27 | if options[size-option] == 1: 28 | return True 29 | return False -------------------------------------------------------------------------------- /ClassManager.py: -------------------------------------------------------------------------------- 1 | import os 2 | from Entities.Entity import Entity 3 | from pathlib import Path 4 | from os import path 5 | import importlib.util 6 | import importlib.machinery 7 | import sys, inspect 8 | from Logger import Logger 9 | import consts 10 | 11 | class ClassManager(): # Class to load Entities from the Entitties dir and get them from name 12 | def __init__(self,config): 13 | self.logger=Logger(config) 14 | self.modulesFilename=[] 15 | self.mainPath = path.dirname(path.abspath( 16 | sys.modules[self.__class__.__module__].__file__)) 17 | self.GetModulesFilename(consts.ENTITIES_PATH) 18 | self.GetModulesFilename(consts.CUSTOM_ENTITIES_PATH) 19 | 20 | def GetEntityClass(self,entityName): 21 | # From entity name, load the correct module and extract the entity class 22 | for module in self.modulesFilename: # Search the module file 23 | moduleName=self.ModuleNameFromPath(module) 24 | # Check if the module name matches the entity sname 25 | if entityName==moduleName: 26 | # Load the module 27 | loadedModule=self.LoadModule(module) 28 | return self.GetEntityClassFromModule(loadedModule) 29 | return None 30 | 31 | 32 | def LoadModule(self,path): # Get module and load it from the path 33 | loader = importlib.machinery.SourceFileLoader(self.ModuleNameFromPath(path), path) 34 | spec = importlib.util.spec_from_loader(loader.name, loader) 35 | module = importlib.util.module_from_spec(spec) 36 | loader.exec_module(module) 37 | moduleName=os.path.split(path)[1][:-3] 38 | sys.modules[moduleName]=module 39 | return module 40 | 41 | def GetEntityClassFromModule(self,module): # From the module passed, I search for a Class that has the Entity class as parent 42 | for name, obj in inspect.getmembers(module): 43 | if inspect.isclass(obj): 44 | for base in obj.__bases__: # Check parent class 45 | if(base==Entity): 46 | return obj 47 | 48 | 49 | def GetModulesFilename(self,_path): # List files in the Entities directory and get only files in subfolders 50 | entitiesPath=path.join(self.mainPath,_path) 51 | if os.path.exists(entitiesPath): 52 | self.Log(Logger.LOG_DEVELOPMENT,"Looking for Entity python file in \"" + _path + os.sep + "\"...") 53 | result = list(Path(entitiesPath).rglob("*.py")) 54 | entities = [] 55 | for file in result: 56 | filename = str(file) 57 | pathList= filename.split(os.sep) # TO check if a py files is in a folder with the same name (same without extension) 58 | if len(pathList)>=2: 59 | if pathList[len(pathList)-1][:-3]==pathList[len(pathList)-2]: 60 | entities.append(filename) 61 | 62 | self.modulesFilename = self.modulesFilename + entities 63 | self.Log(Logger.LOG_DEVELOPMENT,"Found " + str(len(entities)) + " entity file") 64 | 65 | 66 | def ModuleNameFromPath(self,path): 67 | classname=os.path.split(path) 68 | return classname[1][:-3] 69 | 70 | def Log(self,type,message): 71 | self.logger.Log(type,"Class Manager",message) -------------------------------------------------------------------------------- /Configurator.py: -------------------------------------------------------------------------------- 1 | from consts import * 2 | 3 | class Configurator(): 4 | # Returns the value of the option I find using the path from a passed config 5 | # Can also return a value (that you can pass to the fucntion) if it can't find the path 6 | @staticmethod 7 | def GetOption(config, path, defaultReturnValue=None): 8 | try: 9 | searchConfigTree=config 10 | 11 | # if in options I have a value for that option rerturn that else return False 12 | if type(path) == str: 13 | if searchConfigTree is not None and path in searchConfigTree: 14 | return searchConfigTree[path] 15 | else: 16 | return defaultReturnValue 17 | 18 | elif type(path) == list: # It's a list with the option Path like contents -> values -> first 19 | while (path and len(path)): 20 | if searchConfigTree is None: 21 | return defaultReturnValue 22 | current_option = path.pop(0) 23 | if type(searchConfigTree) == dict and current_option in searchConfigTree: 24 | searchConfigTree = searchConfigTree[current_option] 25 | else: 26 | return defaultReturnValue # Not found 27 | return searchConfigTree # All ok, found 28 | else: 29 | raise Exception( 30 | "Error during GetOption: option type not valid " + str(type(path))) 31 | except Exception as e: 32 | raise Exception(e) 33 | 34 | @staticmethod 35 | def ReturnAsList(configuration, noneCondition=None): 36 | # If the configuration passed is a list, return it, else place configuration in a list and return the list 37 | # PS if configuration passed equals to noneCondition, don't return the list but the noneCondition 38 | if configuration==noneCondition: 39 | return noneCondition 40 | if type(configuration) == list: 41 | return configuration 42 | else: 43 | return [configuration] -------------------------------------------------------------------------------- /Custom/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore -------------------------------------------------------------------------------- /Entities/Commands/BrightnessCommand/BrightnessCommand.py: -------------------------------------------------------------------------------- 1 | from Entities.Entity import Entity 2 | import subprocess 3 | 4 | supports_win_brightness = True 5 | try: 6 | import wmi # Only to get windows brightness 7 | import pythoncom 8 | except: 9 | supports_win_brightness = False 10 | 11 | 12 | IN_TOPIC = 'brightness/set' # Receive a set message 13 | OUT_TOPIC = 'brightness/get' # Send a message with the value 14 | 15 | 16 | class BrightnessCommand(Entity): 17 | def Initialize(self): 18 | self.SubscribeToTopic(IN_TOPIC) 19 | self.AddTopic(OUT_TOPIC) 20 | self.stopCommand = False 21 | self.stopSensor = False 22 | self.stateOff = False 23 | 24 | def PostInitialize(self): 25 | os = self.GetOS() 26 | 27 | # Sensor function settings 28 | if(os == self.consts.FIXED_VALUE_OS_WINDOWS): 29 | self.GetBrightness_OS = self.GetBrightness_Win 30 | elif(os == self.consts.FIXED_VALUE_OS_MACOS): 31 | self.GetBrightness_OS = self.GetBrightness_macOS 32 | elif(os == self.consts.FIXED_VALUE_OS_LINUX): 33 | self.GetBrightness_OS = self.GetBrightness_Linux 34 | else: 35 | self.Log(self.Logger.LOG_WARNING, 36 | 'No brightness sensor available for this operating system') 37 | self.stopSensor = True 38 | 39 | # Command function settings 40 | if(os == self.consts.FIXED_VALUE_OS_WINDOWS): 41 | self.SetBrightness_OS = self.SetBrightness_Win 42 | elif(os == self.consts.FIXED_VALUE_OS_MACOS): 43 | self.SetBrightness_OS = self.SetBrightness_macOS 44 | elif(os == self.consts.FIXED_VALUE_OS_LINUX): 45 | self.SetBrightness_OS = self.SetBrightness_Linux 46 | else: 47 | self.Log(self.Logger.LOG_WARNING, 48 | 'No brightness command available for this operating system') 49 | self.stopCommand = True 50 | 51 | 52 | def Callback(self, message): 53 | state = message.payload.decode("utf-8") 54 | if not self.stopCommand: 55 | 56 | if state == self.consts.ON_STATE and self.stateOff is not False: 57 | state = self.stateOff if self.stateOff is not None else 100 58 | 59 | if state == self.consts.OFF_STATE: 60 | self.stateOff = self.GetTopicValue(OUT_TOPIC) 61 | state = 1 62 | elif self.stateOff is not False: 63 | self.stateOff = False 64 | 65 | try: 66 | # Value from 0 and 100 67 | self.SetBrightness_OS(int(state)) 68 | except ValueError: # Not int -> not a message for that function 69 | return 70 | except Exception as e: 71 | raise Exception("Error during brightness set: " + str(e)) 72 | 73 | # Finally, tell the sensor to update and to send 74 | self.CallUpdate() 75 | self.SendOnlineState() 76 | self.lastSendingTime = None # Force sensor to send immediately 77 | 78 | def Update(self): 79 | if not self.stopSensor: 80 | self.SetTopicValue(OUT_TOPIC, self.GetBrightness_OS(), 81 | self.ValueFormatter.TYPE_PERCENTAGE) 82 | self.SendOnlineState() 83 | 84 | def SetBrightness_macOS(self, value): 85 | value = value/100 # cause I need it from 0 to 1 86 | command = 'brightness ' + str(value) 87 | subprocess.Popen(command.split(), stdout=subprocess.PIPE) 88 | 89 | def SetBrightness_Linux(self, value): 90 | command = 'xbacklight -set ' + str(value) 91 | subprocess.Popen(command.split(), stdout=subprocess.PIPE) 92 | 93 | def SetBrightness_Win(self, value): 94 | if supports_win_brightness: 95 | pythoncom.CoInitialize() 96 | return wmi.WMI(namespace='wmi').WmiMonitorBrightnessMethods()[0].WmiSetBrightness(value, 0) 97 | else: 98 | raise Exception( 99 | 'No WMI module installed') 100 | 101 | def GetBrightness_macOS(self): 102 | try: 103 | command = 'brightness -l' 104 | process = subprocess.Popen(command.split(), stdout=subprocess.PIPE) 105 | stdout = process.communicate()[0] 106 | brightness = re.findall( 107 | 'display 0: brightness.*$', str(stdout))[0][22:30] 108 | brightness = float(brightness)*100 # is between 0 and 1 109 | return brightness 110 | except: 111 | raise Exception( 112 | 'You sure you installed Brightness from Homebrew ? (else try checking you PATH)') 113 | 114 | def GetBrightness_Linux(self): 115 | try: 116 | command = 'xbacklight' 117 | process = subprocess.Popen(command.split(), stdout=subprocess.PIPE) 118 | stdout = process.communicate()[0] 119 | brightness = float(stdout) 120 | return brightness 121 | except: 122 | raise Exception( 123 | 'You sure you installed Brightness from Homebrew ? (else try checking you PATH)') 124 | 125 | def GetBrightness_Win(self): 126 | if supports_win_brightness: 127 | return int(wmi.WMI(namespace='wmi').WmiMonitorBrightness() 128 | [0].CurrentBrightness) 129 | else: 130 | raise Exception( 131 | 'No WMI module installed') 132 | 133 | def GetOS(self): 134 | # Get OS from OsSensor and get temperature based on the os 135 | os = self.FindEntity('Os') 136 | if os: 137 | if not os.postinitializeState: # I run this function in post initialize so the os sensor might not be ready 138 | os.CallPostInitialize() 139 | os.CallUpdate() 140 | return os.GetTopicValue() 141 | 142 | def ManageDiscoveryData(self, discovery_data): 143 | for data in discovery_data: 144 | data['expire_after']="" 145 | 146 | self.SendOnlineState() 147 | 148 | discovery_data[0]['payload']['brightness_state_topic'] = self.SelectTopic( 149 | OUT_TOPIC) 150 | discovery_data[0]['payload']['state_topic'] = self.SelectTopic( 151 | self.STATE_TOPIC) 152 | discovery_data[0]['payload']['brightness_command_topic'] = self.SelectTopic( 153 | IN_TOPIC) 154 | discovery_data[0]['payload']['command_topic'] = self.SelectTopic( 155 | IN_TOPIC) 156 | discovery_data[0]['payload']['payload_on'] = self.consts.ON_STATE 157 | discovery_data[0]['payload']['payload_off'] = self.consts.OFF_STATE 158 | discovery_data[0]['payload']['brightness_scale'] = 100 159 | 160 | return discovery_data 161 | 162 | STATE_TOPIC = 'brightness/state' 163 | 164 | def SendOnlineState(self): 165 | if self.GetTopicValue(OUT_TOPIC) and int(self.GetTopicValue(OUT_TOPIC)) > 1: 166 | self.mqtt_client.SendTopicData( 167 | self.SelectTopic(self.STATE_TOPIC), self.consts.ON_STATE) 168 | else: 169 | self.mqtt_client.SendTopicData( 170 | self.SelectTopic(self.STATE_TOPIC), self.consts.OFF_STATE) 171 | -------------------------------------------------------------------------------- /Entities/Commands/BrightnessCommand/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | sensors: 3 | - Os: 4 | dont_send: True 5 | 6 | discovery: 7 | homeassistant: # Must match discovery preset name 8 | - topic: "brightness/get" 9 | disable: True 10 | payload: 11 | name: "Brightness level" 12 | unit_of_measurement: "%" 13 | - topic: "brightness/set" 14 | type: "light" 15 | payload: 16 | name: "Brightness" -------------------------------------------------------------------------------- /Entities/Commands/InboxCommand/InboxCommand.py: -------------------------------------------------------------------------------- 1 | from Entities.Entity import Entity 2 | from Logger import Logger, ExceptionTracker 3 | 4 | TOPIC = 'inbox_command' 5 | 6 | # Great to be used with the custom topic and # wildcard to discover on which topic messages are received 7 | 8 | 9 | class InboxCommand(Entity): 10 | def Initialize(self): 11 | self.SubscribeToTopic(TOPIC) 12 | 13 | def Callback(self, message): 14 | self.Log(Logger.LOG_INFO, 'Message received from topic: ' + 15 | str(message.topic)) 16 | self.Log(Logger.LOG_MESSAGE, 17 | 'Message received from topic: ' + str(message.payload)) 18 | -------------------------------------------------------------------------------- /Entities/Commands/InboxCommand/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | 3 | discovery: 4 | homeassistant: # Must match discovery preset name 5 | - topic: "*" 6 | payload: 7 | name: "Send message" 8 | icon: "mdi:email" -------------------------------------------------------------------------------- /Entities/Commands/LockCommand/LockCommand.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from Entities.Entity import Entity 3 | from Logger import Logger, ExceptionTracker 4 | 5 | TOPIC = 'lock_command' 6 | 7 | commands = { 8 | 'Windows': { 9 | 'base': 'rundll32.exe user32.dll,LockWorkStation' 10 | }, 11 | 'macOS': { 12 | 'base': 'pmset displaysleepnow' 13 | }, 14 | 'Linux': { 15 | 'gnome': 'gnome-screensaver-command -l', 16 | 'cinnamon': 'cinnamon-screensaver-command -a', 17 | 'i3': 'i3lock' 18 | } 19 | } 20 | 21 | 22 | class LockCommand(Entity): 23 | def Initialize(self): 24 | self.SubscribeToTopic(TOPIC) 25 | 26 | def PostInitialize(self): 27 | self.os = self.GetOS() 28 | self.de = self.GetDE() 29 | 30 | def Callback(self, message): 31 | if self.os in commands: 32 | if self.de in commands[self.os]: 33 | try: 34 | command = commands[self.os][self.de] 35 | process = subprocess.Popen( 36 | command.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE) 37 | except Exception as e: 38 | raise Exception('Error during system lock: ' + str(e)) 39 | else: 40 | raise Exception( 41 | 'No lock command for this Desktop Environment: ' + self.de) 42 | else: 43 | raise Exception( 44 | 'No lock command for this Operating System: ' + self.os) 45 | 46 | def GetOS(self): 47 | # Get OS from OsSensor and get temperature based on the os 48 | os = self.FindEntity('Os') 49 | if os: 50 | if not os.postinitializeState: # I run this function in post initialize so the os sensor might not be ready 51 | os.CallPostInitialize() 52 | os.CallUpdate() 53 | return os.GetTopicValue() 54 | 55 | def GetDE(self): 56 | # Get OS from OsSensor and get temperature based on the os 57 | de = self.FindEntity( 58 | 'DesktopEnvironment') 59 | if de: 60 | if not de.postinitializeState: # I run this function in post initialize so the os sensor might not be ready 61 | de.CallPostInitialize() 62 | de.CallUpdate() 63 | return de.GetTopicValue() 64 | -------------------------------------------------------------------------------- /Entities/Commands/LockCommand/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | sensors: 3 | - Os: 4 | dont_send: True 5 | - DesktopEnvironment: 6 | dont_send: True 7 | 8 | discovery: 9 | homeassistant: # Must match discovery preset name 10 | - topic: "*" 11 | payload: 12 | name: "Lock" 13 | icon: "mdi:lock" -------------------------------------------------------------------------------- /Entities/Commands/NotifyCommand/NotifyCommand.py: -------------------------------------------------------------------------------- 1 | import os 2 | from Entities.Entity import Entity 3 | from Logger import Logger, ExceptionTracker 4 | 5 | supports_win = True 6 | try: 7 | import win10toast 8 | except: 9 | supports_win = False 10 | 11 | 12 | supports_unix = True 13 | try: 14 | import notify2 # Only to get windows temperature 15 | except: 16 | supports_unix = False 17 | 18 | 19 | TOPIC = 'notify' 20 | 21 | # If I haven't value for the notification I use these 22 | DEFAULT_MESSAGE = 'Notification' 23 | DEFAULT_TITLE = 'PyMonitorMQTT' 24 | 25 | DEFAULT_DURATION = 10 # Seconds 26 | 27 | # SAME KEYS MUST BE PLACED IN THE PAYLOAD OF THE MESSAGE IF YOU WANT TO PASS FROM THERE 28 | CONTENTS_TITLE_OPTION_KEY = "title" 29 | CONTENTS_MESSAGE_OPTION_KEY = "message" 30 | 31 | class NotifyCommand(Entity): 32 | def Initialize(self): 33 | self.SubscribeToTopic(TOPIC) 34 | 35 | # I have also contents with title and message (optional) in config 36 | def EntitySchema(self): 37 | schema = super().EntitySchema() 38 | schema = schema.extend({ 39 | self.schemas.Optional(self.consts.CONTENTS_OPTION_KEY): { 40 | self.schemas.Optional(CONTENTS_TITLE_OPTION_KEY): str, 41 | self.schemas.Optional(CONTENTS_MESSAGE_OPTION_KEY): str 42 | } 43 | }) 44 | return schema 45 | 46 | # I need it here cause I have to check the right import for my OS (and I may not know the OS in Init function) 47 | def PostInitialize(self): 48 | self.os = self.GetOS() 49 | if self.os == self.consts.FIXED_VALUE_OS_WINDOWS: 50 | if not supports_win: 51 | raise Exception( 52 | 'Notify not available, have you installed \'win10toast\' on pip ?') 53 | elif self.os == self.consts.FIXED_VALUE_OS_LINUX: 54 | if supports_unix: 55 | # Init notify2 56 | notify2.init('PyMonitorMQTT') 57 | else: 58 | raise Exception( 59 | 'Notify not available, have you installed \'notify2\' on pip ?') 60 | 61 | def Callback(self, message): 62 | # TO DO 63 | # Convert the payload in a dict 64 | messageDict = '' 65 | try: 66 | messageDict = eval(message.payload.decode('utf-8')) 67 | except: 68 | pass # No message or title in the payload 69 | 70 | # Priority for configuration content and title. If not set there, will try to find them in the payload 71 | 72 | # Look for notification content 73 | if self.GetOption([self.consts.CONTENTS_OPTION_KEY,CONTENTS_MESSAGE_OPTION_KEY]): # In config ? 74 | content = self.GetOption([self.consts.CONTENTS_OPTION_KEY,CONTENTS_MESSAGE_OPTION_KEY]) 75 | elif CONTENTS_MESSAGE_OPTION_KEY in messageDict: # In the payload ? 76 | content = messageDict[CONTENTS_MESSAGE_OPTION_KEY] 77 | else: # Nothing found: use default 78 | content = DEFAULT_MESSAGE 79 | self.Log(Logger.LOG_WARNING, 80 | 'No message for the notification set in configuration or in the received payload') 81 | 82 | # Look for notification title 83 | if self.GetOption([self.consts.CONTENTS_OPTION_KEY,CONTENTS_TITLE_OPTION_KEY]): # In config ? 84 | title = self.GetOption([self.consts.CONTENTS_OPTION_KEY,CONTENTS_TITLE_OPTION_KEY]) 85 | elif CONTENTS_TITLE_OPTION_KEY in messageDict: # In the payload ? 86 | title = messageDict[CONTENTS_TITLE_OPTION_KEY] 87 | else: # Nothing found: use default 88 | title = DEFAULT_TITLE 89 | 90 | # Check only the os (if it's that os, it's supported because if it wasn't supported, 91 | # an exception would be thrown in post-inits) 92 | if self.os == self.consts.FIXED_VALUE_OS_WINDOWS: 93 | toaster = win10toast.ToastNotifier() 94 | toaster.show_toast( 95 | title, content, duration=DEFAULT_DURATION, threaded=False) 96 | elif self.os == self.consts.FIXED_VALUE_OS_LINUX: 97 | notification = notify2.Notification(title, content) 98 | notification.show() 99 | elif self.os == self.consts.FIXED_VALUE_OS_MACOS: 100 | command = 'osascript -e \'display notification "{}" with title "{}"\''.format( 101 | content, title) 102 | os.system(command) 103 | else: 104 | self.Log(self.Logger.LOG_WARNING,"No notify command available for this operating system ("+ str(self.os) +")... Aborting") 105 | 106 | def GetOS(self): 107 | # Get OS from OsSensor and get temperature based on the os 108 | os = self.FindEntity('Os') 109 | if os: 110 | if not os.postinitializeState: # I run this function in post initialize so the os sensor might not be ready 111 | os.CallPostInitialize() 112 | os.CallUpdate() 113 | return os.GetTopicValue() 114 | -------------------------------------------------------------------------------- /Entities/Commands/NotifyCommand/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | sensors: 3 | - Os: 4 | dont_send: True 5 | 6 | discovery: 7 | homeassistant: # Must match discovery preset name 8 | - topic: "*" 9 | payload: 10 | name: "Notify" 11 | icon: "mdi:forum" -------------------------------------------------------------------------------- /Entities/Commands/RebootCommand/RebootCommand.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from Entities.Entity import Entity 3 | 4 | TOPIC = 'reboot_command' 5 | 6 | commands = { 7 | 'Windows': 'shutdown /r', 8 | 'macOS': 'sudo reboot', 9 | 'Linux': 'sudo reboot' 10 | } 11 | 12 | 13 | class RebootCommand(Entity): 14 | def Initialize(self): 15 | self.SubscribeToTopic(TOPIC) 16 | 17 | def PostInitialize(self): 18 | self.os = self.GetOS() 19 | 20 | def Callback(self, message): 21 | try: 22 | command = commands[self.os] 23 | subprocess.Popen(command.split(), stdout=subprocess.PIPE) 24 | except: 25 | raise Exception( 26 | 'No reboot command for this Operating System') 27 | 28 | def GetOS(self): 29 | # Get OS from OsSensor and get temperature based on the os 30 | os = self.FindEntity('Os') 31 | if os: 32 | if not os.postinitializeState: # I run this function in post initialize so the os sensor might not be ready 33 | os.CallPostInitialize() 34 | os.CallUpdate() 35 | return os.GetTopicValue() 36 | -------------------------------------------------------------------------------- /Entities/Commands/RebootCommand/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | sensors: 3 | - Os: 4 | dont_send: True 5 | 6 | discovery: 7 | homeassistant: # Must match discovery preset name 8 | - topic: "*" 9 | payload: 10 | name: "Reboot" 11 | icon: "mdi:restart" -------------------------------------------------------------------------------- /Entities/Commands/ShutdownCommand/ShutdownCommand.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from Entities.Entity import Entity 3 | 4 | TOPIC = 'shutdown_command' 5 | 6 | 7 | commands = { 8 | 'Windows': 'shutdown /s /t 0', 9 | 'macOS': 'sudo shutdown -h now', 10 | 'Linux': 'sudo shutdown -h now' 11 | } 12 | 13 | 14 | class ShutdownCommand(Entity): 15 | def Initialize(self): 16 | self.SubscribeToTopic(TOPIC) 17 | 18 | def PostInitialize(self): 19 | self.os = self.GetOS() 20 | 21 | def Callback(self, message): 22 | try: 23 | command = commands[self.os] 24 | subprocess.Popen(command.split(), stdout=subprocess.PIPE) 25 | except: 26 | raise Exception( 27 | 'No shutdown command for this Operating System') 28 | 29 | def GetOS(self): 30 | # Get OS from OsSensor and get temperature based on the os 31 | os = self.FindEntity('Os') 32 | if os: 33 | if not os.postinitializeState: # I run this function in post initialize so the os sensor might not be ready 34 | os.CallPostInitialize() 35 | os.CallUpdate() 36 | return os.GetTopicValue() -------------------------------------------------------------------------------- /Entities/Commands/ShutdownCommand/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | sensors: 3 | - Os: 4 | dont_send: True 5 | 6 | discovery: 7 | homeassistant: # Must match discovery preset name 8 | - topic: "*" 9 | payload: 10 | name: "Shutdown" 11 | icon: "mdi:power" -------------------------------------------------------------------------------- /Entities/Commands/SleepCommand/SleepCommand.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import os as sys_os 3 | from Entities.Entity import Entity 4 | 5 | TOPIC = 'sleep_command' 6 | 7 | commands = { 8 | 'Windows': 'rundll32.exe powrprof.dll,SetSuspendState 0,1,0', 9 | 'Linux_X11': 'xset dpms force standby' 10 | } 11 | 12 | 13 | class SleepCommand(Entity): 14 | def Initialize(self): 15 | self.SubscribeToTopic(TOPIC) 16 | 17 | def PostInitialize(self): 18 | self.os=self.GetOS() 19 | 20 | def Callback(self, message): 21 | try: 22 | prefix = '' 23 | 24 | # Additional linux checking to find Window Manager 25 | # TODO: Update TurnOffMonitors, TurnOnMonitors, ShutdownCommand, LockCommand to use prefix lookup below 26 | if self.os == 'Linux': 27 | # Check running X11 28 | if sys_os.environ.get('DISPLAY'): 29 | prefix = '_X11' 30 | 31 | lookup_key = self.os + prefix 32 | command = commands[lookup_key] 33 | subprocess.Popen(command.split(), stdout=subprocess.PIPE) 34 | 35 | except: 36 | raise Exception( 37 | 'No Sleep command for this Operating System') 38 | 39 | def GetOS(self): 40 | # Get OS from OsSensor and get temperature based on the os 41 | os = self.FindEntity('Os') 42 | if os: 43 | if not os.postinitializeState: # I run this function in post initialize so the os sensor might not be ready 44 | os.CallPostInitialize() 45 | os.CallUpdate() 46 | return os.GetTopicValue() 47 | -------------------------------------------------------------------------------- /Entities/Commands/SleepCommand/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | sensors: 3 | - Os: 4 | dont_send: True 5 | 6 | discovery: 7 | homeassistant: # Must match discovery preset name 8 | - topic: "*" 9 | payload: 10 | name: "Sleep" 11 | icon: "mdi:power-sleep" -------------------------------------------------------------------------------- /Entities/Commands/TerminalCommand/TerminalCommand.py: -------------------------------------------------------------------------------- 1 | from Entities.Entity import Entity 2 | import subprocess 3 | import fnmatch 4 | from Logger import Logger, ExceptionTracker 5 | 6 | TOPIC = 'terminal_command' 7 | 8 | CONTENTS_COMMAND_OPTION_KEY = 'command' 9 | 10 | CONTENTS_WHITELIST_OPTION_KEY = 'whitelist' 11 | WHITELIST_DENY = 'deny' 12 | WHITELIST_ALLOW = 'allow' 13 | # Config content: 'whitelist' 14 | # 'whitelist' accepts: deny, allow, or allowed commands regex rules 15 | 16 | 17 | class TerminalCommand(Entity): 18 | 19 | def Initialize(self): 20 | self.SubscribeToTopic(TOPIC) 21 | 22 | # I have also contents with title and message (optional) in config 23 | def EntitySchema(self): 24 | schema = super().EntitySchema() 25 | schema = schema.extend({ 26 | self.schemas.Required(self.consts.CONTENTS_OPTION_KEY): { # One of the lower keys is required then contents is required 27 | self.schemas.Optional(CONTENTS_WHITELIST_OPTION_KEY): self.schemas.Or(str,dict), # Whitelist required only if message not in configuration 28 | self.schemas.Optional(CONTENTS_COMMAND_OPTION_KEY): str # Command optional becuase can be also in the payload 29 | } 30 | }) 31 | return schema 32 | 33 | 34 | def Callback(self, message): 35 | messageDict = '' 36 | try: 37 | messageDict = eval(message.payload.decode('utf-8')) 38 | except: 39 | pass # No message in the payload 40 | 41 | # Look for the command 42 | # At first check if defined in options 43 | if self.GetOption([self.consts.CONTENTS_OPTION_KEY, CONTENTS_COMMAND_OPTION_KEY]): 44 | command = self.GetOption( 45 | [self.consts.CONTENTS_OPTION_KEY, CONTENTS_COMMAND_OPTION_KEY]) 46 | self.ExecuteCommand(command) 47 | # Else check if I received the command: if yes, it must be in the commands whitelist (SECURITY) 48 | elif 'command' in messageDict: 49 | # Check if I have the whitelist 50 | whitelist = self.GetOption( 51 | [self.consts.CONTENTS_OPTION_KEY, CONTENTS_WHITELIST_OPTION_KEY]) 52 | if (whitelist): 53 | # Check if the command is in the whitelist: I have to check only for the filename, not for the arguments 54 | # Disallow 55 | if str(whitelist) == WHITELIST_DENY: 56 | self.Log( 57 | Logger.LOG_WARNING, 'Command not executed: whitelist deny') 58 | # Check if allow 59 | elif str(whitelist) == WHITELIST_ALLOW: 60 | content = messageDict['command'] 61 | self.ExecuteCommand(content) 62 | # Check if in list: wildcard check 63 | # and messageDict['command'].split()[0] in whitelist) 64 | elif type(whitelist) == list: 65 | for rule in whitelist: 66 | if fnmatch.fnmatch(messageDict['command'], rule): 67 | content = messageDict['command'] 68 | self.ExecuteCommand(content) 69 | return 70 | self.Log(Logger.LOG_WARNING, "Command not in whitelist: " + 71 | messageDict['command'].strip()) 72 | else: 73 | self.Log( 74 | Logger.LOG_WARNING, 'You must specify a whitelist to send the command through message') 75 | else: 76 | self.Log(Logger.LOG_WARNING, 77 | 'No valid terminal command received/set') 78 | 79 | def ExecuteCommand(self, command): 80 | try: 81 | subprocess.Popen(command.split(), stdout=subprocess.PIPE) 82 | self.Log(Logger.LOG_INFO, "Command executed: " + command) 83 | except: 84 | self.Log(Logger.LOG_WARNING, 85 | "Error during command execution: " + command) 86 | -------------------------------------------------------------------------------- /Entities/Commands/TerminalCommand/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | 3 | discovery: 4 | homeassistant: # Must match discovery preset name 5 | - topic: "*" 6 | payload: 7 | name: "Terminal command" 8 | icon: "mdi:console" -------------------------------------------------------------------------------- /Entities/Commands/TurnOffMonitorsCommand/TurnOffMonitorsCommand.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import ctypes 3 | import os as sys_os 4 | from Entities.Entity import Entity 5 | from ctypes import * 6 | 7 | TOPIC = 'turn_off_monitors_command' 8 | 9 | class TurnOffMonitorsCommand(Entity): 10 | def Initialize(self): 11 | self.SubscribeToTopic(TOPIC) 12 | 13 | def PostInitialize(self): 14 | self.os = self.GetOS() 15 | 16 | def Callback(self, message): 17 | if self.os == 'Windows': 18 | ctypes.windll.user32.SendMessageA(0xFFFF, 0x0112, 0xF170, 2) 19 | elif self.os == 'Linux': 20 | # Check if X11 or something else 21 | if sys_os.environ.get('DISPLAY'): 22 | command = 'xset dpms force off' 23 | subprocess.Popen(command.split(), stdout=subprocess.PIPE) 24 | else: 25 | raise Exception( 26 | 'The Turn Off Monitors command is not available for this Linux Window System') 27 | 28 | else: 29 | raise Exception( 30 | 'The Turn Off Monitors command is not available for this Operating System') 31 | 32 | def GetOS(self): 33 | # Get OS from OsSensor and get temperature based on the os 34 | os = self.FindEntity('Os') 35 | if os: 36 | if not os.postinitializeState: # I run this function in post initialize so the os sensor might not be ready 37 | os.CallPostInitialize() 38 | os.CallUpdate() 39 | return os.GetTopicValue() 40 | 41 | 42 | -------------------------------------------------------------------------------- /Entities/Commands/TurnOffMonitorsCommand/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | sensors: 3 | - Os: 4 | dont_send: True 5 | 6 | discovery: 7 | homeassistant: # Must match discovery preset name 8 | - topic: "*" 9 | payload: 10 | name: "Turn off monitors" 11 | icon: "mdi:monitor-off" -------------------------------------------------------------------------------- /Entities/Commands/TurnOnMonitorsCommand/TurnOnMonitorsCommand.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import ctypes 3 | import os as sys_os 4 | from Entities.Entity import Entity 5 | from ctypes import * 6 | 7 | TOPIC = 'turn_on_monitors_command' 8 | 9 | 10 | class TurnOnMonitorsCommand(Entity): 11 | def Initialize(self): 12 | self.SubscribeToTopic(TOPIC) 13 | 14 | def PostInitialize(self): 15 | self.os = self.GetOS() 16 | 17 | def Callback(self, message): 18 | if self.os == 'Windows': 19 | ctypes.windll.user32.SendMessageA(0xFFFF, 0x0112, 0xF170, -1) # Untested 20 | elif self.os == 'Linux': 21 | # Check if X11 or something else 22 | if sys_os.environ.get('DISPLAY'): 23 | command = 'xset dpms force on' 24 | subprocess.Popen(command.split(), stdout=subprocess.PIPE) 25 | else: 26 | raise Exception( 27 | 'The Turn ON Monitors command is not available for this Linux Window System') 28 | 29 | else: 30 | raise Exception( 31 | 'The Turn ON Monitors command is not available for this Operating System') 32 | 33 | def GetOS(self): 34 | # Get OS from OsSensor and get temperature based on the os 35 | os = self.FindEntity('Os') 36 | if os: 37 | if not os.postinitializeState: # I run this function in post initialize so the os sensor might not be ready 38 | os.CallPostInitialize() 39 | os.CallUpdate() 40 | return os.GetTopicValue() 41 | -------------------------------------------------------------------------------- /Entities/Commands/TurnOnMonitorsCommand/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | sensors: 3 | - Os: 4 | dont_send: True 5 | 6 | discovery: 7 | homeassistant: # Must match discovery preset name 8 | - topic: "*" 9 | payload: 10 | name: "Turn on monitors" 11 | icon: "mdi:monitor-star" -------------------------------------------------------------------------------- /Entities/Entity.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from Logger import Logger, ExceptionTracker 3 | import json 4 | from Configurator import Configurator as cf 5 | import sys 6 | import yaml 7 | import hashlib 8 | from os import path 9 | import consts 10 | 11 | 12 | class Entity(): 13 | import voluptuous 14 | import consts 15 | import schemas 16 | from Settings import Settings 17 | from ValueFormatter import ValueFormatter 18 | from Logger import Logger, ExceptionTracker 19 | from Configurator import Configurator 20 | 21 | # To replace an original topic with a personalized one from configuration (may not be used). 22 | # When a sensor send the data with a topic, if the user choose a fixed topic in config, 23 | # then when I send the data I don't use the topic defined in the function but I replaced that 24 | # with the user's one that I store in this list of dict 25 | lastSendingTime = None 26 | lastDiscoveryTime = None 27 | 28 | 29 | def __init__(self, monitor_id, brokerConfigs, mqtt_client, send_interval, entityConfigs, logger, entityManager, entityType=None): # Config is args 30 | self.initializeState=False 31 | self.postinitializeState=False 32 | 33 | self.name = self.GetEntityName(entityType) 34 | self.monitor_id = monitor_id 35 | 36 | self.replacedTopics = [] 37 | self.outTopics = [] # List of {topic, value} 38 | self.inTopics = [] # Only used for discovery, real used list is in the mqtt client where I have a list with topic-callback 39 | self.outTopicsAddedNumber = 0 40 | self.inTopicsAddedNumber = 0 # was subscribedTopics 41 | 42 | self.brokerConfigs = brokerConfigs 43 | self.entityConfigs = entityConfigs 44 | self.options = {} 45 | 46 | self.mqtt_client = mqtt_client 47 | self.send_interval = send_interval 48 | self.logger = logger 49 | self.entityManager = entityManager 50 | 51 | # Get for some features the pathof the folder cutting the py filename (abs path to avoid windows problems) 52 | self.individualPath = path.dirname(path.abspath( 53 | sys.modules[self.__class__.__module__].__file__)) 54 | 55 | # Do per sensor operations 56 | 57 | # First thing: validate entity configuration and set the entity config to the validated config (with defaults) 58 | self.ValidateSchema() 59 | 60 | # Then load the options in the entity from the configuration file 61 | self.ParseOptions() 62 | 63 | self.Log(self.Logger.LOG_DEVELOPMENT,"Options founds:") 64 | self.Log(self.Logger.LOG_DEVELOPMENT,self.options) 65 | 66 | self.CallInitialize() 67 | 68 | def Initialize(self): # Implemented in sub-classes 69 | pass 70 | 71 | def CallInitialize(self): 72 | try: 73 | self.Initialize() 74 | self.initializeState=True 75 | self.Log(Logger.LOG_INFO,"Initialization successfully completed") 76 | except Exception as e: 77 | self.Log(Logger.LOG_ERROR,"Initialization interrupted due to an error") 78 | self.Log(Logger.LOG_ERROR, 79 | ExceptionTracker.TrackString(e)) 80 | del(self) 81 | 82 | 83 | # Implemented in sub-classes 84 | def Callback(self, message): # Run by the OnMessageEvent 85 | pass 86 | 87 | 88 | def PostInitialize(self): # Implemented in sub-classes 89 | pass 90 | 91 | def CallPostInitialize(self): 92 | try: 93 | self.PostInitialize() 94 | self.postinitializeState=True 95 | self.Log(Logger.LOG_INFO,"Post-initialization successfully completed") 96 | except Exception as e: 97 | self.Log(Logger.LOG_ERROR,"Post-initialization interrupted due to an error") 98 | self.Log(Logger.LOG_ERROR, 99 | ExceptionTracker.TrackString(e)) 100 | del(self) 101 | 102 | 103 | 104 | # Function that returns the default schema if not implemented directly in each entity 105 | def EntitySchema(self): # Can be implemented in sub-entity 106 | return self.GetDefaultEntitySchema() 107 | 108 | 109 | def ValidateSchema(self): 110 | try: 111 | self.Log(Logger.LOG_INFO,"Validating configuration...") 112 | if self.entityConfigs is not None: 113 | self.entityConfigs = self.EntitySchema()(self.entityConfigs) # Validate with the entity config and set the entity config to the validated config (with defaults) 114 | self.Log(Logger.LOG_INFO,"Validation successfully completed") 115 | except Exception as e: 116 | self.Log(Logger.LOG_ERROR,"Error while validating entity configuration: " +str(e)) 117 | raise Exception("Can't validate " + self.name + " configuration. Check your configuration.yaml file") 118 | 119 | 120 | # Can be edited from sub sensors to edit different options of the discovery data 121 | def ManageDiscoveryData(self, discovery_data): 122 | return discovery_data 123 | 124 | def ParseOptions(self): 125 | # I can have options both in broker configs and single sensor configs 126 | # At first I search in broker config. Then I check the per-sensor option and if I find 127 | # something there, I replace - if was set from first step - broker configs (or simply add a new entry) 128 | 129 | for optionToSearch in self.consts.SCAN_OPTIONS: 130 | # 1: Set from broker's configs 131 | if optionToSearch in self.brokerConfigs: 132 | if type(self.brokerConfigs[optionToSearch])==dict: # Id dict I have to copy to avoid errors 133 | self.options[optionToSearch]=self.brokerConfigs[optionToSearch].copy() 134 | else: 135 | self.options[optionToSearch]=self.brokerConfigs[optionToSearch] 136 | 137 | # 2: Set from entity's configs: join to previous value if was set 138 | if self.entityConfigs and optionToSearch in self.entityConfigs: 139 | if optionToSearch in self.options: # If I've just found this option in monitors config, then I have to add the entity config to the previous set options 140 | self.options[optionToSearch]=self.JoinDictsOrLists(self.options[optionToSearch],self.entityConfigs[optionToSearch]) 141 | else: # else I have only the entity config -> I set the option to that 142 | self.options[optionToSearch]=self.entityConfigs[optionToSearch] 143 | 144 | def GetOption(self, path, defaultReturnValue=None): 145 | return cf.GetOption(self.options, path, defaultReturnValue) 146 | 147 | def ListTopics(self): 148 | return self.outTopics 149 | 150 | def AddTopic(self, topic): 151 | self.outTopicsAddedNumber += 1 152 | # If user in options defined custom topics, store original and custom topic and replace it in the send function 153 | if self.GetOption(self.consts.CUSTOM_TOPICS_OPTION_KEY) is not None and len(self.GetOption(self.consts.CUSTOM_TOPICS_OPTION_KEY)) >= self.outTopicsAddedNumber: 154 | self.AddReplacedTopic(topic,self.GetOption(self.consts.CUSTOM_TOPICS_OPTION_KEY)[self.outTopicsAddedNumber-1]) 155 | self.Log(Logger.LOG_INFO, 'Using custom topic defined in options') 156 | 157 | self.outTopics.append({'topic': topic, 'value': ""}) 158 | 159 | self.Log(Logger.LOG_DEVELOPMENT, "Adding topic: " + topic) 160 | self.Log(Logger.LOG_DEVELOPMENT, 161 | "Discovery topic normalizer: " + topic.replace("/", "_")) 162 | 163 | def SubscribeToTopic(self, topic): 164 | self.inTopics.append(topic) 165 | self.inTopicsAddedNumber += 1 166 | 167 | 168 | # If user in options defined custom topics, use them and not the one choosen in the command 169 | if self.GetOption(self.consts.CUSTOM_TOPICS_OPTION_KEY) and len(self.GetOption(self.consts.CUSTOM_TOPICS_OPTION_KEY)) >= self.inTopicsAddedNumber: 170 | original=topic 171 | topic = self.GetOption(self.consts.CUSTOM_TOPICS_OPTION_KEY)[ 172 | self.inTopicsAddedNumber-1] 173 | self.AddReplacedTopic(original,topic) # Add to edited topics 174 | self.Log(Logger.LOG_INFO, 'Using custom topic defined in options') 175 | else: # If I don't have a custom topic for this, use the default topic format 176 | topic = self.FormatTopic(topic) 177 | 178 | self.mqtt_client.AddNewTopic(topic, self) 179 | 180 | # Log the topic as debug if user wants 181 | self.Log(Logger.LOG_DEBUG, 'Subscribed to topic: ' + topic) 182 | 183 | return topic # Return the topic cause upper function should now that topic may have been edited 184 | 185 | 186 | def RemoveOutboundTopic(self,topic): # Should receive the element in the list outTopics: string with the original topic (like 'message_time') 187 | if type(topic) == str: # Not the topic,value combo -> get the combo 188 | for top in self.outTopics: 189 | if top['topic']==topic: 190 | topic=top 191 | 192 | if topic in self.outTopics: 193 | self.outTopicsAddedNumber -= 1 194 | self.Log(Logger.LOG_DEBUG,"Removing topic: " + topic['topic']) 195 | self.outTopics.remove(topic) 196 | 197 | def RemoveInboundTopic(self,topic): # Should receive the element in the list inTopics: string with the original topic (like 'lock_command') 198 | if topic in self.inTopics: 199 | self.inTopicsAddedNumber-=1 200 | self.Log(Logger.LOG_DEBUG,"Unsubscribed to topic: " + topic) 201 | self.inTopics.remove(topic) 202 | self.mqtt_client.UnsubscribeToTopic(self.SelectTopic(topic)) # The client has to remove the full topic (customized) 203 | 204 | def AddReplacedTopic(self,original,custom): 205 | self.replacedTopics.append( 206 | {'original': original, 'custom': custom}) 207 | 208 | def GetFirstTopic(self): 209 | return self.outTopics[0]['topic'] if len(self.outTopics) else None 210 | 211 | def GetTopicByName(self, name): 212 | # Using topic string, I get his dict from topics list 213 | for topic in self.outTopics: 214 | if topic['topic'] == name: 215 | return topic 216 | return None 217 | 218 | def GetTopicValue(self, topic_name=None): 219 | if not topic_name: 220 | topic_name = self.GetFirstTopic() 221 | 222 | topic = self.GetTopicByName(topic_name) 223 | if topic: 224 | return topic['value'] 225 | else: 226 | return None 227 | 228 | 229 | # valueType, valueSize, forceValueFormatter are for the ValueFormatter and are not required 230 | def SetTopicValue(self, topic_name, value, valueType=None): 231 | # At first using topic string, I get his dict from topics list 232 | topic = self.GetTopicByName(topic_name) 233 | if topic: # Found 234 | 235 | if valueType is not None: 236 | # If user defined in options he wants size / unit of measurement (1200 [in Byte] -> 1,2KB) 237 | value = self.ValueFormatter.GetFormattedValue(value,valueType, self.GetValueFormatterOptionForTopic(topic_name)) 238 | # I pass the options from format_value that I need 239 | 240 | # Set the value 241 | topic['value'] = value 242 | else: # Not found, log error 243 | self.Log(Logger.LOG_ERROR, 'Topic ' + 244 | topic_name + ' does not exist !') 245 | 246 | def GetValueFormatterOptionForTopic(self,valueTopic): # Return the ValueFormat options for the passed topic 247 | VFoptions = self.GetOption(self.consts.VALUE_FORMAT_OPTION_KEY) 248 | # if the options are not in a list: specified options are for every topic 249 | if type(VFoptions) is not list: 250 | return VFoptions 251 | else: 252 | # I have the same structure (topic with wildcard and configs) that I have for the topic settings in discovery 253 | for topicOptions in VFoptions: 254 | optionTopic = cf.GetOption(topicOptions,"topic") 255 | if optionTopic == "*" or optionTopic==valueTopic: 256 | return topicOptions 257 | return None 258 | return None 259 | 260 | def CallUpdate(self): # Call the Update method safely 261 | try: 262 | self.Update() 263 | except Exception as exc: 264 | self.Log(Logger.LOG_ERROR, 'Error occured during update') 265 | self.Log(Logger.LOG_ERROR, ExceptionTracker.TrackString(exc)) 266 | self.entityManager.UnloadEntity(self) 267 | 268 | def Update(self): # Implemented in sub-classes - Here values are taken 269 | self.Log(Logger.LOG_WARNING, 'Update method not implemented') 270 | pass # Must not be called directly, cause stops everything in exception, call only using CallUpdate 271 | 272 | def CallCallback(self, message): # Safe method to run the Callback 273 | try: 274 | self.Log(Logger.LOG_INFO, 'Command actioned') 275 | self.Callback(message) 276 | except Exception as exc: 277 | self.Log(Logger.LOG_ERROR, 'Error occured in callback: '+str(exc)) 278 | self.Log(Logger.LOG_ERROR, ExceptionTracker.TrackString(exc)) 279 | self.entityConfigs.UnloadEntity(self) 280 | 281 | def SelectTopic(self, topic): 282 | # for a topic look for its customized topic and return it if there's. Else return the default one but completed with FormatTopic 283 | 284 | if(type(topic) == dict): 285 | checkTopic = topic['topic'] 286 | else: 287 | checkTopic = topic 288 | 289 | for customs in self.replacedTopics: 290 | # If it's in the list of topics to replace 291 | if checkTopic == customs['original']: 292 | return customs['custom'] 293 | 294 | return self.FormatTopic(checkTopic) 295 | 296 | def SendData(self): 297 | if self.GetOption('dont_send') is True: 298 | return # Don't send if disabled in config 299 | 300 | if self.mqtt_client is not None: 301 | for topic in self.outTopics: # Send data for all topic 302 | 303 | # For each topic I check if I send to that or if it has to be replaced with a custom topic defined in options 304 | topicToUse = self.SelectTopic(topic) 305 | 306 | # Log the topic as debug if it's on 307 | if 'debug' in self.brokerConfigs and self.brokerConfigs['debug'] is True: 308 | self.Log(Logger.LOG_DEBUG, "Sending data to " + topicToUse) 309 | 310 | self.mqtt_client.SendTopicData( 311 | topicToUse, topic['value']) 312 | 313 | def FindEntities(self, name): # Find active entities for some specific action 314 | if(self.entityManager): 315 | return self.entityManager.FindEntities(name, self.monitor_id) 316 | else: 317 | self.Log(Logger.LOG_ERROR, 318 | 'EntityManager not set!') 319 | return None 320 | 321 | def FindEntity(self, name): # Return first found entity from FindEntities 322 | if(self.entityManager): 323 | entities = self.FindEntities(name) 324 | if(len(entities)): 325 | return entities[0] 326 | else: 327 | return None 328 | else: 329 | self.Log(Logger.LOG_ERROR, 330 | 'EntityManager not set!') 331 | return None 332 | 333 | def FormatTopic(self, last_part_of_topic): 334 | model = self.consts.TOPIC_FORMAT 335 | if 'topic_prefix' in self.brokerConfigs: 336 | model = self.brokerConfigs['topic_prefix'] + '/'+model 337 | return model.format(self.brokerConfigs['name'], last_part_of_topic) 338 | 339 | # Calculate if a send_interval spent since the last sending time 340 | def ShouldSendMessage(self): 341 | if self.outTopicsAddedNumber == 0: 342 | return False 343 | 344 | if self.GetLastSendingTime() is None: # Never sent anything 345 | return True # Definitely yes, you should send 346 | else: 347 | # Calculate time elapsed 348 | # Get current time 349 | now = datetime.datetime.now() 350 | # Calculate 351 | seconds_elapsed = (now-self.GetLastSendingTime()).total_seconds() 352 | # Check if now I have to send 353 | if seconds_elapsed >= self.GetSendMessageInterval(): 354 | return True 355 | else: 356 | return False 357 | 358 | def IsDiscoveryEnabled(self): 359 | return cf.GetOption(self.brokerConfigs, [self.consts.CONFIG_DISCOVERY_KEY, self.consts.DISCOVERY_ENABLE_KEY], False) 360 | 361 | # Calculate if a send_interval spent since the last sending time 362 | def ShouldSendDiscoveryConfig(self): 363 | # Check if Discovery is enabled 364 | if self.GetOption([self.consts.CONFIG_DISCOVERY_KEY, self.consts.DISCOVERY_ENABLE_KEY], False) is not False: 365 | # Not for don't send sensors 366 | if self.GetOption('dont_send') is True: 367 | return False # Don't send if disabled in config 368 | if self.GetLastDiscoveryTime() is None: # Never sent anything 369 | return True # Definitely yes, you should send 370 | else: 371 | # Calculate time elapsed 372 | # Get current time 373 | now = datetime.datetime.now() 374 | # Calculate 375 | seconds_elapsed = ( 376 | now-self.GetLastDiscoveryTime()).total_seconds() 377 | # Check if now I have to send 378 | if seconds_elapsed >= self.GetSendDiscoveryConfigInterval(): 379 | return True 380 | else: 381 | return False 382 | else: 383 | return False 384 | 385 | # Save the time when last message is sent. If no time passed, will be used current time 386 | def SaveTimeMessageSent(self, time=None): 387 | if time is not None: 388 | self.lastSendingTime = time 389 | else: 390 | self.lastSendingTime = datetime.datetime.now() 391 | 392 | def SaveTimeDiscoverySent(self, time=None): 393 | if time is not None: 394 | self.lastDiscoveryTime = time 395 | else: 396 | self.lastDiscoveryTime = datetime.datetime.now() 397 | 398 | def GetClassName(self): 399 | # Sensor.SENSORFOLDER.SENSORCLASS 400 | return self.__class__.__name__ 401 | 402 | def GetEntityName(self, suffix): 403 | if suffix==None: 404 | suffix=self.consts.SENSOR_NAME_SUFFIX 405 | 406 | # Only SENSORCLASS (without Sensor suffix) 407 | if self.consts.SENSOR_NAME_SUFFIX in self.GetClassName(): 408 | return self.GetClassName().split(self.consts.SENSOR_NAME_SUFFIX)[0] 409 | elif self.consts.COMMAND_NAME_SUFFIX in self.GetClassName(): 410 | return self.GetClassName().split(self.consts.COMMAND_NAME_SUFFIX)[0] 411 | else: 412 | return self.GetClassName() 413 | 414 | def GetSendMessageInterval(self): 415 | return self.send_interval 416 | 417 | def GetSendDiscoveryConfigInterval(self): 418 | # Search in config or use default 419 | return self.GetOption([self.consts.CONFIG_DISCOVERY_KEY, self.consts.DISCOVERY_PUBLISH_INTERVAL_KEY], self.consts.DISCOVERY_PUBLISH_INTERVAL_DEFAULT) 420 | 421 | def GetMqttClient(self): 422 | return self.mqtt_client 423 | 424 | def GetLogger(self): 425 | return self.logger 426 | 427 | def GetMonitorID(self): 428 | return self.monitor_id 429 | 430 | def GetLastSendingTime(self): 431 | return self.lastSendingTime 432 | 433 | def GetLastDiscoveryTime(self): 434 | return self.lastDiscoveryTime 435 | 436 | # Use this function to ask if a topic is in the replacedTopic list 437 | def TopicHadBeenReplaced(self,topic): 438 | for combo in self.replacedTopics: 439 | if combo['original']==topic: 440 | return True 441 | return False 442 | 443 | def LoadSettings(self): 444 | # 1: Get path of the single object 445 | # 2: If I dont find the yaml in that folder, I return None 446 | # 3: If I find it, I parse the yaml and I return the dict 447 | # Start: 448 | # 1 449 | settings_path = path.join( 450 | self.individualPath, self.consts.OBJECT_SETTINGS_FILENAME) 451 | # try 3 except 2 452 | try: 453 | with open(settings_path) as f: 454 | self.settings = yaml.load(f, Loader=yaml.FullLoader) 455 | except: 456 | self.settings = None 457 | 458 | return self.settings 459 | 460 | 461 | def PrepareDiscoveryPayloads(self): 462 | discovery_data = [] 463 | 464 | # Check if Discovery is enabled 465 | if self.GetOption([self.consts.CONFIG_DISCOVERY_KEY, self.consts.DISCOVERY_ENABLE_KEY], False) is not False: 466 | # Okay need auto discovery 467 | 468 | # Not for don't send sensors 469 | if self.GetOption('dont_send') is True: 470 | return # Don't send if disabled in config 471 | 472 | prefix = self.GetOption([ 473 | self.consts.CONFIG_DISCOVERY_KEY, self.consts.DISCOVERY_DISCOVER_PREFIX_KEY], self.consts.DISCOVERY_DISCOVER_PREFIX_DEFAULT) 474 | preset = self.GetOption([ 475 | self.consts.CONFIG_DISCOVERY_KEY, self.consts.DISCOVERY_PRESET_KEY]) 476 | entity_preset_data = None 477 | 478 | if preset: 479 | # Check here if I have an entry in the discovery file for this topic and use that data (PLACE IN 'sensor_data') 480 | entity_preset_data = cf.GetOption(self.settings,[self.consts.SETTINGS_DISCOVERY_KEY, preset]) # THIS 481 | 482 | for topic in self.outTopics: 483 | # discoveryData: {name, config_topic, payload} 484 | # print(topic) 485 | data = self.PrepareTopicDiscoveryData( 486 | topic['topic'], self.consts.TYPE_TOPIC_OUT, prefix, preset, entity_preset_data) 487 | if data: 488 | discovery_data.append(data) 489 | 490 | for topic in self.inTopics: 491 | # discoveryData: {name, config_topic, payload} 492 | data = self.PrepareTopicDiscoveryData( 493 | topic, self.consts.TYPE_TOPIC_IN, prefix, preset, entity_preset_data) 494 | if data: 495 | discovery_data.append(data) 496 | 497 | return discovery_data 498 | 499 | 500 | def PrepareTopicDiscoveryData(self, topic, entity_model, prefix, preset, entity_preset_data): 501 | payload = {} 502 | topicSettings = None 503 | 504 | # Warning ! Discovery configuration for a single topic could be in: entity settings; user configuration 505 | 506 | # DISCOVERY DATA FROM ENTITY SETTINGS 507 | # Look for custom discovery settings for this sensor, topic and preset: 508 | if entity_preset_data: 509 | for discoveryTopic in entity_preset_data: 510 | dtTopic = cf.GetOption(discoveryTopic, "topic") 511 | if (dtTopic == topic or dtTopic == "*") and cf.GetOption(discoveryTopic, self.consts.SETTINGS_DISCOVERY_PRESET_PAYLOAD_KEY): 512 | # Found dict for this topic in this sensor for this preset: Place in the payload 513 | topicSettings = discoveryTopic 514 | payload = cf.GetOption( 515 | discoveryTopic, self.consts.SETTINGS_DISCOVERY_PRESET_PAYLOAD_KEY).copy() 516 | 517 | # Check for Advanced information topic if I don't send advanced infomration: PS THIS IS USELESS CAUSE THIS TOPIC WON'T BE IN OUTTOPIC IN THAT CASE BUT IT'S BETTER TO CHECK 518 | # If I don't send advanced_information and the topic settings says the topic is advanced, I return None because this entity won't send any message on this topic 519 | if self.GetOption(self.consts.ADVANCED_INFO_OPTION_KEY,False)==False and cf.GetOption(topicSettings,[self.consts.SETTINGS_DISCOVERY_KEY,self.consts.SETTINGS_DISCOVERY_ADVANCED_TOPIC_KEY],False)==True: 520 | return None 521 | 522 | # DISCOVERY DATA FROM USER CONFIGURATION in entityConfig -> discovery -> settings 523 | 524 | # Take user_discovery_config not from options( thaht includes also monitors discovery config but oly from entity configs 525 | user_discovery_config=cf.ReturnAsList(cf.GetOption(self.entityConfigs,[self.consts.ENTITY_DISCOVERY_KEY,self.consts.ENTITY_DISCOVERY_PAYLOAD_KEY]),None) 526 | if user_discovery_config: 527 | for user_topic_config in user_discovery_config: 528 | dtTopic=cf.GetOption(user_topic_config,"topic") 529 | if not dtTopic or dtTopic == topic or dtTopic == "*": 530 | # Copy all the configuration I have in the payload 531 | for key, value in user_topic_config.items(): 532 | if key != "topic": # Avoid topic because is a non-payload information but only to recognise settings 533 | payload[key]=value 534 | 535 | 536 | # If I have to disable, return None 537 | if cf.GetOption(topicSettings, self.consts.SETTINGS_DISCOVERY_PRESET_DISABLE_KEY, False): 538 | return None 539 | 540 | # Do I have the name in the preset settings or do I set it using the default topic ? 541 | if not 'name' in payload: 542 | payload['name'] = topic.replace("/", "_") 543 | 544 | # Check and add this only if has option true 545 | if self.GetOption([self.consts.CONFIG_DISCOVERY_KEY, self.consts.DISCOVERY_NAME_PREFIX_KEY], self.consts.DISCOVERY_NAME_PREFIX_DEFAULT): 546 | payload['name'] = self.brokerConfigs['name'] + \ 547 | " - " + payload['name'] 548 | 549 | # Prepare the part of the config topic after the prefix and the sensortype 550 | topic_component = self.TopicRemoveBadCharacters(self.SelectTopic(topic)) 551 | 552 | payload['device'] = self.GetDiscoveryDeviceData() 553 | 554 | # Unique hashed 555 | payload['unique_id'] = hashlib.md5((self.SelectTopic(topic)).encode('utf-8')).hexdigest() 556 | 557 | if(entity_model == self.consts.TYPE_TOPIC_OUT): 558 | # Do I have the type in the sensor preset settings or do I set it to 'sensor' ? 559 | entity_type = cf.GetOption( 560 | topicSettings, self.consts.SETTINGS_DISCOVERY_PRESET_TYPE_KEY, "sensor") 561 | # Send the topic where the Sensor will send his state 562 | 563 | payload['expire_after']=self.GetOption([self.consts.CONFIG_DISCOVERY_KEY, self.consts.DISCOVERY_EXPIRE_AFTER_KEY], self.consts.DISCOVERY_EXPIRE_AFTER_DEFAULT) 564 | payload['state_topic'] = self.SelectTopic(topic) 565 | else: 566 | # Do I have the type in the sensor preset settings or do I set it to 'sensor' ? 567 | entity_type = cf.GetOption( 568 | topicSettings, self.consts.SETTINGS_DISCOVERY_PRESET_TYPE_KEY, "switch") 569 | # Send the topic where the Switch will receive the message 570 | payload['command_topic'] = self.SelectTopic(topic) 571 | 572 | 573 | # Compose the topic that will be used to send the disoovery configuration 574 | config_send_topic = self.consts.AUTODISCOVERY_TOPIC_CONFIG_FORMAT.format( 575 | prefix, entity_type, topic_component) 576 | 577 | return {"name": topic, "config_topic": config_send_topic, "payload": dict(payload)} 578 | 579 | def GetDiscoveryDeviceData(self): # Add device information 580 | sw_info = self.Settings.GetInformation() 581 | device = {} 582 | device['name'] = "Monitor " + self.brokerConfigs['name'] 583 | device['model'] = self.brokerConfigs['name'] 584 | device['identifiers'] = self.brokerConfigs['name'] 585 | try: 586 | device['manufacturer'] = sw_info['name'] 587 | device['sw_version'] = sw_info['version'] 588 | except: 589 | self.Log(Logger.LOG_WARNING,"No software information file found !") 590 | return device 591 | 592 | # discoveryData: {name, config_topic, payload} 593 | def PublishDiscoveryData(self, discovery_data): 594 | for discovery_entry in discovery_data: 595 | self.mqtt_client.SendTopicData( 596 | discovery_entry['config_topic'], json.dumps(discovery_entry['payload'])) 597 | 598 | def TopicRemoveBadCharacters(self, string): 599 | return string.replace("/", "_").replace(" ", "_").replace("-", "_").lower() 600 | 601 | def Log(self, messageType, message): 602 | self.logger.Log(messageType, self.name + " Entity", message) 603 | 604 | def GetDefaultEntitySchema(self): 605 | return self.schemas.ENTITY_DEFAULT_SCHEMA 606 | 607 | # Used to scan the options and join the found options in the configuration.yaml to the default option value (which is the source) 608 | def JoinDictsOrLists(self,source,toJoin): # If source is a list, join toJoin to the list; if source is a dict, join toJoin keys and values to the source 609 | if type(source)==list: 610 | if type(toJoin)==list: 611 | return source+toJoin 612 | else: 613 | source.append(toJoin) 614 | elif type(source)==dict: 615 | if type(toJoin) ==dict: 616 | for key,value in toJoin.items(): 617 | source[key]=value 618 | else: 619 | source['no_key']=toJoin 620 | return source 621 | return toJoin # If no source recognized, return toJoin -------------------------------------------------------------------------------- /Entities/Sensors/ActiveWindowSensor/ActiveWindowSensor.py: -------------------------------------------------------------------------------- 1 | from Entities.Entity import Entity 2 | 3 | # Linux dep 4 | try: 5 | import os, re, sys 6 | from subprocess import PIPE, Popen 7 | linux_support=True 8 | except: 9 | linux_support=False 10 | 11 | 12 | # Windows dep 13 | try: 14 | from win32gui import GetWindowText, GetForegroundWindow 15 | windows_support=True 16 | except: 17 | windows_support=False 18 | 19 | # macOS dep 20 | try: 21 | from AppKit import NSWorkspace 22 | from Quartz import ( 23 | CGWindowListCopyWindowInfo, 24 | kCGWindowListOptionOnScreenOnly, 25 | kCGNullWindowID 26 | ) 27 | macos_support=True 28 | except: 29 | macos_support=False 30 | 31 | 32 | TOPIC = 'active_window' 33 | 34 | 35 | class ActiveWindowSensor(Entity): 36 | def Initialize(self): 37 | self.AddTopic(TOPIC) 38 | 39 | def PostInitialize(self): 40 | os = self.GetOS() 41 | self.UpdateSpecificFunction = None # Specific function for this os/de, set this here to avoid all if else except at each update 42 | 43 | if os == self.consts.FIXED_VALUE_OS_LINUX: 44 | if linux_support: 45 | self.UpdateSpecificFunction = self.GetActiveWindow_Linux 46 | else: 47 | raise Exception("Unsatisfied dependencies for this entity") 48 | elif os == self.consts.FIXED_VALUE_OS_WINDOWS: 49 | if windows_support: 50 | self.UpdateSpecificFunction = self.GetActiveWindow_Windows 51 | else: 52 | raise Exception("Unsatisfied dependencies for this entity") 53 | elif os == self.consts.FIXED_VALUE_OS_MACOS: 54 | if macos_support: 55 | self.UpdateSpecificFunction = self.GetActiveWindow_macOS 56 | else: 57 | raise Exception("Unsatisfied dependencies for this entity") 58 | else: 59 | raise Exception( 60 | 'Entity not available for this operating system') 61 | 62 | def Update(self): 63 | self.SetTopicValue(TOPIC, str(self.UpdateSpecificFunction())) 64 | 65 | def GetActiveWindow_macOS(self): 66 | curr_app = NSWorkspace.sharedWorkspace().frontmostApplication() 67 | curr_pid = NSWorkspace.sharedWorkspace().activeApplication()['NSApplicationProcessIdentifier'] 68 | curr_app_name = curr_app.localizedName() 69 | options = kCGWindowListOptionOnScreenOnly 70 | windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) 71 | for window in windowList: 72 | pid = window['kCGWindowOwnerPID'] 73 | windowNumber = window['kCGWindowNumber'] 74 | ownerName = window['kCGWindowOwnerName'] 75 | geometry = window['kCGWindowBounds'] 76 | windowTitle = window.get('kCGWindowName', u'Unknown') 77 | if curr_pid == pid: 78 | return windowTitle 79 | 80 | 81 | def GetActiveWindow_Windows(self): 82 | return GetWindowText(GetForegroundWindow()) 83 | 84 | def GetActiveWindow_Linux(self): 85 | root = Popen( ['xprop', '-root', '_NET_ACTIVE_WINDOW'], stdout = PIPE ) 86 | stdout, stderr = root.communicate() 87 | 88 | m = re.search( b'^_NET_ACTIVE_WINDOW.* ([\w]+)$', stdout ) 89 | 90 | if m is not None: 91 | window_id = m.group( 1 ) 92 | window = Popen( ['xprop', '-id', window_id, 'WM_NAME'], stdout = PIPE ) 93 | stdout, stderr = window.communicate() 94 | 95 | match = re.match( b'WM_NAME\(\w+\) = (?P.+)$', stdout ) 96 | if match is not None: 97 | return match.group( 'name' ).decode( 'UTF-8' ).strip( '"' ) 98 | 99 | return 'Inactive' 100 | 101 | def GetOS(self): 102 | # Get OS from OsSensor and get temperature based on the os 103 | os = self.FindEntity('Os') 104 | if os: 105 | if not os.postinitializeState: # I run this function in post initialize so the os sensor might not be ready 106 | os.CallPostInitialize() 107 | os.CallUpdate() 108 | return os.GetTopicValue() 109 | -------------------------------------------------------------------------------- /Entities/Sensors/ActiveWindowSensor/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | sensors: 3 | - Os: 4 | dont_send: True 5 | 6 | discovery: 7 | homeassistant: # Must match discovery preset name 8 | - topic: "*" 9 | payload: 10 | name: "Active window" 11 | icon: "mdi:window-restore" -------------------------------------------------------------------------------- /Entities/Sensors/BatterySensor/BatterySensor.py: -------------------------------------------------------------------------------- 1 | import psutil 2 | from Entities.Entity import Entity 3 | 4 | 5 | TOPIC_PERCENTAGE = 'battery/battery_level_percentage' 6 | TOPIC_CHARGING_STATUS = 'battery/battery_charging' 7 | 8 | 9 | class BatterySensor(Entity): 10 | def Initialize(self): 11 | self.AddTopic(TOPIC_PERCENTAGE) 12 | self.AddTopic(TOPIC_CHARGING_STATUS) 13 | 14 | def PostInitialize(self): 15 | # Check if battery infomration are present 16 | if not psutil.sensors_battery(): 17 | raise("No battery sensor for this host") 18 | 19 | def Update(self): 20 | batteryInfo = self.GetBatteryInformation() 21 | self.SetTopicValue(TOPIC_PERCENTAGE, int(batteryInfo['level']),self.ValueFormatter.TYPE_PERCENTAGE) 22 | self.SetTopicValue(TOPIC_CHARGING_STATUS, str(batteryInfo['charging'])) 23 | 24 | def GetBatteryInformation(self): 25 | battery = psutil.sensors_battery() 26 | return {'level': battery.percent, 'charging': battery.power_plugged} 27 | -------------------------------------------------------------------------------- /Entities/Sensors/BatterySensor/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | sensors: 3 | - Os: 4 | dont_send: True 5 | 6 | discovery: 7 | homeassistant: # Must match discovery preset name 8 | - topic: "battery/battery_level_percentage" 9 | payload: 10 | name: "Battery level" 11 | unit_of_measurement: "%" 12 | device_class: battery 13 | 14 | - topic: "battery/battery_charging" 15 | type: binary_sensor 16 | payload: 17 | name: "Charging status" 18 | device_class: battery_charging 19 | payload_off: "False" 20 | payload_on: "True" 21 | 22 | -------------------------------------------------------------------------------- /Entities/Sensors/BoottimeSensor/BoottimeSensor.py: -------------------------------------------------------------------------------- 1 | import uptime 2 | from Entities.Entity import Entity 3 | 4 | 5 | TOPIC = 'boottime' 6 | 7 | 8 | class Boottime(Entity): 9 | def Initialize(self): 10 | self.AddTopic(TOPIC) 11 | 12 | def Update(self): 13 | self.SetTopicValue(TOPIC, str(uptime.boottime())) 14 | 15 | -------------------------------------------------------------------------------- /Entities/Sensors/BoottimeSensor/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | 3 | discovery: 4 | homeassistant: # Must match discovery preset name 5 | - topic: "*" 6 | payload: 7 | name: "Boot Time" 8 | icon: "mdi:clock" -------------------------------------------------------------------------------- /Entities/Sensors/CpuSensor/CpuSensor.py: -------------------------------------------------------------------------------- 1 | import psutil 2 | from Entities.Entity import Entity 3 | 4 | 5 | # Basic CPU info 6 | TOPIC_PERCENTAGE = 'cpu/cpu_used_percentage' 7 | TOPIC_COUNT = 'cpu/cpu_count' 8 | # Advanced CPU info 9 | # CPU times 10 | TOPIC_TIMES_USER = 'cpu/cpu_times/user' 11 | TOPIC_TIMES_SYSTEM = 'cpu/cpu_times/system' 12 | TOPIC_TIMES_IDLE = 'cpu/cpu_times/idle' 13 | # CPU stats 14 | TOPIC_STATS_CTX = 'cpu/cpu_stats/ctx_switches' 15 | TOPIC_STATS_INTERR = 'cpu/cpu_stats/interrupts' 16 | # CPU freq 17 | TOPIC_FREQ_MIN = 'cpu/cpu_freq/min' 18 | TOPIC_FREQ_MAX = 'cpu/cpu_freq/max' 19 | TOPIC_FREQ_CURRENT = 'cpu/cpu_freq/current' 20 | # CPU avg load 21 | TOPIC_AVERAGE_LOAD_LAST_1 = 'cpu/cpu_avg_load/1minute' 22 | TOPIC_AVERAGE_LOAD_LAST_5 = 'cpu/cpu_avg_load/5minutes' 23 | TOPIC_AVERAGE_LOAD_LAST_15 = 'cpu/cpu_avg_load/15minutes' 24 | 25 | # Supports ADVANCED 26 | 27 | 28 | class CpuSensor(Entity): 29 | def Initialize(self): 30 | 31 | self.AddTopic(TOPIC_PERCENTAGE) 32 | self.AddTopic(TOPIC_COUNT) 33 | 34 | # Advanced only if asked in options 35 | if self.GetOption(self.consts.ADVANCED_INFO_OPTION_KEY): 36 | # CPU times 37 | self.AddTopic(TOPIC_TIMES_USER) 38 | self.AddTopic(TOPIC_TIMES_SYSTEM) 39 | self.AddTopic(TOPIC_TIMES_IDLE) 40 | # CPU stats 41 | self.AddTopic(TOPIC_STATS_CTX) 42 | self.AddTopic(TOPIC_STATS_INTERR) 43 | # CPU freq 44 | self.AddTopic(TOPIC_FREQ_MIN) 45 | self.AddTopic(TOPIC_FREQ_MAX) 46 | self.AddTopic(TOPIC_FREQ_CURRENT) 47 | 48 | def PostInitialize(self): 49 | self.os = self.GetOS() 50 | if self.os != 'macOS' and self.GetOption(self.consts.ADVANCED_INFO_OPTION_KEY): 51 | # CPU avg load (not available in macos) 52 | self.AddTopic(TOPIC_AVERAGE_LOAD_LAST_1) 53 | self.AddTopic(TOPIC_AVERAGE_LOAD_LAST_5) 54 | self.AddTopic(TOPIC_AVERAGE_LOAD_LAST_15) 55 | 56 | def Update(self): 57 | # Send base data 58 | self.SetTopicValue(TOPIC_PERCENTAGE, psutil.cpu_percent(), 59 | self.ValueFormatter.TYPE_PERCENTAGE) 60 | self.SetTopicValue(TOPIC_COUNT, psutil.cpu_count()) 61 | # Send if wanted, extra data 62 | if self.GetOption(self.consts.ADVANCED_INFO_OPTION_KEY): 63 | # CPU times 64 | self.SetTopicValue(TOPIC_TIMES_USER, psutil.cpu_times()[ 65 | 0], self.ValueFormatter.TYPE_TIME) 66 | self.SetTopicValue(TOPIC_TIMES_SYSTEM, psutil.cpu_times()[ 67 | 1], self.ValueFormatter.TYPE_TIME) 68 | self.SetTopicValue(TOPIC_TIMES_IDLE, psutil.cpu_times()[ 69 | 2], self.ValueFormatter.TYPE_TIME) 70 | # CPU stats 71 | self.SetTopicValue(TOPIC_STATS_CTX, psutil.cpu_stats()[0]) 72 | self.SetTopicValue(TOPIC_STATS_INTERR, psutil.cpu_stats()[1]) 73 | # CPU freq 74 | self.SetTopicValue(TOPIC_FREQ_CURRENT, psutil.cpu_freq()[ 75 | 0], self.ValueFormatter.TYPE_FREQUENCY) 76 | self.SetTopicValue(TOPIC_FREQ_MIN, psutil.cpu_freq()[ 77 | 1], self.ValueFormatter.TYPE_FREQUENCY) 78 | self.SetTopicValue(TOPIC_FREQ_MAX, psutil.cpu_freq()[ 79 | 2], self.ValueFormatter.TYPE_FREQUENCY) 80 | if self.os != 'macOS': 81 | # CPU avg load 82 | self.SetTopicValue(TOPIC_AVERAGE_LOAD_LAST_1, 83 | psutil.getloadavg()[0]) 84 | self.SetTopicValue(TOPIC_AVERAGE_LOAD_LAST_5, 85 | psutil.getloadavg()[1]) 86 | self.SetTopicValue(TOPIC_AVERAGE_LOAD_LAST_15, 87 | psutil.getloadavg()[2]) 88 | 89 | def GetOS(self): 90 | # Get OS from OsSensor and get temperature based on the os 91 | os = self.FindEntity('Os') 92 | if os: 93 | if not os.postinitializeState: # I run this function in post initialize so the os sensor might not be ready 94 | os.CallPostInitialize() 95 | os.CallUpdate() 96 | return os.GetTopicValue() 97 | -------------------------------------------------------------------------------- /Entities/Sensors/CpuSensor/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | sensors: 3 | - Os: 4 | dont_send: True 5 | 6 | discovery: 7 | homeassistant: # Must match discovery preset name 8 | - topic: "cpu/cpu_used_percentage" 9 | payload: 10 | name: "Cpu usage" 11 | unit_of_measurement: "%" 12 | icon: "mdi:calculator-variant" 13 | 14 | - topic: cpu/cpu_count 15 | payload: 16 | name: "Cpu count" 17 | unit_of_measurement: "" 18 | icon: "mdi:calculator-variant" 19 | 20 | - topic: cpu/cpu_times/user 21 | advanced_topic: True 22 | payload: 23 | name: "Cpu user time" 24 | unit_of_measurement: "s" 25 | icon: "mdi:calculator-variant" 26 | 27 | - topic: cpu/cpu_times/system 28 | advanced_topic: True 29 | payload: 30 | name: "Cpu system time" 31 | unit_of_measurement: "s" 32 | icon: "mdi:calculator-variant" 33 | 34 | - topic: cpu/cpu_times/idle 35 | advanced_topic: True 36 | payload: 37 | name: "Cpu idle time" 38 | unit_of_measurement: "s" 39 | icon: "mdi:calculator-variant" 40 | 41 | - topic: cpu/cpu_stats/ctx_switches 42 | advanced_topic: True 43 | payload: 44 | name: "Cpu CTX switches" 45 | unit_of_measurement: "" 46 | icon: "mdi:calculator-variant" 47 | 48 | - topic: cpu/cpu_stats/interrupts 49 | advanced_topic: True 50 | payload: 51 | name: "Cpu interrupts" 52 | unit_of_measurement: "" 53 | icon: "mdi:calculator-variant" 54 | 55 | - topic: cpu/cpu_freq/min 56 | advanced_topic: True 57 | payload: 58 | name: "Cpu minimum frequency" 59 | unit_of_measurement: "Hz" 60 | icon: "mdi:calculator-variant" 61 | 62 | - topic: cpu/cpu_freq/max 63 | advanced_topic: True 64 | payload: 65 | name: "Cpu maximum frequency" 66 | unit_of_measurement: "Hz" 67 | icon: "mdi:calculator-variant" 68 | 69 | - topic: cpu/cpu_freq/current 70 | advanced_topic: True 71 | payload: 72 | name: "Cpu current frequency" 73 | unit_of_measurement: "Hz" 74 | icon: "mdi:calculator-variant" 75 | 76 | - topic: cpu/cpu_avg_load/1minute 77 | advanced_topic: True 78 | payload: 79 | name: "Cpu average load (last minute)" 80 | unit_of_measurement: "s" 81 | icon: "mdi:calculator-variant" 82 | 83 | - topic: cpu/cpu_avg_load/5minutes 84 | advanced_topic: True 85 | payload: 86 | name: "Cpu average load (last 5 minutes)" 87 | unit_of_measurement: "s" 88 | icon: "mdi:calculator-variant" 89 | 90 | - topic: cpu/cpu_avg_load/15minutes 91 | advanced_topic: True 92 | payload: 93 | name: "Cpu average load (last 15 minutes)" 94 | unit_of_measurement: "s" 95 | icon: "mdi:calculator-variant" -------------------------------------------------------------------------------- /Entities/Sensors/CpuTemperaturesSensor/CpuTemperaturesSensor.py: -------------------------------------------------------------------------------- 1 | from Entities.Entity import Entity 2 | import psutil 3 | import json 4 | from Logger import Logger, ExceptionTracker 5 | 6 | supports_win_temperature = True 7 | try: 8 | import wmi # Only to get windows temperature 9 | openhardwaremonitor = wmi.WMI(namespace="root\\OpenHardwareMonitor") 10 | except: 11 | supports_win_temperature = False 12 | 13 | 14 | TOPIC = 'cpu/temperatures' 15 | 16 | 17 | class CpuTemperaturesSensor(Entity): 18 | def Initialize(self): 19 | self.AddTopic(TOPIC) 20 | 21 | def PostInitialize(self): 22 | os = self.GetOS() 23 | 24 | self.UpdateSpecificFunction = None # Specific function for this os/de, set this here to avoid all if else except at each update 25 | 26 | if(os == self.consts.FIXED_VALUE_OS_WINDOWS): 27 | self.UpdateSpecificFunction = self.GetCpuTemperature_Win 28 | # elif(Get_Operating_System() == self.consts.FIXED_VALUE_OS_MACOS): 29 | # self.UpdateSpecificFunction = Get_Temperatures_macOS NOT SUPPORTED 30 | elif(os == self.consts.FIXED_VALUE_OS_LINUX): 31 | self.UpdateSpecificFunction = self.GetCpuTemperature_Unix 32 | else: 33 | raise Exception( 34 | 'No temperature sensor available for this operating system') 35 | 36 | def Update(self): 37 | self.SetTopicValue(TOPIC, self.UpdateSpecificFunction()) 38 | 39 | def GetCpuTemperature_Unix(self): 40 | temps = psutil.sensors_temperatures() 41 | if 'coretemp' in temps: 42 | for temp in temps['coretemp']: 43 | if 'Core' in temp.label: 44 | return temp.current 45 | elif 'cpu_thermal' in temps: 46 | for temp in temps['cpu_thermal']: 47 | return temp.current 48 | else: 49 | self.Log(Logger.LOG_ERROR, "Can't get temperature for your system.") 50 | self.Log(Logger.LOG_ERROR, 51 | "Open a Git Issue and show this: " + str(temps)) 52 | self.Log(Logger.LOG_ERROR, "Thank you") 53 | raise Exception("No dict data") 54 | # Send the list as json 55 | raise Exception("No temperature data found") 56 | 57 | def GetCpuTemperature_Win(self): 58 | if supports_win_temperature: 59 | # Needs OpenHardwareMonitor interface for WMI 60 | sensors = openhardwaremonitor.Sensor() 61 | for sensor in sensors: 62 | if sensor.SensorType == u'Temperature' and not 'GPU' in sensor.Name: 63 | return float(sensor.Value) 64 | raise Exception("No temperature data found") 65 | 66 | # def GetCpuTemperatures_macOS(): 67 | #command = commands['macOS']['temperature'] + ' | grep \'temperature\'' 68 | # print(command) 69 | # out = subprocess.Popen(command.split(), 70 | # stdout=subprocess.PIPE, 71 | # stderr=subprocess.STDOUT) 72 | #out, errors = out.communicate() 73 | # from out, I have to get the float with temperature 74 | #temperature = [re.findall("\d+\.\d+", str(out))] 75 | # print(temperature) 76 | # Send the list as json 77 | # return str(json.dumps(temperature)) 78 | 79 | 80 | def GetOS(self): 81 | # Get OS from OsSensor and get temperature based on the os 82 | os = self.FindEntity('Os') 83 | if os: 84 | if not os.postinitializeState: # I run this function in post initialize so the os sensor might not be ready 85 | os.CallPostInitialize() 86 | os.CallUpdate() 87 | return os.GetTopicValue() 88 | -------------------------------------------------------------------------------- /Entities/Sensors/CpuTemperaturesSensor/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | sensors: 3 | - Os: 4 | dont_send: True 5 | 6 | discovery: 7 | homeassistant: # Must match discovery preset name 8 | - topic: "*" 9 | payload: 10 | name: "Cpu temperature" 11 | unit_of_measurement: "\u00b0C" -------------------------------------------------------------------------------- /Entities/Sensors/DesktopEnvironmentSensor/DesktopEnvironmentSensor.py: -------------------------------------------------------------------------------- 1 | import os 2 | from Entities.Entity import Entity 3 | 4 | 5 | TOPIC = 'desktop_environment' 6 | 7 | CONTENTS_VALUE_OPTION_KEY = "value" 8 | 9 | class DesktopEnvironmentSensor(Entity): 10 | def Initialize(self): 11 | self.AddTopic(TOPIC) 12 | 13 | def PostInitialize(self): 14 | # The value for this sensor is static for the entire script run time 15 | self.value=self.GetDesktopEnvironment() 16 | 17 | # I have also contents with value (optional) in config 18 | def EntitySchema(self): 19 | schema = super().EntitySchema() 20 | schema = schema.extend({ 21 | self.schemas.Optional(self.consts.CONTENTS_OPTION_KEY): { 22 | self.schemas.Optional(CONTENTS_VALUE_OPTION_KEY): str 23 | } 24 | }) 25 | return schema 26 | 27 | def Update(self): 28 | self.SetTopicValue(TOPIC, self.value) 29 | 30 | # If value passed use it else get it from the system 31 | def GetDesktopEnvironment(self): 32 | 33 | de = os.environ.get('DESKTOP_SESSION') 34 | if de == None: 35 | de = "base" 36 | 37 | # If I have the value in the options, send that. otherwise try to get that 38 | return self.GetOption([self.consts.CONTENTS_OPTION_KEY,CONTENTS_VALUE_OPTION_KEY],de) 39 | -------------------------------------------------------------------------------- /Entities/Sensors/DesktopEnvironmentSensor/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | 3 | discovery: 4 | homeassistant: # Must match discovery preset name 5 | - topic: "*" 6 | payload: 7 | name: "Desktop Environment" 8 | unit_of_measurement: "" -------------------------------------------------------------------------------- /Entities/Sensors/DiskSensor/DiskSensor.py: -------------------------------------------------------------------------------- 1 | import psutil 2 | from Entities.Entity import Entity 3 | 4 | 5 | TOPIC = 'disk_used_percentage' 6 | 7 | 8 | class DiskSensor(Entity): 9 | def Initialize(self): 10 | self.AddTopic(TOPIC) 11 | 12 | def Update(self): 13 | self.SetTopicValue(TOPIC, self.GetDiskUsedPercentage(),self.ValueFormatter.TYPE_PERCENTAGE) 14 | 15 | def GetDiskUsedPercentage(self): 16 | return psutil.disk_usage('/')[3] 17 | -------------------------------------------------------------------------------- /Entities/Sensors/DiskSensor/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | 3 | discovery: 4 | homeassistant: # Must match discovery preset name 5 | - topic: "*" 6 | payload: 7 | name: "Disk used" 8 | unit_of_measurement: "%" 9 | icon: "mdi:harddisk" -------------------------------------------------------------------------------- /Entities/Sensors/FileReadSensor/FileReadSensor.py: -------------------------------------------------------------------------------- 1 | import psutil 2 | from Entities.Entity import Entity 3 | import os 4 | 5 | 6 | 7 | # Tip: customize topic because with more than one file, you must have different topics 8 | TOPIC = 'file/file' 9 | 10 | FILE_READ_SENSOR_FILENAME_CONTENTS_OPTION = "filename" 11 | 12 | class FileReadSensor(Entity): 13 | def Initialize(self): 14 | self.AddTopic(TOPIC) 15 | self.filename = self.GetOption([self.consts.CONTENTS_OPTION_KEY,FILE_READ_SENSOR_FILENAME_CONTENTS_OPTION]) 16 | 17 | # I have also contents with filename (required) in config 18 | def EntitySchema(self): 19 | schema = super().EntitySchema() 20 | schema = schema.extend({ 21 | self.schemas.Required(self.consts.CONTENTS_OPTION_KEY): { 22 | self.schemas.Required(FILE_READ_SENSOR_FILENAME_CONTENTS_OPTION): str 23 | } 24 | }) 25 | return schema 26 | 27 | def Update(self): 28 | if not self.FileExists(): 29 | raise Exception("File must exist (and can't be a directory) !") 30 | with open(self.filename,"r") as f: 31 | self.SetTopicValue(TOPIC,f.read()) 32 | 33 | def FileExists(self): 34 | return os.path.exists(self.filename) and os.path.isfile(self.filename) -------------------------------------------------------------------------------- /Entities/Sensors/FileReadSensor/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | 3 | discovery: 4 | homeassistant: # Must match discovery preset name 5 | - topic: "*" 6 | payload: 7 | name: "File content" 8 | unit_of_measurement: "" 9 | icon: "mdi:file" -------------------------------------------------------------------------------- /Entities/Sensors/HostnameSensor/HostnameSensor.py: -------------------------------------------------------------------------------- 1 | import socket 2 | from Entities.Entity import Entity 3 | 4 | 5 | TOPIC = 'hostname' 6 | 7 | 8 | class Hostname(Entity): 9 | def Initialize(self): 10 | self.AddTopic(TOPIC) 11 | 12 | def Update(self): 13 | self.SetTopicValue(TOPIC, str(self.GetHostname())) 14 | 15 | def GetHostname(self): 16 | return socket.gethostname() 17 | 18 | 19 | -------------------------------------------------------------------------------- /Entities/Sensors/HostnameSensor/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | 3 | discovery: 4 | homeassistant: # Must match discovery preset name 5 | - topic: "*" 6 | payload: 7 | name: "Hostname" 8 | unit_of_measurement: "" 9 | icon: "mdi:form-textbox" -------------------------------------------------------------------------------- /Entities/Sensors/MessageSensor/MessageSensor.py: -------------------------------------------------------------------------------- 1 | import os 2 | from Entities.Entity import Entity 3 | 4 | 5 | TOPIC = 'message' 6 | default_message = "default" 7 | 8 | config_content_message_key = "message" 9 | 10 | class MessageSensor(Entity): 11 | def Initialize(self): 12 | self.AddTopic(TOPIC) 13 | 14 | def PostInitialize(self): # for this topic the value is fixed in cofniguration so I don't need to get it from there at every update 15 | self.value=self.GetOption([self.consts.CONTENTS_OPTION_KEY,config_content_message_key],default_message) 16 | 17 | # I have also contents with message (required) in config 18 | def EntitySchema(self): 19 | schema = super().EntitySchema() 20 | schema = schema.extend({ 21 | self.schemas.Required(self.consts.CONTENTS_OPTION_KEY): { 22 | self.schemas.Required(config_content_message_key): str 23 | } 24 | }) 25 | return schema 26 | 27 | def Update(self): 28 | self.SetTopicValue(TOPIC, self.value) 29 | -------------------------------------------------------------------------------- /Entities/Sensors/MessageSensor/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | 3 | discovery: 4 | homeassistant: # Must match discovery preset name 5 | - topic: "*" 6 | payload: 7 | name: "Message" 8 | unit_of_measurement: "" 9 | icon: "mdi:email" -------------------------------------------------------------------------------- /Entities/Sensors/NetworkSensor/NetworkSensor.py: -------------------------------------------------------------------------------- 1 | # Wireless strenght method taken from: https://github.com/s7jones/Wifi-Signal-Plotter/ 2 | 3 | import psutil 4 | import re 5 | import subprocess 6 | 7 | from Entities.Entity import Entity 8 | from ValueFormatter import ValueFormatter 9 | 10 | supports_win_signal_strenght = True 11 | supports_linux_signal_strenght = True 12 | supports_macos_signal_strenght = False # to avoid using those data in macOS 13 | 14 | try: 15 | import winreg 16 | except: 17 | supports_win_signal_strenght = False 18 | 19 | try: 20 | import netifaces 21 | except: 22 | supports_linux_signal_strenght = False 23 | 24 | 25 | DOWNLOAD_TRAFFIC_TOPIC = 'network/traffic/bytes_recv' 26 | UPLOAD_TRAFFIC_TOPIC = 'network/traffic/bytes_sent' 27 | 28 | NIC_ADDRESS_TOPIC = 'network/interfaces/{}/private_address' # nic name in brackets 29 | NIC_SIGNAL_STRENGHT_TOPIC = 'network/interfaces/{}/signal_strenght' # nic name in brackets - 0 for non wireless nic 30 | 31 | # Supports FORMATTED for traffic information 32 | 33 | SIZE_OPTION_KEY = "size" 34 | EXCLUDE_INTERFACES_CONTENT_OPTION_KEY = "exclude_interfaces" 35 | RENAME_INTERFACES_CONTENT_OPTION_KEY = "rename_interfaces" 36 | 37 | NIC_PRIVATE_ADDRESS_DISCOVERY_NAME_FORMAT = "{} private ip" 38 | NIC_PRIVATE_ADDRESS_DISCOVERY_ICON = "mdi:ip-network" 39 | 40 | NIC_SIGNAL_STRENGHT_DISCOVERY_NAME_FORMAT = "{} signal strenght" 41 | NIC_SIGNAL_STRENGHT_DISCOVERY_ICON = "mdi:network-strength-3" 42 | 43 | # Windows returns strenght in %, linux in dB 44 | NIC_SIGNAL_STRENGHT_DISCOVERY_UNIT_OF_MEASUREMENT_LINUX = "dB" 45 | NIC_SIGNAL_STRENGHT_DISCOVERY_UNIT_OF_MEASUREMENT_WINDOWS = "%" 46 | 47 | class NetworkSensor(Entity): 48 | def Initialize(self): 49 | 50 | self.supports_signal_strenght = True 51 | 52 | # Get list of interfaces to ignore: if not specified: [], if set only a string: [string], if set a list: [item1,item2] -> I always have a list (else schema not validated) 53 | self.excludeInterfaces=self.Configurator.ReturnAsList(self.GetOption([self.consts.CONTENTS_OPTION_KEY,EXCLUDE_INTERFACES_CONTENT_OPTION_KEY],[])) 54 | 55 | 56 | # Interfaces 57 | self.nics = [] 58 | for nic in netifaces.interfaces(): 59 | # If I don't exclude it and if has a private address (AF_INET=2) 60 | if '{' not in self.GetNicName(nic) and self.GetNicName(nic) not in self.excludeInterfaces and netifaces.AF_INET in netifaces.ifaddresses(nic): 61 | self.AddTopic(self.InterfaceTopicFormat(NIC_ADDRESS_TOPIC,self.GetNicName(nic))) 62 | self.AddTopic(self.InterfaceTopicFormat(NIC_SIGNAL_STRENGHT_TOPIC,self.GetNicName(nic))) 63 | self.nics.append(nic) 64 | self.Log(self.Logger.LOG_DEBUG, "Added " + self.GetNicName(nic,getRenamed=False)+ " interface") 65 | if self.GetNicName(nic) != self.GetNicName(nic, getRenamed= False): 66 | self.Log(self.Logger.LOG_DEBUG, "Renamed " + self.GetNicName(nic,getRenamed=False)+ " to " + self.GetNicName(nic)) 67 | 68 | # Traffic data 69 | self.AddTopic(DOWNLOAD_TRAFFIC_TOPIC) 70 | self.AddTopic(UPLOAD_TRAFFIC_TOPIC) 71 | 72 | 73 | def PostInitialize(self): 74 | global supports_linux_signal_strenght,supports_win_signal_strenght,supports_macos_signal_strenght 75 | 76 | os = self.GetOS() 77 | 78 | self.UpdateWirelessSignalStrenghtSpecificFunction = None # Specific function for this os/de, set this here to avoid all if else except at each update 79 | 80 | if(os == self.consts.FIXED_VALUE_OS_WINDOWS): 81 | self.NIC_SIGNAL_STRENGHT_DISCOVERY_UNIT_OF_MEASUREMENT= NIC_SIGNAL_STRENGHT_DISCOVERY_UNIT_OF_MEASUREMENT_WINDOWS 82 | self.UpdateWirelessSignalStrenghtSpecificFunction = self.GetWirelessStrenght_Windows 83 | 84 | if not supports_win_signal_strenght: 85 | self.Log(self.Logger.LOG_ERROR,"Error with signal strenght sensor, have you installed all the dependencies ? (winreg, netifaces)") 86 | # If not supported, I remove the signal strenght topic (also discovery won't include anymore this topic) 87 | self.RemoveSignalStrenghtTopics() 88 | self.supports_signal_strenght = False 89 | 90 | elif(os == self.consts.FIXED_VALUE_OS_LINUX): 91 | self.NIC_SIGNAL_STRENGHT_DISCOVERY_UNIT_OF_MEASUREMENT= NIC_SIGNAL_STRENGHT_DISCOVERY_UNIT_OF_MEASUREMENT_LINUX 92 | self.UpdateWirelessSignalStrenghtSpecificFunction = self.GetWirelessStrenght_Linux 93 | 94 | if not supports_linux_signal_strenght: 95 | self.Log(self.Logger.LOG_ERROR,"Error with signal strenght sensor, have you installed all the dependencies ? (netifaces)") 96 | # If not supported, I remove the signal strenght topic (also discovery won't include anymore this topic) 97 | self.RemoveSignalStrenghtTopics() 98 | self.supports_signal_strenght = False 99 | 100 | 101 | # elif(Get_Operating_System() == self.consts.FIXED_VALUE_OS_MACOS): 102 | # self.UpdateSpecificFunction = NOT SUPPORTED 103 | 104 | else: 105 | self.Log(self.Logger.LOG_ERROR, 'No wireless signal strenght sensor available for this operating system') 106 | # If not supported, I remove the signal strenght topic (also discovery won't include anymore this topic) 107 | self.RemoveSignalStrenghtTopics() 108 | self.supports_signal_strenght = False 109 | 110 | 111 | 112 | 113 | def Update(self): 114 | # Interfaces data 115 | strenght_data = self.UpdateWirelessSignalStrenghtSpecificFunction() 116 | strenght_set = False # If after the check in the strenght data values, if the nic hasn't a value, set 0 117 | 118 | for nic in self.nics: 119 | # Private address 120 | self.SetTopicValue(self.InterfaceTopicFormat(NIC_ADDRESS_TOPIC,self.GetNicName(nic)), netifaces.ifaddresses(nic)[netifaces.AF_INET][0]['addr']) 121 | 122 | # Wireless strenght 123 | if self.supports_signal_strenght: 124 | strenght_set = False 125 | for nic_strenght in strenght_data: 126 | if nic_strenght[0]==self.GetNicName(nic,getRenamed=False): 127 | strenght_set = True 128 | self.SetTopicValue(self.InterfaceTopicFormat(NIC_SIGNAL_STRENGHT_TOPIC,self.GetNicName(nic)), nic_strenght[1]) 129 | if strenght_set==False: 130 | self.SetTopicValue(self.InterfaceTopicFormat(NIC_SIGNAL_STRENGHT_TOPIC,self.GetNicName(nic)), 0) 131 | 132 | 133 | # Traffic data 134 | self.SetTopicValue(DOWNLOAD_TRAFFIC_TOPIC, psutil.net_io_counters()[ 135 | 1], self.ValueFormatter.TYPE_BYTE) 136 | self.SetTopicValue(UPLOAD_TRAFFIC_TOPIC, psutil.net_io_counters()[ 137 | 0], self.ValueFormatter.TYPE_BYTE) 138 | 139 | 140 | 141 | # Signal strenght methods: 142 | def GetWirelessStrenght_Linux(self): 143 | p = subprocess.Popen("iwconfig", stdout=subprocess.PIPE, stderr=subprocess.PIPE) 144 | out = p.stdout.read().decode() 145 | m = re.findall('(wl.*?) .*?Signal level=(-[0-9]+) dBm', out, re.DOTALL) 146 | p.communicate() 147 | return m 148 | 149 | def GetWirelessStrenght_Windows(self): 150 | p = subprocess.Popen("netsh wlan show interfaces", stdout=subprocess.PIPE, stderr=subprocess.PIPE) 151 | out = p.stdout.read().decode(errors="ignore") 152 | if "Segnale" in out: # Italian support 153 | m = re.findall('Nome.*?:.*? ([A-z0-9 \-]*).*?Segnale.*?:.*?([0-9]*)%', out, re.DOTALL) 154 | elif "Signal" in out: # English support 155 | m = re.findall('Nome.*?:.*?([A-z0-9 ]*).*?Signal.*?:.*?([0-9]*)%', out, re.DOTALL) 156 | else: 157 | self.Log(self.Logger.LOG_ERROR,"Can't get signal strenght data in your region. Please open a Git issue with and show the output of this command in the CMD: 'netsh wlan show interfaces' ") 158 | self.supports_signal_strenght=False 159 | # TODO Remove the signal strenght topic for all the inets here ! 160 | p.communicate() 161 | return m 162 | 163 | # OS entity information get 164 | 165 | def GetOS(self): 166 | # Get OS from OsSensor and get temperature based on the os 167 | os = self.FindEntity('Os') 168 | if os: 169 | if not os.postinitializeState: # I run this function in post initialize so the os sensor might not be ready 170 | os.CallPostInitialize() 171 | os.CallUpdate() 172 | return os.GetTopicValue() 173 | 174 | 175 | # Other entity settings 176 | 177 | # I have also contents with value (exclude_interfaces) in config 178 | def EntitySchema(self): 179 | schema = super().EntitySchema() 180 | schema = schema.extend({ 181 | self.schemas.Optional(self.consts.CONTENTS_OPTION_KEY): { 182 | self.schemas.Optional(EXCLUDE_INTERFACES_CONTENT_OPTION_KEY): self.schemas.Or(list, str), 183 | self.schemas.Optional(RENAME_INTERFACES_CONTENT_OPTION_KEY): dict 184 | } 185 | }) 186 | return schema 187 | 188 | def InterfaceTopicFormat(self, topic,nic): 189 | return topic.format(nic) 190 | 191 | 192 | def ManageDiscoveryData(self, discovery_data): 193 | 194 | for nic in self.nics: 195 | for data in discovery_data: 196 | if self.GetNicName(nic) in data['name']: # Check if data of the correct nic 197 | if NIC_ADDRESS_TOPIC.split("/")[-1] in data['name']: # Check if it's the private address 198 | data['payload']['name'] = data['payload']['name'].split("-")[0] + "- " + NIC_PRIVATE_ADDRESS_DISCOVERY_NAME_FORMAT.format(self.GetNicName(nic)) 199 | data['payload']['icon'] = NIC_PRIVATE_ADDRESS_DISCOVERY_ICON 200 | elif NIC_SIGNAL_STRENGHT_TOPIC.split("/")[-1] in data['name']: # Check if it's the signal strenght 201 | data['payload']['name'] = data['payload']['name'].split("-")[0] + "- " + NIC_SIGNAL_STRENGHT_DISCOVERY_NAME_FORMAT.format(self.GetNicName(nic)) 202 | data['payload']['icon'] = NIC_SIGNAL_STRENGHT_DISCOVERY_ICON 203 | data['payload']['unit_of_measurement'] = self.NIC_SIGNAL_STRENGHT_DISCOVERY_UNIT_OF_MEASUREMENT 204 | 205 | return discovery_data 206 | 207 | def GetNicName(self,nic,getRenamed=True): 208 | # On windows, the network interface name has only the id "{......}" 209 | name = nic 210 | try: 211 | if '{' in nic: 212 | reg = winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) 213 | reg_key = winreg.OpenKey(reg, r'SYSTEM\CurrentControlSet\Control\Network\{4d36e972-e325-11ce-bfc1-08002be10318}') 214 | reg_subkey = winreg.OpenKey(reg_key, nic + r'\Connection') 215 | name = winreg.QueryValueEx(reg_subkey, 'Name')[0] 216 | except: 217 | name = nic 218 | 219 | if getRenamed: 220 | # In configuration, user can set a rename interfaces dict, with key=original name, value=new name 221 | rename_dict=self.GetOption([self.consts.CONTENTS_OPTION_KEY,RENAME_INTERFACES_CONTENT_OPTION_KEY],{}) 222 | if name in rename_dict: 223 | return rename_dict[name] 224 | 225 | return name 226 | 227 | 228 | def RemoveSignalStrenghtTopics(self): 229 | # If not supported, I remove the signal strenght topic (also discovery won't include anymore this topic) 230 | for topic in self.outTopics: 231 | if topic['topic'].split("/")[-1] == NIC_SIGNAL_STRENGHT_TOPIC.split("/")[-1]: 232 | self.RemoveOutboundTopic(topic) 233 | 234 | # Example in configuration: 235 | # 236 | # - Network: 237 | # value_format: # for traffic information 238 | # size: MB // SIZE_....BYTE constant 239 | # content: 240 | # exclude_interfaces: 241 | # - lo 242 | # - VirtualBox Host-Only Network 243 | # rename_interfaces: 244 | # wlp0s20f3: Wi-Fi -------------------------------------------------------------------------------- /Entities/Sensors/NetworkSensor/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | sensors: 3 | - Os: 4 | dont_send: True 5 | 6 | discovery: 7 | homeassistant: # Must match discovery preset name 8 | - topic: "network/traffic/bytes_recv" 9 | payload: 10 | name: "Network received data" 11 | icon: "mdi:download" 12 | unit_of_measurement: "MB" 13 | - topic: "network/traffic/bytes_sent" 14 | payload: 15 | name: "Network sent data" 16 | icon: "mdi:upload" 17 | unit_of_measurement: "MB" 18 | # Other network interface specific settings are in the ManageDiscoveryData function of the entity -------------------------------------------------------------------------------- /Entities/Sensors/OsSensor/OsSensor.py: -------------------------------------------------------------------------------- 1 | import platform 2 | from Entities.Entity import Entity 3 | #from consts import FIXED_VALUE_OS_MACOS 4 | 5 | TOPIC = 'operating_system' 6 | 7 | 8 | class OsSensor(Entity): 9 | def Initialize(self): 10 | self.AddTopic(TOPIC) 11 | 12 | def PostInitialize(self): 13 | # The value for this sensor is static for the entire script run time 14 | self.SetTopicValue(TOPIC, self.GetOperatingSystem()) 15 | 16 | 17 | def Update(self): # Nothing to update 18 | pass 19 | 20 | def GetOperatingSystem(self): 21 | os = platform.system() 22 | if os == 'Darwin': # It's macOS 23 | return self.consts.FIXED_VALUE_OS_MACOS 24 | return os 25 | 26 | def ManageDiscoveryData(self, discovery_data): 27 | # Setup icons if I have a dict of OS in the settings 28 | icons = discovery_data[0]['payload']['icon'] 29 | if (type(icons)==dict): 30 | os = self.GetTopicValue(self.GetFirstTopic()) 31 | if os in icons: 32 | discovery_data[0]['payload']['icon'] = icons[os] 33 | else: 34 | discovery_data[0]['payload']['icon'] = "mdi:flask" 35 | return discovery_data -------------------------------------------------------------------------------- /Entities/Sensors/OsSensor/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | 3 | discovery: 4 | homeassistant: # Must match discovery preset name 5 | - topic: "*" 6 | payload: 7 | name: "Operating system" 8 | unit_of_measurement: "" 9 | icon: # Will be managed by the Os class 10 | Linux: "mdi:penguin" # OS name must respect FIXED values in consts 11 | Windows: "mdi:microsoft" # OS name must respect FIXED values in consts 12 | macOS: "mdi:apple" # OS name must respect FIXED values in consts -------------------------------------------------------------------------------- /Entities/Sensors/RamSensor/RamSensor.py: -------------------------------------------------------------------------------- 1 | import psutil 2 | import math 3 | from Entities.Entity import Entity 4 | 5 | # Virtual memory 6 | TOPIC_MEMORY_TOTAL = 'ram/physical_memory/total' 7 | TOPIC_MEMORY_AVAILABLE = 'ram/physical_memory/available' 8 | TOPIC_MEMORY_FREE = 'ram/physical_memory/free' 9 | TOPIC_MEMORY_USED = 'ram/physical_memory/used' 10 | TOPIC_MEMORY_PERCENTAGE = 'ram/physical_memory/percentage' 11 | # Swap memory 12 | TOPIC_SWAP_TOTAL = 'ram/swap_memory/total' 13 | TOPIC_SWAP_USED = 'ram/swap_memory/used' 14 | TOPIC_SWAP_FREE = 'ram/swap_memory/free' 15 | TOPIC_SWAP_PERCENTAGE = 'ram/swap_memory/percentage' 16 | 17 | # Supports SIZED, ADVANCED 18 | 19 | 20 | class RamSensor(Entity): 21 | def Initialize(self): 22 | self.AddTopic(TOPIC_MEMORY_PERCENTAGE) 23 | self.AddTopic(TOPIC_SWAP_PERCENTAGE) 24 | 25 | if self.GetOption(self.consts.ADVANCED_INFO_OPTION_KEY): 26 | # Virtual memory 27 | self.AddTopic(TOPIC_MEMORY_TOTAL) 28 | self.AddTopic(TOPIC_MEMORY_AVAILABLE) 29 | self.AddTopic(TOPIC_MEMORY_FREE) 30 | self.AddTopic(TOPIC_MEMORY_USED) 31 | # Swap memory 32 | self.AddTopic(TOPIC_SWAP_TOTAL) 33 | self.AddTopic(TOPIC_SWAP_USED) 34 | self.AddTopic(TOPIC_SWAP_FREE) 35 | 36 | def Update(self): 37 | 38 | self.SetTopicValue(TOPIC_MEMORY_PERCENTAGE, psutil.virtual_memory()[ 39 | 2], self.ValueFormatter.TYPE_PERCENTAGE) 40 | self.SetTopicValue(TOPIC_SWAP_PERCENTAGE, psutil.swap_memory()[ 41 | 3], self.ValueFormatter.TYPE_PERCENTAGE) 42 | 43 | if self.GetOption(self.consts.ADVANCED_INFO_OPTION_KEY): 44 | # Virtual memory 45 | self.SetTopicValue(TOPIC_MEMORY_TOTAL, 46 | psutil.virtual_memory()[0], self.ValueFormatter.TYPE_BYTE) 47 | self.SetTopicValue(TOPIC_MEMORY_AVAILABLE, 48 | psutil.virtual_memory()[1], self.ValueFormatter.TYPE_BYTE) 49 | self.SetTopicValue(TOPIC_MEMORY_USED, 50 | psutil.virtual_memory()[3], self.ValueFormatter.TYPE_BYTE) 51 | self.SetTopicValue(TOPIC_MEMORY_FREE, 52 | psutil.virtual_memory()[4], self.ValueFormatter.TYPE_BYTE) 53 | # Swap memory 54 | self.SetTopicValue( 55 | TOPIC_SWAP_TOTAL, psutil.swap_memory()[0], self.ValueFormatter.TYPE_BYTE) 56 | self.SetTopicValue( 57 | TOPIC_SWAP_USED, psutil.swap_memory()[1], self.ValueFormatter.TYPE_BYTE) 58 | self.SetTopicValue( 59 | TOPIC_SWAP_FREE, psutil.swap_memory()[2], self.ValueFormatter.TYPE_BYTE) 60 | -------------------------------------------------------------------------------- /Entities/Sensors/RamSensor/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | 3 | discovery: 4 | homeassistant: # Must match discovery preset name 5 | - topic: ram/physical_memory/percentage 6 | payload: 7 | name: "Ram usage" 8 | unit_of_measurement: "%" 9 | icon: "mdi:memory" 10 | 11 | - topic: ram/swap_memory/percentage 12 | payload: 13 | name: "Swap used" 14 | unit_of_measurement: "%" 15 | icon: "mdi:memory" 16 | 17 | - topic: ram/physical_memory/total 18 | advanced_topic: True 19 | payload: 20 | name: "Ram total memory" 21 | unit_of_measurement: "GB" 22 | icon: "mdi:memory" 23 | 24 | - topic: ram/physical_memory/available 25 | advanced_topic: True 26 | payload: 27 | name: "Ram available memory" 28 | unit_of_measurement: "GB" 29 | icon: "mdi:memory" 30 | 31 | - topic: ram/physical_memory/free 32 | advanced_topic: True 33 | payload: 34 | name: "Ram free memory" 35 | unit_of_measurement: "GB" 36 | icon: "mdi:memory" 37 | 38 | - topic: ram/physical_memory/used 39 | advanced_topic: True 40 | payload: 41 | name: "Ram used memory" 42 | unit_of_measurement: "GB" 43 | icon: "mdi:memory" 44 | 45 | - topic: ram/swap_memory/total 46 | advanced_topic: True 47 | payload: 48 | name: "Swap total memory" 49 | unit_of_measurement: "GB" 50 | icon: "mdi:memory" 51 | 52 | - topic: ram/swap_memory/used 53 | advanced_topic: True 54 | payload: 55 | name: "Swap used memory" 56 | unit_of_measurement: "GB" 57 | icon: "mdi:memory" 58 | 59 | - topic: ram/swap_memory/free 60 | advanced_topic: True 61 | payload: 62 | name: "Swap free memory" 63 | unit_of_measurement: "GB" 64 | icon: "mdi:memory" -------------------------------------------------------------------------------- /Entities/Sensors/ScreenshotSensor/ScreenshotSensor.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pyscreenshot as ImageGrab 3 | from PIL import Image 4 | from Entities.Entity import Entity 5 | 6 | 7 | TOPIC = 'screenshot' 8 | 9 | SCREENSHOT_FILENAME = 'screenshot.png' 10 | scriptFolder = str(os.path.dirname(os.path.realpath(__file__))) 11 | 12 | 13 | class ScreenshotSensor(Entity): 14 | def Initialize(self): 15 | self.AddTopic(TOPIC) 16 | 17 | def Update(self): 18 | self.SetTopicValue(TOPIC, self.TakeScreenshot()) 19 | 20 | def TakeScreenshot(self): 21 | filename = os.path.join(scriptFolder, SCREENSHOT_FILENAME) 22 | ImageGrab.grab().save(filename) 23 | f = open(filename, "rb") # 3.7kiB in same folder 24 | fileContent = f.read() 25 | image = bytearray(fileContent) 26 | f.close() 27 | os.remove(filename) 28 | return image 29 | 30 | def ManageDiscoveryData(self, discovery_data): 31 | # Camera must not have some information that sensors have so here they have to be removed ! (done with expire_after) 32 | 33 | discovery_data[0]['payload'].pop('state_topic', None) 34 | discovery_data[0]['payload']['topic']=self.SelectTopic({"topic":TOPIC}) 35 | 36 | if 'expire_after' in discovery_data[0]['payload']: # Camera must not have this information or will be discarded 37 | del(discovery_data[0]['payload']['expire_after']) 38 | 39 | ''' 40 | discovery_data[0]['payload']['availability']={} 41 | discovery_data[0]['payload']['availability']['topic']=self.SelectTopic("status") 42 | discovery_data[0]['payload']['availability']['payload_available']=ONLINE_STATE 43 | discovery_data[0]['payload']['availability']['payload_not_available']=OFFLINE_STATE 44 | ''' 45 | return discovery_data -------------------------------------------------------------------------------- /Entities/Sensors/ScreenshotSensor/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | 3 | discovery: 4 | homeassistant: # Must match discovery preset name 5 | - topic: "*" 6 | type: camera 7 | payload: 8 | name: "Screen" 9 | 10 | 11 | -------------------------------------------------------------------------------- /Entities/Sensors/StateSensor/StateSensor.py: -------------------------------------------------------------------------------- 1 | import psutil 2 | from Entities.Entity import Entity 3 | import signal, sys 4 | import time 5 | 6 | TOPIC_STATE = 'state' 7 | 8 | 9 | class StateSensor(Entity): 10 | def Initialize(self): 11 | self.AddTopic(TOPIC_STATE) 12 | signal.signal(signal.SIGINT, self.ExitSignal) 13 | 14 | 15 | def Update(self): 16 | self.SetTopicValue(TOPIC_STATE, self.consts.ONLINE_STATE) 17 | 18 | def SendOfflineState(self): 19 | self.mqtt_client.SendTopicData(self.SelectTopic(TOPIC_STATE),self.consts.OFFLINE_STATE) 20 | 21 | def ExitSignal(self,sig, frame): 22 | # Before exiting I send an offline message to the state_topic if prese 23 | print("\r", end="") # This removes the Control-C symbol (^C) 24 | self.Log(self.Logger.LOG_INFO,'Let me send the Offline state message') 25 | self.SendOfflineState() 26 | time.sleep(1) 27 | self.Log(self.Logger.LOG_INFO,"All done, goodbye !") 28 | sys.exit(0) 29 | -------------------------------------------------------------------------------- /Entities/Sensors/StateSensor/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | 3 | discovery: 4 | homeassistant: # Must match discovery preset name 5 | - topic: "state" 6 | type: binary_sensor 7 | payload: 8 | name: "Online state" 9 | device_class: "connectivity" 10 | payload_off: "Offline" # Must match the fixed value in consts 11 | payload_on: "Online" # Must match the fixed value in consts 12 | -------------------------------------------------------------------------------- /Entities/Sensors/TimeSensor/TimeSensor.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from Entities.Entity import Entity 3 | 4 | 5 | TOPIC_MESSAGE_TIME = 'message_time' 6 | 7 | 8 | class TimeSensor(Entity): 9 | def Initialize(self): 10 | self.AddTopic(TOPIC_MESSAGE_TIME) 11 | 12 | def Update(self): 13 | self.SetTopicValue(TOPIC_MESSAGE_TIME, self.GetCurrentTime()) 14 | 15 | def GetCurrentTime(self): 16 | return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") 17 | -------------------------------------------------------------------------------- /Entities/Sensors/TimeSensor/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | 3 | discovery: 4 | homeassistant: # Must match discovery preset name 5 | - topic: "*" 6 | payload: 7 | name: "Message time" 8 | unit_of_measurement: "" 9 | icon: "mdi:clock" -------------------------------------------------------------------------------- /Entities/Sensors/UptimeSensor/UptimeSensor.py: -------------------------------------------------------------------------------- 1 | import uptime 2 | from Entities.Entity import Entity 3 | 4 | 5 | TOPIC = 'uptime' 6 | 7 | 8 | class Uptime(Entity): 9 | def Initialize(self): 10 | self.AddTopic(TOPIC) 11 | 12 | def Update(self): 13 | self.SetTopicValue(TOPIC, str(uptime.uptime())) 14 | 15 | -------------------------------------------------------------------------------- /Entities/Sensors/UptimeSensor/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | 3 | discovery: 4 | homeassistant: # Must match discovery preset name 5 | - topic: "*" 6 | payload: 7 | name: "Uptime" 8 | unit_of_measurement: "s" 9 | icon: "mdi:clock" -------------------------------------------------------------------------------- /Entities/Sensors/UsernameSensor/UsernameSensor.py: -------------------------------------------------------------------------------- 1 | import os 2 | from Entities.Entity import Entity 3 | 4 | 5 | TOPIC = 'username' 6 | 7 | 8 | class Username(Entity): 9 | def Initialize(self): 10 | self.AddTopic(TOPIC) 11 | 12 | def Update(self): 13 | self.SetTopicValue(TOPIC, str(self.GetUsername())) 14 | 15 | def GetUsername(self): 16 | # Gives user's home directory 17 | userhome = os.path.expanduser('~') 18 | 19 | # Gives username by splitting path based on OS 20 | return os.path.split(userhome)[-1] 21 | 22 | -------------------------------------------------------------------------------- /Entities/Sensors/UsernameSensor/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | 3 | discovery: 4 | homeassistant: # Must match discovery preset name 5 | - topic: "*" 6 | payload: 7 | name: "Username" 8 | unit_of_measurement: "" 9 | icon: "mdi:account" -------------------------------------------------------------------------------- /Entities/Sensors/VolumeSensor/VolumeSensor.py: -------------------------------------------------------------------------------- 1 | from Entities.Entity import Entity 2 | from os import path 3 | from ctypes import * 4 | 5 | TOPIC_LEVEL = 'volume/level_get' 6 | TOPIC_MUTE = 'volume/mute_get' 7 | 8 | scriptFolder = str(path.dirname(path.realpath(__file__))) 9 | #EXTERNAL_SOFTWARE_FILENAME = path.join(scriptFolder,'..','..','ExternalUtilities','FILE') 10 | 11 | 12 | class VolumeSensor(Entity): 13 | def Initialize(self): 14 | self.AddTopic(TOPIC_LEVEL) 15 | self.AddTopic(TOPIC_MUTE) 16 | 17 | def PostInitialize(self): 18 | os = self.GetOS() 19 | self.UpdateSpecificFunction = None # Specific function for this os/de, set this here to avoid all if else except at each update 20 | 21 | raise Exception('No volume sensor available') 22 | 23 | def Update(self): 24 | self.SetTopicValue(TOPIC, self.UpdateSpecificFunction()) 25 | 26 | def GetWindowsVolume(self): 27 | pass 28 | 29 | def GetOS(self): 30 | # Get OS from OsSensor and get temperature based on the os 31 | os = self.FindEntity('Os') 32 | if os: 33 | if not os.postinitializeState: # I run this function in post initialize so the os sensor might not be ready 34 | os.CallPostInitialize() 35 | os.CallUpdate() 36 | return os.GetTopicValue() 37 | -------------------------------------------------------------------------------- /Entities/Sensors/VolumeSensor/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | sensors: 3 | - Os: 4 | dont_send: True 5 | 6 | discovery: 7 | homeassistant: # Must match discovery preset name 8 | - topic: "volume/level_get" 9 | payload: 10 | name: "Volume level" 11 | unit_of_measurement: "%" 12 | icon: "mdi:volume-source" 13 | 14 | - topic: "volume/mute_get" 15 | type: binary_sensor 16 | payload: 17 | name: "Volume mute status" 18 | device_class: sound 19 | payload_on: On # Must match the fixed value in consts 20 | payload_off: Off # Must match the fixed value in consts -------------------------------------------------------------------------------- /EntityManager.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import inspect 3 | import time 4 | from Logger import Logger, ExceptionTracker 5 | from ClassManager import ClassManager 6 | 7 | # Delay in second 8 | update_rate = 20 # If not set in config 9 | 10 | 11 | class EntityManager(): 12 | # entities is a list of dicts: [{entity, mqtt_client, logger}] 13 | entities = [] 14 | continue_sending = True # Stop loop condition 15 | 16 | def __init__(self, config): 17 | self.config = config 18 | self.logger = Logger(config) 19 | self.classManager = ClassManager(config) # The one that loads and returns entities from Entities folder giving him only the name 20 | 21 | # ENTITIES MANAGEMENT PART 22 | 23 | def Start(self): 24 | # Start the send loop 25 | self.SendAllData() 26 | 27 | 28 | def ActiveEntities(self): 29 | return self.entities 30 | 31 | 32 | ## ENTITY POSTINITIALIZATION 33 | 34 | def PostInitializeEntities(self): 35 | self.Log(Logger.LOG_DEBUG,"Starting post-initialization") 36 | for entity in self.entities: 37 | if not entity.postinitializeState: 38 | entity.CallPostInitialize() 39 | self.Log(Logger.LOG_DEBUG,"Finished post-initialization") 40 | 41 | 42 | ## ENTITY LOAD AND INITIALIZATION 43 | 44 | # Here I receive the name of the entity (or maybe also the options) and pass it to a function to get the object 45 | # which will be initialized and appended in the list of entities 46 | # Here configs are specific for the monitor, it's not the same as this manager 47 | def LoadEntity(self, entity_suffix, entityString, monitor_id, config, mqtt_client, send_interval, logger): 48 | name = entityString 49 | options = None 50 | 51 | # If in the list I have a dict then I have some options for that command 52 | if type(entityString) == dict: 53 | name = list(entityString.keys())[0] 54 | options = entityString[name] 55 | 56 | obj = self.classManager.GetEntityClass(name+entity_suffix) 57 | if obj: 58 | try: 59 | objAlive = obj(monitor_id, config, mqtt_client, 60 | send_interval, options, logger, self) 61 | if objAlive: # If initialize went great 62 | self.entities.append(objAlive) 63 | req = objAlive.LoadSettings() 64 | # self.Log(Logger.LOG_INFO, name + 65 | # ' entity loaded', logger=logger) 66 | return req # Return the settings with equirements 67 | except Exception as exc: 68 | self.Log(Logger.LOG_ERROR, ExceptionTracker.TrackString( 69 | exc), logger=logger) 70 | self.Log(Logger.LOG_ERROR, name + " not loaded", logger=logger) 71 | return None 72 | 73 | def UnloadEntity(self, entity): # by entity object 74 | self.Log(Logger.LOG_WARNING, entity.name + 75 | ' entity unloaded', logger=entity.GetLogger()) 76 | self.entities.remove(entity) 77 | del(entity) 78 | 79 | def UnloadEntityByName(self, name, monitor_id): # by name and monitor id 80 | self.UnloadEntity(self.FindEntities(name, monitor_id)[0]) # HEREEE 81 | 82 | def FindEntities(self, name, monitor_id): 83 | # Return the entity object present in entities list: to get entity value from another entity for example 84 | entities = [] 85 | for entity in self.ActiveEntities(): 86 | # If it's an object->obj.name, if a class must use the .__dict__ for the name 87 | if name == entity.name and monitor_id == entity.GetMonitorID(): 88 | entities.append(entity) 89 | return entities 90 | 91 | 92 | 93 | ## ENTITIES OUTGOING DATA (AKA SENSOR ENTITIES) PART 94 | 95 | def SendSensorsData(self): 96 | for sensor in self.entities: 97 | sensor.SendData() 98 | 99 | # Also discovery data every X second 100 | def SendAllData(self): 101 | while self.continue_sending: 102 | for entity in self.ActiveEntities(): 103 | if entity.GetMqttClient().connected: 104 | if entity.ShouldSendMessage(): # HERE CHECK IF HAS OUTTOPICS 105 | entity.CallUpdate() 106 | entity.SendData() 107 | # Save this time as time when last message is sent 108 | entity.SaveTimeMessageSent() 109 | self.Log(Logger.LOG_DEBUG,"Sending " + entity.name) 110 | if entity.ShouldSendDiscoveryConfig(): 111 | try: 112 | discovery_data = entity.PrepareDiscoveryPayloads() 113 | discovery_data = entity.ManageDiscoveryData( 114 | discovery_data) 115 | entity.PublishDiscoveryData(discovery_data) 116 | entity.SaveTimeDiscoverySent() 117 | except Exception as e : 118 | self.Log(Logger.LOG_ERROR, "Error while preparing discovery configuration for " + entity.name + ": " + str(e)) 119 | self.UnloadEntity(entity) 120 | time.sleep(1) # Wait a second and recheck if someone has to send 121 | 122 | 123 | # LOG 124 | 125 | def Log(self, messageType, message, logger=None): 126 | if logger is None: 127 | logger = self.logger 128 | logger.Log(messageType, 'Entity Manager', message) 129 | -------------------------------------------------------------------------------- /Home Assistant Monitors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richibrics/PyMonitorMQTT/e16e67f9d7092fd3274c3cc93f635eced77d9e9e/Home Assistant Monitors.png -------------------------------------------------------------------------------- /Logger.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import sys 4 | from consts import * 5 | import json 6 | from Configurator import Configurator 7 | 8 | # Fill start of string with spaces to jusitfy the message (0: no padding) 9 | # First for type, second for monitor, third for source 10 | STRINGS_LENGTH = [8, 12, 26] 11 | 12 | # Number of spaces between prestring (date,source,ecc..) and message 13 | PRESTRING_MESSAGE_SEPARATOR_LEN = 2 14 | LONG_MESSAGE_PRESTRING_CHAR = ' ' 15 | 16 | DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S' 17 | 18 | scriptFolder = str(os.path.dirname(os.path.realpath(__file__))) 19 | LOGS_FOLDER = 'Logs' 20 | MAIN_LOG_FILENAME = 'Log.log' 21 | 22 | 23 | class Logger(): 24 | LOG_MESSAGE = 0 25 | LOG_ERROR = 1 26 | LOG_WARNING = 2 27 | LOG_INFO = 3 28 | LOG_DEBUG = 4 29 | LOG_DEVELOPMENT = 5 30 | 31 | def __init__(self, globalConfig, monitor_id=None): 32 | self.globalConfig = globalConfig 33 | self.monitor_id = monitor_id 34 | self.GetConfiguration() 35 | self.SetupFolder() 36 | 37 | def Log(self, messageLevel, source, message): 38 | if type(message) == dict: 39 | self.LogDict(messageLevel,source,message) 40 | return # Log dict will call this function so I don't need to go down at the moment 41 | elif type(message) == list: 42 | self.LogList(messageLevel,source,message) 43 | return # Log list will call this function so I don't need to go down at the moment 44 | 45 | if messageLevel == self.LOG_INFO: 46 | messageType = 'Info' 47 | elif messageLevel == self.LOG_ERROR: 48 | messageType = 'Error' 49 | elif messageLevel == self.LOG_WARNING: 50 | messageType = 'Warning' 51 | elif messageLevel == self.LOG_DEBUG: 52 | messageType = 'Debug' 53 | elif messageLevel == self.LOG_MESSAGE: 54 | messageType = 'Message' 55 | elif messageLevel == self.LOG_DEVELOPMENT: 56 | messageType = 'Dev' 57 | else: 58 | messageType = 'Logger' 59 | 60 | if self.monitor_id is None: # It's not the logger for a monitor 61 | monitor_text = 'Main' 62 | else: 63 | monitor_text = 'Monitor #' + str(self.monitor_id) 64 | 65 | prestring = '[ '+self.GetDatetimeString()+' | '+messageType.center(STRINGS_LENGTH[0]) + ' | '+monitor_text.center(STRINGS_LENGTH[1]) + \ 66 | ' | '+source.center(STRINGS_LENGTH[2])+']' + \ 67 | PRESTRING_MESSAGE_SEPARATOR_LEN*' ' # justify 68 | 69 | # Manage string to print in more lines if it's too long 70 | while len(message) > 0: 71 | string = prestring+message[:self.logger_message_width] 72 | # Cut for next iteration if message is longer than a line 73 | message = message[self.logger_message_width:] 74 | if(len(message) > 0): 75 | string = string+'-' # Print new line indicator if I will go down in the next iteration 76 | self.PrintAndSave(string, messageLevel) 77 | # -1 + space cause if the char in the prestring isn't a space, it will be directly attached to my message without a space 78 | 79 | prestring = (len(prestring)-PRESTRING_MESSAGE_SEPARATOR_LEN) * \ 80 | LONG_MESSAGE_PRESTRING_CHAR+PRESTRING_MESSAGE_SEPARATOR_LEN*' ' 81 | 82 | 83 | def LogDict(self, messageLevel, source, dict): 84 | try: 85 | string = json.dumps(dict, indent=4, sort_keys=False, default=lambda o: '') 86 | lines=string.splitlines() 87 | for line in lines: 88 | self.Log(messageLevel,source,"> "+line) 89 | except Exception as e: 90 | self.Log(self.LOG_ERROR,source,"Can't print dictionary content") 91 | 92 | def LogList(self, messageLevel, source, _list): 93 | try: 94 | for index, item in enumerate(_list): 95 | if type(item)==dict or type(item)==list: 96 | self.Log(messageLevel,source,"Item #"+str(index)) 97 | self.Log(messageLevel,source, item) 98 | else: 99 | self.Log(messageLevel,source,str(index) + ": " + str(item)) 100 | 101 | except: 102 | self.Log(self.LOG_ERROR,source,"Can't print dictionary content") 103 | 104 | 105 | def SetupFolder(self): 106 | if not os.path.exists(os.path.join(scriptFolder, LOGS_FOLDER)): 107 | os.mkdir(os.path.join(scriptFolder, LOGS_FOLDER)) 108 | 109 | def GetDatetimeString(self): 110 | now = datetime.datetime.now() 111 | return now.strftime(DATETIME_FORMAT) 112 | 113 | def PrintAndSave(self, string, level): 114 | if level <= self.console_log_level: 115 | print(string) 116 | if level <= self.file_log_level: 117 | with open(os.path.join(scriptFolder, LOGS_FOLDER, MAIN_LOG_FILENAME), "a") as logFile: 118 | logFile.write(string+' \n') 119 | 120 | def GetConfiguration(self): 121 | # Message width 122 | self.logger_message_width = Configurator.GetOption(self.globalConfig, [ 123 | LOGGER_CONFIG_KEY, LOGGER_MESSAGE_WIDTH_KEY], LOGGER_MESSAGE_WIDTH_DEFAULT) 124 | # File level 125 | self.file_log_level = Configurator.GetOption( 126 | self.globalConfig, [LOGGER_CONFIG_KEY, LOGGER_FILE_LEVEL_KEY], LOGGER_DEFAULT_LEVEL) 127 | # Console level 128 | self.console_log_level = Configurator.GetOption(self.globalConfig, [ 129 | LOGGER_CONFIG_KEY, LOGGER_CONSOLE_LEVEL_KEY], LOGGER_DEFAULT_LEVEL) 130 | 131 | 132 | class ExceptionTracker(): 133 | 134 | # Call this static method inside an except block 135 | def Track(): 136 | # Return file and line where exception occured 137 | exception_type, exception_object, exception_traceback = sys.exc_info() 138 | filename = exception_traceback.tb_frame.f_code.co_filename 139 | line_number = exception_traceback.tb_lineno 140 | return {'filename': filename, 'line': line_number} 141 | 142 | # Call this static method inside an except block, will return a formatted string with data 143 | def TrackString(exception): 144 | data = ExceptionTracker.Track() 145 | message = str(exception) 146 | return "Critical error in '{}' at line {}: {}".format(data['filename'], data['line'], message) 147 | -------------------------------------------------------------------------------- /Monitor.py: -------------------------------------------------------------------------------- 1 | from Configurator import Configurator as cf 2 | from MqttClient import MqttClient 3 | from Logger import Logger, ExceptionTracker 4 | import multiprocessing 5 | from consts import * 6 | 7 | 8 | class Monitor(): 9 | 10 | def __init__(self, config, globalConfig, entityManager, monitor_id=1): 11 | self.config = config 12 | self.globalConfig = globalConfig 13 | self.monitor_id = monitor_id 14 | self.entityManager = entityManager 15 | # Some Sensors and Commands, after load, will return which sensor or command they need to run 16 | self.requirements = [] 17 | self.loadedEntities = [] # To avoid reload for requirements, something is working 18 | self.Setup() 19 | 20 | def Setup(self): 21 | # Setup logger 22 | self.logger = Logger(self.globalConfig, self.monitor_id) 23 | self.Log(Logger.LOG_INFO, 'Starting') 24 | 25 | # Setup MQTT client 26 | self.mqttClient = MqttClient(self.config, self.logger) 27 | 28 | # HERE I TAKE THE SENSORS AND COMMANDS NAME FROM THE CONFIGURATION AND ASSIGN THE SUFFIX TO THE NAME TO LOAD THEM CORRECTLY. Add here the Custom part 29 | if CONFIG_SENSORS_KEY in self.config: 30 | self.LoadEntities( 31 | self.config[CONFIG_SENSORS_KEY], SENSOR_NAME_SUFFIX) 32 | if CONFIG_COMMANDS_KEY in self.config: 33 | self.LoadEntities( 34 | self.config[CONFIG_COMMANDS_KEY], COMMAND_NAME_SUFFIX) 35 | 36 | # While because some requirements may need other requirements themselves 37 | while(len(self.requirements)): 38 | self.Log(Logger.LOG_INFO, "Loading dependencies...") 39 | self.LoadRequirements() 40 | 41 | # Some need post-initialize configuration 42 | self.entityManager.PostInitializeEntities() 43 | # Some need post-initialize configuration 44 | # self.commandManager.PostInitializeCommands() 45 | 46 | def LoadEntities(self, entitiesToAdd, name_suffix, loadingRequirements=False): 47 | # From configs I read sensors list and I give the names to the sensors manager which will initialize them 48 | # and will keep trace of who is the mqtt_client and the logger of the sensor 49 | # self.entityManager.PostInitializeEntities() 50 | if entitiesToAdd: 51 | for entity in entitiesToAdd: 52 | # I load the sensor and if I need some requirements, I save them to the list 53 | # Additional check to not load double if I am loading requirements 54 | if not (loadingRequirements and entity in self.loadedEntities): 55 | settings = self.entityManager.LoadEntity(name_suffix, 56 | entity, self.monitor_id, self.config, self.mqttClient, self.config['send_interval'], self.logger) 57 | requirements = cf.GetOption( 58 | settings, SETTINGS_REQUIREMENTS_KEY) 59 | if requirements: 60 | self.requirements.append(requirements) 61 | self.loadedEntities.append(entity) 62 | 63 | def LoadRequirements(self): 64 | # Here I load sensors and commands 65 | # I have a dict with {'sensors':[SENSORS],'commands':[COMMANDS]} 66 | # SENSORS and COMMANDS have the same format as the configutaration.yaml so I 67 | # tell to LoadSensor and LoadCommands what to load with the usual method 68 | for requirements in self.requirements: 69 | sensors = cf.GetOption( 70 | requirements, SETTINGS_REQUIREMENTS_SENSOR_KEY) 71 | commands = cf.GetOption( 72 | requirements, SETTINGS_REQUIREMENTS_COMMAND_KEY) 73 | if sensors: 74 | self.LoadEntities(sensors, SENSOR_NAME_SUFFIX, loadingRequirements=True) 75 | if commands: 76 | self.LoadEntities(commands, COMMAND_NAME_SUFFIX, loadingRequirements=True) 77 | 78 | self.requirements.remove(requirements) 79 | 80 | def Log(self, messageType, message): 81 | self.logger.Log(messageType, 'Main', message) 82 | -------------------------------------------------------------------------------- /MqttClient.py: -------------------------------------------------------------------------------- 1 | import paho.mqtt.client as mqtt 2 | import paho.mqtt.publish as publish 3 | from Logger import Logger, ExceptionTracker 4 | 5 | 6 | class MqttClient(): 7 | client = None 8 | connected = False 9 | 10 | def __init__(self, config, logger): 11 | self.config = config 12 | self.logger = logger 13 | # Topics to subscribe, dict 'topic', Command (as 'callback') 14 | self.topics = [] 15 | self.subscribed_topics = [] # Topics already subscribed 16 | # Prepare the client 17 | self.Log(Logger.LOG_INFO, 'Preparing MQTT client') 18 | self.SetupClient() 19 | self.AsyncConnect() 20 | self.Log(Logger.LOG_INFO, 'MQTT Client ready to connect') 21 | 22 | # SETUP PART 23 | 24 | def AsyncConnect(self): 25 | # Connect async to the broker 26 | # If broker is not reachable, wait till he's reachable 27 | if 'port' in self.config: 28 | self.client.connect_async( 29 | self.config['broker'], port=self.config['port']) 30 | else: 31 | self.client.connect_async(self.config['broker']) 32 | # Client ready to start -> activate callback 33 | self.client.loop_start() 34 | 35 | def SetupClient(self): 36 | if 'mqtt_id' in self.config: 37 | self.client = mqtt.Client(self.config['mqtt_id']) 38 | else: 39 | self.client = mqtt.Client(self.config['name']) 40 | 41 | if 'username' in self.config and 'password' in self.config: 42 | self.client.username_pw_set( 43 | self.config['username'], self.config['password']) 44 | 45 | # Assign event callbacks 46 | self.client.on_connect = self.Event_OnClientConnect 47 | self.client.on_disconnect = self.Event_OnClientDisconnect 48 | self.client.on_message = self.Event_OnMessageReceive 49 | 50 | # INCOMING MESSAGES PART 51 | 52 | def SendTopicData(self, topic, data): 53 | self.client.publish(topic, data) 54 | 55 | # OUTCOMING MESSAGES PART 56 | 57 | def AddNewTopic(self, topic, callbackCommand): 58 | self.topics.append({'topic': topic, 'callback': callbackCommand}) 59 | self.SubscribeToTopic(topic) 60 | 61 | def SubscribeToTopic(self, topic): 62 | if topic not in self.subscribed_topics and self.connected: 63 | self.subscribed_topics.append(topic) 64 | self.client.subscribe(topic, 0) 65 | 66 | def UnsubscribeToTopic(self,topic): 67 | # Look for the topic in the list of tuples 68 | #for _tuple in self.subscribed_topics: 69 | #if _tuple[] 70 | 71 | if topic in self.subscribed_topics: 72 | for top in self.topics: 73 | if top['topic']==topic: 74 | self.topics.remove(top) 75 | self.subscribed_topics.remove(topic) 76 | self.client.unsubscribe(topic) 77 | 78 | 79 | def SubscribeToAllTopics(self): 80 | for topic in self.topics: 81 | self.SubscribeToTopic(topic['topic']) 82 | 83 | # EVENTS 84 | 85 | def Event_OnClientConnect(self, client, userdata, flags, rc): 86 | if rc == 0: # Connections is OK 87 | self.Log(Logger.LOG_INFO, "Connection established") 88 | self.connected = True 89 | self.SubscribeToAllTopics() 90 | else: 91 | self.Log(Logger.LOG_ERROR, "Connection error") 92 | 93 | def Event_OnClientDisconnect(self, client, userdata, rc): 94 | self.Log(Logger.LOG_ERROR, "Connection lost") 95 | self.connected = False 96 | self.subscribed_topics.clear() 97 | 98 | def Event_OnMessageReceive(self, client, userdata, message): 99 | # Compare message topic whith topics in my list 100 | for topic in self.topics: 101 | if self.TopicArrivedMatches(message, topic): 102 | # Run the callback function of the Command assigned to the topic 103 | topic['callback'].CallCallback(message) 104 | 105 | # Check if topic of the message that just arrived is the same with the one I'm checking. 106 | # Some subscription (that end with #) receive messages from topics that start only in the same way but the end is different 107 | # (it's like the * wildcard) then I have to match also them 108 | def TopicArrivedMatches(self, message, topic): 109 | if message.topic == topic['topic']: 110 | return True 111 | if topic['topic'].endswith('#'): 112 | if topic['topic'] == '#': 113 | return True 114 | # Cut the # and check if message topic starts with my topic without # 115 | myTopic = topic['topic'][:-1] 116 | if message.topic.startswith(myTopic): 117 | return True 118 | 119 | return False 120 | 121 | def Log(self, messageType, message): 122 | self.logger.Log(messageType, 'MQTT', message) 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyMonitorMQTT 2 | 3 | If you're looking for this script, you should look for **IoTuring**, the major upgrade of PyMonitorMQTT: lighter, faster and more powerful. Try it now ! 4 | 5 | [IoTuring: the new script to control your machine remotly](https://github.com/richibrics/IoTuring) 6 | 7 | ## HomeAssistant with PyMonitorMQTT example 8 | 9 | ![HomeAssistant Example](Home%20Assistant%20Monitors.png?raw=true "HomeAssistant Example") 10 | 11 | ## Authors 12 | 13 | **Riccardo Briccola** - Project development - [Github Account](https://github.com/richibrics) 14 | -------------------------------------------------------------------------------- /Settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import consts 3 | import json 4 | 5 | class Settings(): 6 | @staticmethod 7 | def GetMainFolder(): 8 | return str(os.path.dirname(os.path.realpath(__file__))) 9 | 10 | @staticmethod 11 | def GetInformation(): 12 | path = os.path.join(Settings.GetMainFolder(),consts.INFORMATION_FILENAME) 13 | if os.path.exists(path): 14 | with open(path,"r") as f: 15 | return json.loads(f.read()) -------------------------------------------------------------------------------- /StartupAgents/Linux/PyMonitorMQTT.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Python computer monitor that sends real time information via MQTT 3 | After=network.target 4 | 5 | [Service] 6 | Type=simple 7 | RemainAfterExit=yes 8 | # Change path: 9 | ExecStart=python3 /path/to/PyMonitorMQTT/main.py 10 | # Change user and group: 11 | User=user 12 | Group=group 13 | TimeoutStartSec=0 14 | 15 | [Install] 16 | WantedBy=default.target 17 | -------------------------------------------------------------------------------- /StartupAgents/MacOS/macOS.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Label 6 | my.python.monitor-mqtt 7 | ProgramArguments 8 | 9 | /usr/bin/python3 10 | /Users/riccardobriccola/Documents/Coding/PyMonitorMQTT/main.py 11 | 192.168.1.233 12 | iMacDiRiccardo 13 | homeassistant 14 | hello 15 | 16 | StandardErrorPath 17 | /Users/riccardobriccola/Documents/Coding/PyMonitorMQTT/log.err 18 | StandardOutPath 19 | /Users/riccardobriccola/Documents/Coding/PyMonitorMQTT/log.log 20 | KeepAlive 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /StartupAgents/README.md: -------------------------------------------------------------------------------- 1 | # PyMonitorMQTT 2 | ## Windows 3 | 1. Edit Windows/PyMonitorMQTT.vbs to point to the right path to the project. 4 | 2. Copy PyMonitorMQTT.vbs file from Windows folder. 5 | 2. Open "shell:startup" or "%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup". 6 | 3. Paste that file here. 7 | 8 | ## Linux with Systemd 9 | 10 | You can run this program in the background automatically on startup this way. 11 | 12 | 1. Edit `StartupAgents/Linux/PyMonitorMQTT.service` 13 | 14 | - Change path on `ExecStart` line to point to main.py 15 | 16 | - Change user and group 17 | 18 | 19 | 2. Copy the unit file, than enable and start the daemon 20 | 21 | ```bash 22 | sudo cp PyMonitorMQTT.service /etc/systemd/system/ 23 | sudo systemctl daemon-reload 24 | sudo systemctl enable PyMonitorMQTT.service 25 | sudo systemctl start PyMonitorMQTT.service 26 | ``` 27 | 28 | 3. Check if it's running correctly: 29 | 30 | ```bash 31 | systemctl status PyMonitorMQTT.service 32 | ``` 33 | 34 | To check the logs: 35 | 36 | ```bash 37 | sudo journalctl -u PyMonitorMQTT.service 38 | ``` -------------------------------------------------------------------------------- /StartupAgents/Windows/PyMonitorMQTT.vbs: -------------------------------------------------------------------------------- 1 | CreateObject("Wscript.Shell").Run """" & "D:\Projects\PyMonitorMQTT\StartupAgents\Windows\Win_command.bat" & """", 0, False -------------------------------------------------------------------------------- /StartupAgents/Windows/Win_command.bat: -------------------------------------------------------------------------------- 1 | cd /D "%~dp0" 2 | python3 ..\..\main.py -------------------------------------------------------------------------------- /ValueFormatter.py: -------------------------------------------------------------------------------- 1 | from consts import * 2 | import math 3 | from Configurator import Configurator as cf 4 | 5 | # Value formatter 6 | # If I have to return value not in byte but with MB/GB/KB; same for time 7 | 8 | # OPTIONS MEANING (example with byte values) 9 | # size: True "means that will be used the nearest unit 1024Byte->1KB" False "send the value without using the pow1024 mechianism" 10 | # size: MB "means that will be used the specified size 2014Byte->0.001MB" 11 | # unit_of_measurement: True/False "if you want to add the unit at the end of the value" 12 | # decimals: integer "the number of decimal to leave to the value" 13 | 14 | 15 | class ValueFormatter(): 16 | TYPE_NONE = 0 17 | TYPE_BYTE = 1 18 | TYPE_TIME = 2 19 | TYPE_PERCENTAGE = 3 20 | TYPE_FREQUENCY = 4 21 | 22 | @staticmethod 23 | def GetFormattedValue(value, valueType, options=None): 24 | if options==None: 25 | options=ValueFormatter.Options() # Default will be used 26 | 27 | if valueType == ValueFormatter.TYPE_NONE: # No edit needed 28 | return value 29 | elif valueType == ValueFormatter.TYPE_BYTE: 30 | return ValueFormatter.ByteFormatter(value,options) 31 | elif valueType == ValueFormatter.TYPE_TIME: 32 | return ValueFormatter.TimeFormatter(value, options) 33 | elif valueType == ValueFormatter.TYPE_FREQUENCY: 34 | return ValueFormatter.FrequencyFormatter(value, options) 35 | elif valueType == ValueFormatter.TYPE_PERCENTAGE: 36 | if cf.GetOption(options,VALUEFORMATTER_OPTIONS_UNIT_OF_MEASUREMENT_KEY,VALUEFORMATTER_OPTIONS_UNIT_OF_MEASUREMENT_DEFAULT): 37 | return str(value) + '%' 38 | else: 39 | return value 40 | else: 41 | return value 42 | 43 | # Get from number of bytes the correct byte size: 1045B is 1KB. If size_wanted passed and is SIZE_MEGABYTE, if I have 10^9B, I won't diplay 1GB but c.a. 1000MB 44 | @staticmethod 45 | def ByteFormatter(value,options): 46 | # Get value in bytes 47 | asked_size = cf.GetOption(options,VALUEFORMATTER_OPTIONS_SIZE_KEY) 48 | decimals = cf.GetOption(options,VALUEFORMATTER_OPTIONS_DECIMALS_KEY) 49 | 50 | if asked_size and asked_size in BYTE_SIZES: 51 | powOf1024 = BYTE_SIZES.index(asked_size) 52 | else: 53 | powOf1024 = math.floor(math.log(value, 1024)) 54 | 55 | result = str(round(value/(math.pow(1024, powOf1024)), decimals)) 56 | 57 | # Add unit 58 | if cf.GetOption(options,VALUEFORMATTER_OPTIONS_UNIT_OF_MEASUREMENT_KEY,VALUEFORMATTER_OPTIONS_UNIT_OF_MEASUREMENT_DEFAULT): 59 | result = result + BYTE_SIZES[powOf1024] 60 | 61 | return result 62 | 63 | @staticmethod 64 | def TimeFormatter(value, options): 65 | # Get value in milliseconds 66 | result=value 67 | # Add unit 68 | if cf.GetOption(options,VALUEFORMATTER_OPTIONS_UNIT_OF_MEASUREMENT_KEY,VALUEFORMATTER_OPTIONS_UNIT_OF_MEASUREMENT_DEFAULT): 69 | result = str(value) + 'ms' 70 | return result 71 | 72 | @staticmethod 73 | def FrequencyFormatter(value, options): 74 | # Get value in hertz 75 | result=value 76 | # Add unit 77 | if cf.GetOption(options,VALUEFORMATTER_OPTIONS_UNIT_OF_MEASUREMENT_KEY,VALUEFORMATTER_OPTIONS_UNIT_OF_MEASUREMENT_DEFAULT): 78 | result = str(value) + 'hz' 79 | return result 80 | 81 | @staticmethod 82 | def Options(decimals=VALUEFORMATTER_OPTIONS_DECIMALS_DEFAULT ,add_unit_of_measurement=VALUEFORMATTER_OPTIONS_UNIT_OF_MEASUREMENT_DEFAULT, adjust_size=VALUEFORMATTER_OPTIONS_SIZE_DEFAULT): 83 | return {VALUEFORMATTER_OPTIONS_DECIMALS_KEY: decimals, VALUEFORMATTER_OPTIONS_UNIT_OF_MEASUREMENT_KEY: add_unit_of_measurement, VALUEFORMATTER_OPTIONS_SIZE_KEY:adjust_size} 84 | 85 | #def -------------------------------------------------------------------------------- /configuration-homeassistant.yaml: -------------------------------------------------------------------------------- 1 | logger: 2 | logger_message_width: 70 3 | file_level: 3 4 | console_level: 4 5 | 6 | monitors: 7 | - broker: BROKER_ADDRESS 8 | name: COMPUTER_NAME 9 | send_interval: 20 # Seconds 10 | 11 | advanced_information: True # Set to False to receive less data 12 | 13 | discovery: 14 | enable: True 15 | preset: homeassistant 16 | discover_prefix: homeassistant # prefix of config topic, this is the default value of homeassistant 17 | name_prefix: True # To add the computer name in front of sensors and switches name 18 | publish_interval: 30 19 | 20 | sensors: 21 | - State # Important to send availability information 22 | - Os 23 | - Ram 24 | - Disk 25 | - Cpu 26 | - Battery 27 | - CpuTemperatures 28 | - Network 29 | - DesktopEnvironment: # When the software returns "base", it's better for you to manually set it 30 | contents: 31 | value: gnome 32 | - Screenshot 33 | 34 | commands: 35 | - Shutdown 36 | - Lock 37 | - Reboot 38 | - Sleep 39 | - Brightness 40 | - TurnOffMonitors 41 | - TurnOnMonitors 42 | -------------------------------------------------------------------------------- /configuration.yaml.example: -------------------------------------------------------------------------------- 1 | logger_message_width: 50 2 | 3 | monitors: 4 | - broker: 3.122.209.170 5 | name: 5f685dc02e9d83468db6e0f7 6 | send_interval: 20 # Seconds 7 | 8 | sensors: 9 | - Os 10 | - DesktopEnvironment 11 | 12 | commands: 13 | - Notify: 14 | custom_topics: 15 | - 5f685dc02e9d83468db6e0f7/directive/powerState 16 | 17 | 18 | -------------------------------------------------------------------------------- /consts.py: -------------------------------------------------------------------------------- 1 | SENSOR_NAME_SUFFIX = "Sensor" 2 | COMMAND_NAME_SUFFIX = "Command" 3 | 4 | SEND_INTERVAL_DEFAULT = 20 5 | 6 | TYPE_TOPIC_IN = 0 7 | TYPE_TOPIC_OUT = 1 8 | 9 | # Topic format 10 | TOPIC_FORMAT = 'monitor/{}/{}' 11 | AUTODISCOVERY_TOPIC_CONFIG_FORMAT = "{}/{}/monitormqtt/{}/config" 12 | 13 | CONFIG_MONITORS_KEY = "monitors" 14 | 15 | # Inside monitor keys 16 | CONFIG_COMMANDS_KEY = 'commands' 17 | CONFIG_SENSORS_KEY = 'sensors' 18 | CONFIG_BROKER_KEY = 'broker' 19 | CONFIG_PORT_KEY = 'port' 20 | CONFIG_NAME_KEY = 'name' 21 | CONFIG_SEND_INTERVAL_KEY = 'send_interval' 22 | CONFIG_USERNAME_KEY = 'username' 23 | CONFIG_PASSWORD_KEY = 'password' 24 | CONFIG_MQTT_ID_KEY = 'mqtt_id' 25 | CONFIG_DISCOVERY_KEY = "discovery" 26 | 27 | CONFIG_PORT_DEFAULT = 1883 28 | 29 | 30 | INFORMATION_FILENAME = "information.json" 31 | 32 | 33 | # Option parsers will look for these keys in config dicts 34 | ADVANCED_INFO_OPTION_KEY = 'advanced_information' # Extra info from sensors 35 | ADVANCED_INFO_OPTION_DEFAULT = False 36 | 37 | CUSTOM_TOPICS_OPTION_KEY = 'custom_topics' # To set custom topics to sensors/commands 38 | CUSTOM_TOPICS_OPTION_DEFAULT = [] 39 | 40 | DONT_SEND_DATA_OPTION_KEY = 'dont_send' # Don't send sensors values and autodiscovery config 41 | DONT_SEND_DATA_OPTION_DEFAULT = False 42 | 43 | VALUE_FORMAT_OPTION_KEY = 'value_format' # Dict where user can place the VALUEFORMATTER_OPTIONS_x 44 | VALUE_FORMAT_OPTION_DEFAULT = {} 45 | 46 | CONTENTS_OPTION_KEY = 'contents' # To pass extra options to sensors (example: notify receives message and title with this) 47 | CONTENTS_OPTION_DEFAULT = {} 48 | 49 | # list of tuples key,default 50 | SCAN_OPTIONS = [ 51 | ADVANCED_INFO_OPTION_KEY, 52 | CUSTOM_TOPICS_OPTION_KEY, 53 | DONT_SEND_DATA_OPTION_KEY, 54 | VALUE_FORMAT_OPTION_KEY, 55 | CONTENTS_OPTION_KEY, 56 | CONFIG_DISCOVERY_KEY 57 | ] 58 | 59 | # Removed DEBUG mode because you only need to set the console/file level to debug or upper 60 | 61 | FIXED_VALUE_OS_MACOS = "macOS" 62 | FIXED_VALUE_OS_WINDOWS = "Windows" 63 | FIXED_VALUE_OS_LINUX = "Linux" 64 | 65 | OBJECT_SETTINGS_FILENAME = "settings.yaml" 66 | 67 | ONLINE_STATE = "Online" 68 | OFFLINE_STATE = "Offline" 69 | 70 | 71 | # LOGGER CONSTS 72 | LOGGER_CONFIG_KEY = "logger" 73 | # Split in more lines if message's too long 74 | LOGGER_MESSAGE_WIDTH_KEY = "logger_message_width" 75 | LOGGER_MESSAGE_WIDTH_DEFAULT = 40 76 | LOGGER_FILE_LEVEL_KEY = "file_level" 77 | LOGGER_CONSOLE_LEVEL_KEY = "console_level" 78 | LOGGER_DEFAULT_LEVEL = 3 79 | 80 | # DISCOVERY 81 | DISCOVERY_ENABLE_KEY = "enable" 82 | DISCOVERY_DISCOVER_PREFIX_KEY = "discover_prefix" 83 | DISCOVERY_NAME_PREFIX_KEY = "name_prefix" 84 | DISCOVERY_PUBLISH_INTERVAL_KEY = "publish_interval" 85 | DISCOVERY_PRESET_KEY = "preset" 86 | DISCOVERY_EXPIRE_AFTER_KEY = "expire_after" 87 | 88 | # Defaults 89 | DISCOVERY_DISCOVER_PREFIX_DEFAULT = "monitor" 90 | DISCOVERY_NAME_PREFIX_DEFAULT = False 91 | DISCOVERY_PUBLISH_INTERVAL_DEFAULT = 30 92 | 93 | # Should be greater than twice the publish interval 94 | # It's the time after sensors are unavailable if not messages are received in these seconds 95 | DISCOVERY_EXPIRE_AFTER_DEFAULT = 60 96 | 97 | # Sensor and command settings 98 | SETTINGS_REQUIREMENTS_KEY = "requirements" 99 | SETTINGS_REQUIREMENTS_SENSOR_KEY = "sensors" 100 | SETTINGS_REQUIREMENTS_COMMAND_KEY = "commands" 101 | 102 | # where in the entity settings, options like payload, sensor type and topic will be placed 103 | SETTINGS_DISCOVERY_KEY = "discovery" 104 | SETTINGS_DISCOVERY_PRESET_PAYLOAD_KEY = "payload" 105 | SETTINGS_DISCOVERY_PRESET_TYPE_KEY = "type" 106 | SETTINGS_DISCOVERY_PRESET_DISABLE_KEY = "disable" 107 | SETTINGS_DISCOVERY_EXPIRE_AFTER_KEY = DISCOVERY_EXPIRE_AFTER_KEY # must be the same of the monitor one (so Entity.GetOption will use it's priority) 108 | 109 | SETTINGS_DISCOVERY_ADVANCED_TOPIC_KEY = "advanced_topic" 110 | 111 | # Where in the user configuration the custom PAYLOAD will be placed 112 | ENTITY_DISCOVERY_KEY = "discovery" 113 | ENTITY_DISCOVERY_PAYLOAD_KEY = "settings" 114 | 115 | ENTITIES_PATH = "Entities" 116 | CUSTOM_ENTITIES_PATH = "Custom" 117 | 118 | ON_STATE = "On" 119 | OFF_STATE = "Off" 120 | 121 | 122 | 123 | # Lists of measure units 124 | BYTE_SIZES = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] 125 | 126 | # In the user config for sensor: 127 | FORMATTED_VALUE_SIZE_OPTION_KEY = "size" 128 | # "size" can be set to these: 129 | SIZE_BYTE = "B" 130 | SIZE_KILOBYTE = "KB" 131 | SIZE_MEGABYTE = "MB" 132 | SIZE_GIGABYTE = "GB" 133 | SIZE_TERABYTE = "TB" 134 | 135 | 136 | # Boolean value to add or not the unit to the end of the value 137 | VALUEFORMATTER_OPTIONS_UNIT_OF_MEASUREMENT_KEY = "unit_of_measurement" 138 | # only for Xbytes: If I have 1GB but I want the values as MB, setting size to SIZE_MEGABYTE, will send 1000MB 139 | VALUEFORMATTER_OPTIONS_SIZE_KEY = "size" 140 | # Number of decimals for the numeric value 141 | VALUEFORMATTER_OPTIONS_DECIMALS_KEY = "decimals" 142 | 143 | # Default values 144 | VALUEFORMATTER_OPTIONS_DECIMALS_DEFAULT = 2 145 | VALUEFORMATTER_OPTIONS_SIZE_DEFAULT = False # None is Disabled 146 | VALUEFORMATTER_OPTIONS_UNIT_OF_MEASUREMENT_DEFAULT = False 147 | -------------------------------------------------------------------------------- /information.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "PyMonitorMQTT", 3 | "website": "https://richibrics.github.io/PyMonitorMQTT/", 4 | "repository": "https://github.com/richibrics/PyMonitorMQTT", 5 | "developer": "Riccardo Briccola", 6 | "version": "1.0.0" 7 | } -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import yaml 3 | import time 4 | from Logger import Logger, ExceptionTracker 5 | from EntityManager import EntityManager 6 | from ClassManager import ClassManager # To list entities in help 7 | import consts 8 | import sys 9 | from Monitor import Monitor 10 | from schemas import ROOT_SCHEMA 11 | 12 | config = None 13 | config_filename = 'configuration.yaml' 14 | 15 | scriptFolder = str(os.path.dirname(os.path.realpath(__file__))) 16 | 17 | 18 | def LoadYAML(): 19 | global config 20 | with open(os.path.join(scriptFolder, config_filename)) as f: 21 | config = yaml.load(f, Loader=yaml.FullLoader) 22 | 23 | config = ROOT_SCHEMA(config) # Here I validate che config schema (not for entities) 24 | 25 | 26 | def SetupMonitors(): 27 | # Setup manager 28 | entityManager = EntityManager( 29 | config) 30 | 31 | # If I have not a list of monitors, I setup only a monitor 32 | if (consts.CONFIG_MONITORS_KEY not in config): 33 | monitor = Monitor(config, config, entityManager) 34 | else: # More Monitors 35 | # Now setup monitors 36 | monitor_id = 0 37 | for monitor_config in config[consts.CONFIG_MONITORS_KEY]: 38 | monitor_id += 1 39 | monitor = Monitor(monitor_config, config, 40 | entityManager, monitor_id) 41 | 42 | # Start sensors loop 43 | entityManager.Start() 44 | 45 | 46 | def OutputAvailableEntities(): 47 | sensors = [] 48 | commands = [] 49 | classManager=ClassManager(None) # Loads the entities files 50 | 51 | for entityFilename in classManager.modulesFilename: 52 | entityName = classManager.ModuleNameFromPath(entityFilename) 53 | if consts.SENSOR_NAME_SUFFIX in entityName: 54 | sensors.append(entityName.split(consts.SENSOR_NAME_SUFFIX)[0]) 55 | elif consts.COMMAND_NAME_SUFFIX in entityName: 56 | commands.append(entityName.split(consts.COMMAND_NAME_SUFFIX)[0]) 57 | 58 | sensors.sort() 59 | commands.sort() 60 | 61 | if len(sensors) == 0: 62 | raise("Can't load any sensor") 63 | else: 64 | print("Sensors:") 65 | for sensor in sensors: 66 | print(" -",sensor) 67 | 68 | if len(commands) == 0: 69 | raise("Can't load any command") 70 | else: 71 | print("Commands:") 72 | for command in commands: 73 | print(" -",command) 74 | 75 | 76 | if __name__ == "__main__": 77 | try: 78 | # Do we have a config file? 79 | config_path = scriptFolder + '/' + config_filename 80 | if not os.path.isfile(config_path): 81 | print("\nOops, looks like you've not setup a configuration.yaml file yet!") 82 | print("Tried to load: {}".format(config_path)) 83 | print(" See the configuration.yaml.example to get you started\n") 84 | print("Check the wiki and/or website for help") 85 | print(" https://github.com/richibrics/PyMonitorMQTT/wiki - https://richibrics.github.io/PyMonitorMQTT/\n") 86 | print("Here's a list of options to get you started....") 87 | OutputAvailableEntities() 88 | exit(1) 89 | if len(sys.argv) == 1: 90 | # Run the main logic 91 | LoadYAML() 92 | SetupMonitors() 93 | else: 94 | # Additional command line logic 95 | x1 = sys.argv[1] 96 | # Very basic help command 97 | if (x1 == 'help') or (x1 == '-h') or (x1 == '--help') or (x1 == '--h'): 98 | OutputAvailableEntities() 99 | exit(1) 100 | 101 | print( 102 | "Run without arguments to start application or use --help to see available options") 103 | except Exception as exc: # Main try except to give information about exception management 104 | logger = Logger(config) 105 | logger.Log(Logger.LOG_ERROR, 'Main', 106 | ExceptionTracker.TrackString(exc)) 107 | logger.Log(Logger.LOG_ERROR, 'Main', 108 | 'Try to check your configuration.yaml') 109 | logger.Log(Logger.LOG_ERROR, 'Main', 110 | "If the problem persists, check issues (or open a new one) at 'https://github.com/richibrics/PyMonitorMQTT'") 111 | exit(1) 112 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | EasyProcess==0.3 2 | entrypoint2==0.2.1 3 | jeepney==0.4.3 4 | mss==6.0.0 5 | paho-mqtt==1.5.0 6 | Pillow 7 | psutil==5.7.2 8 | pyscreenshot==2.2 9 | PyYAML==5.4 10 | voluptuous 11 | -------------------------------------------------------------------------------- /schemas.py: -------------------------------------------------------------------------------- 1 | from consts import * 2 | from voluptuous import * 3 | import voluptuous 4 | 5 | # where in defaults I put (for example) MONITOR_VALUE_FORMAT_SCHEMA({}), in MONITOR_VALUE_FORMAT_SCHEMA I must not have required 6 | # fields because then I can't use the ({}) system for default in the parent schema 7 | 8 | # ROOT 9 | 10 | # Root schema will be validated at start, each entity schema will be validated at entity init 11 | 12 | MONITOR_VALUE_FORMAT_SCHEMA = Schema({ 13 | Optional(VALUEFORMATTER_OPTIONS_UNIT_OF_MEASUREMENT_KEY, default=VALUEFORMATTER_OPTIONS_UNIT_OF_MEASUREMENT_DEFAULT): bool, 14 | Optional(VALUEFORMATTER_OPTIONS_DECIMALS_KEY, default=VALUEFORMATTER_OPTIONS_DECIMALS_DEFAULT): int, 15 | Optional(VALUEFORMATTER_OPTIONS_SIZE_KEY, default=VALUEFORMATTER_OPTIONS_SIZE_DEFAULT): Or(str,False) 16 | }) 17 | 18 | MONITOR_LOGGER_SCHEMA = Schema({ 19 | Optional(LOGGER_MESSAGE_WIDTH_KEY,default=LOGGER_MESSAGE_WIDTH_DEFAULT): int, 20 | Optional(LOGGER_FILE_LEVEL_KEY,default=LOGGER_DEFAULT_LEVEL): int, 21 | Optional(LOGGER_CONSOLE_LEVEL_KEY,default=LOGGER_DEFAULT_LEVEL): int 22 | }) 23 | 24 | MONITOR_DISCOVERY_SCHEMA = Schema({ 25 | Required(DISCOVERY_ENABLE_KEY): bool, 26 | Required(DISCOVERY_PRESET_KEY): str, 27 | Optional(DISCOVERY_DISCOVER_PREFIX_KEY, default=DISCOVERY_DISCOVER_PREFIX_DEFAULT): str, 28 | Optional(DISCOVERY_NAME_PREFIX_KEY, default=False): bool, 29 | Optional(DISCOVERY_PUBLISH_INTERVAL_KEY, default=DISCOVERY_NAME_PREFIX_DEFAULT): int, 30 | Optional(DISCOVERY_EXPIRE_AFTER_KEY): int 31 | }) 32 | 33 | 34 | MONITOR_SCHEMA = Schema({ 35 | Required(CONFIG_BROKER_KEY): str, 36 | 37 | Optional(CONFIG_PORT_KEY,default=CONFIG_PORT_DEFAULT): int, 38 | 39 | Required(CONFIG_NAME_KEY): str, 40 | Optional(CONFIG_MQTT_ID_KEY): str, 41 | Optional(CONFIG_SEND_INTERVAL_KEY, default=SEND_INTERVAL_DEFAULT): int, 42 | 43 | Optional(CONFIG_USERNAME_KEY): str, 44 | Optional(CONFIG_PASSWORD_KEY): str, 45 | 46 | Optional(CONFIG_DISCOVERY_KEY): MONITOR_DISCOVERY_SCHEMA, 47 | 48 | Optional(ADVANCED_INFO_OPTION_KEY, default=ADVANCED_INFO_OPTION_DEFAULT): bool, 49 | Optional(CUSTOM_TOPICS_OPTION_KEY, default=CUSTOM_TOPICS_OPTION_DEFAULT): Or(str,[str]), 50 | Optional(DONT_SEND_DATA_OPTION_KEY, default=DONT_SEND_DATA_OPTION_DEFAULT): bool, 51 | Optional(VALUE_FORMAT_OPTION_KEY, default=MONITOR_VALUE_FORMAT_SCHEMA({})): MONITOR_VALUE_FORMAT_SCHEMA, 52 | 53 | Optional(CONFIG_SENSORS_KEY,default=[]): [Or(str,dict)], 54 | Optional(CONFIG_COMMANDS_KEY,default=[]): [Or(str,dict)] 55 | }) 56 | 57 | 58 | ROOT_SCHEMA = Schema({ 59 | Required(CONFIG_MONITORS_KEY): [MONITOR_SCHEMA], 60 | Optional(LOGGER_CONFIG_KEY,default=MONITOR_LOGGER_SCHEMA({})): MONITOR_LOGGER_SCHEMA 61 | }) 62 | 63 | 64 | # ENTITY SCHEMAS 65 | 66 | # Part of the entity schemas where I have schemas with same values as before but without defaults: defaults are the configuration from the monitor schemas upper 67 | 68 | ENTITY_VALUE_FORMAT_SCHEMA = Schema({ 69 | Optional(VALUEFORMATTER_OPTIONS_UNIT_OF_MEASUREMENT_KEY): bool, 70 | Optional(VALUEFORMATTER_OPTIONS_DECIMALS_KEY): int, 71 | Optional(VALUEFORMATTER_OPTIONS_SIZE_KEY): Or(str,False) 72 | }) 73 | 74 | 75 | ENTITY_DISCOVERY_SCHEMA = Schema({ 76 | Optional(DISCOVERY_ENABLE_KEY): bool, 77 | Optional(DISCOVERY_PRESET_KEY): str, 78 | Optional(DISCOVERY_DISCOVER_PREFIX_KEY): str, 79 | Optional(DISCOVERY_NAME_PREFIX_KEY): bool, 80 | Optional(DISCOVERY_PUBLISH_INTERVAL_KEY): int, 81 | Optional(SETTINGS_DISCOVERY_EXPIRE_AFTER_KEY): int, 82 | Optional(ENTITY_DISCOVERY_PAYLOAD_KEY): Or(dict,list) # Where I can put the name for the entity in the hub and the custom icon without editing the entity code 83 | }) 84 | 85 | 86 | 87 | ENTITY_DEFAULT_SCHEMA = Schema({ # No default here (only custom topics), will be used monitor config if not set here 88 | Optional(ADVANCED_INFO_OPTION_KEY): bool, 89 | Optional(CUSTOM_TOPICS_OPTION_KEY, default=CUSTOM_TOPICS_OPTION_DEFAULT): Or(str,[str]), 90 | Optional(DONT_SEND_DATA_OPTION_KEY): bool, 91 | Optional(VALUE_FORMAT_OPTION_KEY): ENTITY_VALUE_FORMAT_SCHEMA, 92 | Optional(ENTITY_DISCOVERY_KEY): ENTITY_DISCOVERY_SCHEMA 93 | }) --------------------------------------------------------------------------------