From 4e061b0f57d682fd5a10ad7a4064f511fdbe2f95 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sat, 7 Oct 2023 03:38:08 +0200 Subject: [PATCH 01/11] Lock with loginctl --- IoTuring/Entity/Deployments/Lock/Lock.py | 56 ++++++++++++++++-------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/IoTuring/Entity/Deployments/Lock/Lock.py b/IoTuring/Entity/Deployments/Lock/Lock.py index 95821d8bc..1949d5b57 100644 --- a/IoTuring/Entity/Deployments/Lock/Lock.py +++ b/IoTuring/Entity/Deployments/Lock/Lock.py @@ -2,7 +2,8 @@ from IoTuring.Entity.Entity import Entity from IoTuring.Entity.EntityData import EntityCommand from IoTuring.MyApp.SystemConsts import DesktopEnvironmentDetection as De -from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD # don't name Os as could be a problem with old configurations that used the Os entity +# don't name Os as could be a problem with old configurations that used the Os entity: +from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD KEY_LOCK = 'lock' @@ -17,7 +18,7 @@ 'gnome': 'gnome-screensaver-command -l', 'cinnamon': 'cinnamon-screensaver-command -a', 'i3': 'i3lock', - 'plasma': 'loginctl lock-session' + 'base': 'loginctl lock-session' } } @@ -26,24 +27,41 @@ class Lock(Entity): NAME = "Lock" def Initialize(self): - self.RegisterEntityCommand(EntityCommand( - self, KEY_LOCK, self.Callback_Lock)) - self.os = OsD.GetOs() self.de = De.GetDesktopEnvironment() - def Callback_Lock(self, message): - if self.os in commands: - if self.de in commands[self.os]: - try: - command = commands[self.os][self.de] - process = subprocess.Popen( - command.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE) - except Exception as e: - raise Exception('Error during system lock: ' + str(e)) - else: - raise Exception( - 'No lock command for this Desktop Environment: ' + self.de) + if self.os not in commands: + raise Exception("Unsupported operating system for this entity") + + # Fallback to base, if unsupported de: + if self.de == "base" or self.de not in commands[self.os]: + desktops = ["base"] + + # supported de, add base as fallback: else: - raise Exception( - 'No lock command for this Operating System: ' + self.os) + desktops = [self.de, "base"] + + # Check if command works: + try: + self.command = next((commands[self.os][de] for de in desktops + if OsD.CommandExists(commands[self.os][de].split()[0]))) + + except StopIteration: + raise Exception(f"No lock command found for this system") + + self.Log(self.LOG_DEBUG, f"Found lock command: {self.command}") + + self.RegisterEntityCommand(EntityCommand( + self, KEY_LOCK, self.Callback_Lock)) + + def Callback_Lock(self, message): + try: + p = subprocess.run(self.command.split(), capture_output=True) + self.Log(self.LOG_DEBUG, f"Called lock command: {p}") + + if p.stderr: + self.Log(self.LOG_ERROR, + f"Error during system lock: {p.stdout}") + + except Exception as e: + raise Exception('Error during system lock: ' + str(e)) From beeb573c0d7f5ea9695c04e6d3916b007b59b355 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Mon, 9 Oct 2023 18:19:57 +0200 Subject: [PATCH 02/11] Linux commands --- IoTuring/Entity/Deployments/Lock/Lock.py | 2 +- .../Entity/Deployments/Monitor/Monitor.py | 30 ++++----- IoTuring/Entity/Deployments/Power/Power.py | 66 +++++++++++++------ .../DesktopEnvironmentDetection.py | 16 +++++ 4 files changed, 77 insertions(+), 37 deletions(-) diff --git a/IoTuring/Entity/Deployments/Lock/Lock.py b/IoTuring/Entity/Deployments/Lock/Lock.py index 1949d5b57..79c1ba2c1 100644 --- a/IoTuring/Entity/Deployments/Lock/Lock.py +++ b/IoTuring/Entity/Deployments/Lock/Lock.py @@ -61,7 +61,7 @@ def Callback_Lock(self, message): if p.stderr: self.Log(self.LOG_ERROR, - f"Error during system lock: {p.stdout}") + f"Error during system lock: {p.stderr.decode()}") except Exception as e: raise Exception('Error during system lock: ' + str(e)) diff --git a/IoTuring/Entity/Deployments/Monitor/Monitor.py b/IoTuring/Entity/Deployments/Monitor/Monitor.py index 2b5c11901..71d3d0e5e 100644 --- a/IoTuring/Entity/Deployments/Monitor/Monitor.py +++ b/IoTuring/Entity/Deployments/Monitor/Monitor.py @@ -16,30 +16,26 @@ class Monitor(Entity): NAME = "Monitor" def Initialize(self): - supports_linux = False + if OsD.IsLinux(): if De.IsWayland(): raise Exception("Wayland is not supported") - elif not OsD.CommandExists("xset"): - raise Exception("xset command not found!") else: - # Check if xset is working: - p = subprocess.run(['xset', 'dpms'], capture_output=True, shell=False) - if p.stderr: - raise Exception(f"Xset dpms error: {p.stderr.decode()}") - elif not OsD.GetEnv('DISPLAY'): - raise Exception('No $DISPLAY environment variable!') - else: - supports_linux = True + try: + De.CheckXsetSupport() + except Exception as e: + raise Exception(f'Xset not supported: {str(e)}') + + self.RegisterEntitySensor(EntitySensor(self, KEY_STATE)) + self.RegisterEntityCommand(EntityCommand( + self, KEY_CMD, self.Callback, KEY_STATE)) - if OsD.IsWindows(): + elif OsD.IsWindows(): self.RegisterEntityCommand(EntityCommand( self, KEY_CMD, self.Callback)) - elif supports_linux: # True only if linux and command is working - # Support for sending state on linux - self.RegisterEntitySensor(EntitySensor(self, KEY_STATE)) - self.RegisterEntityCommand(EntityCommand( - self, KEY_CMD, self.Callback, KEY_STATE)) + + else: + raise Exception("Operating System not supported!") def Callback(self, message): payloadString = message.payload.decode('utf-8') diff --git a/IoTuring/Entity/Deployments/Power/Power.py b/IoTuring/Entity/Deployments/Power/Power.py index dbe20f8bd..5c5ae3f93 100644 --- a/IoTuring/Entity/Deployments/Power/Power.py +++ b/IoTuring/Entity/Deployments/Power/Power.py @@ -1,8 +1,9 @@ import subprocess -import os as sys_os + from IoTuring.Entity.Entity import Entity from IoTuring.Entity.EntityData import EntityCommand -from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD # don't name Os as could be a problem with old configurations that used the Os entity +from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD +from IoTuring.MyApp.SystemConsts import DesktopEnvironmentDetection as De KEY_SHUTDOWN = 'shutdown' @@ -12,18 +13,20 @@ commands_shutdown = { 'Windows': 'shutdown /s /t 0', 'macOS': 'sudo shutdown -h now', - 'Linux': 'sudo shutdown -h now' + 'Linux': 'poweroff' } + commands_reboot = { 'Windows': 'shutdown /r', 'macOS': 'sudo reboot', - 'Linux': 'sudo reboot' + 'Linux': 'reboot' } commands_sleep = { 'Windows': 'rundll32.exe powrprof.dll,SetSuspendState 0,1,0', - 'Linux_X11': 'xset dpms force standby' + 'Linux': 'systemctl suspend', + 'Linux_X11': 'xset dpms force standby', } @@ -31,40 +34,65 @@ class Power(Entity): NAME = "Power" def Initialize(self): - self.sleep_command = "" + self.commands = {} self.os = OsD.GetOs() # Check if commands are available for this OS/DE combo, then register them # Shutdown if self.os in commands_shutdown: + self.commands[KEY_SHUTDOWN] = commands_shutdown[self.os] self.RegisterEntityCommand(EntityCommand( self, KEY_SHUTDOWN, self.CallbackShutdown)) # Reboot if self.os in commands_reboot: + self.commands[KEY_REBOOT] = commands_reboot[self.os] self.RegisterEntityCommand(EntityCommand( self, KEY_REBOOT, self.CallbackReboot)) + # Try if command works without sudo, add if it's not working: + if OsD.IsLinux(): + for commandtype in self.commands: + testcommand = self.commands[commandtype] + " --wtmp-only" + if not subprocess.run(testcommand.split(), capture_output=True).returncode == 0: + self.commands[commandtype] = "sudo " + \ + self.commands[commandtype] + # Sleep - # TODO Update TurnOffMonitors, TurnOnMonitors, LockCommand to use prefix lookup below - # Additional linux checking to find Window Manager: check running X11 for linux - prefix = '' - if OsD.IsLinux() and sys_os.environ.get('DISPLAY'): - prefix = '_X11' - lookup_key = self.os + prefix - if lookup_key in commands_sleep: - self.sleep_command = commands_sleep[lookup_key] + if self.os in commands_sleep: + self.commands[KEY_SLEEP] = commands_sleep[self.os] + + # Fallback to xset, if supported: + if OsD.IsLinux() and not De.IsWayland(): + try: + De.CheckXsetSupport() + self.commands[KEY_SLEEP] = commands_sleep["Linux_X11"] + except Exception as e: + self.Log(self.LOG_DEBUG, f'Xset not supported: {str(e)}') + self.RegisterEntityCommand(EntityCommand( self, KEY_SLEEP, self.CallbackSleep)) + def CallCommand(self, command_key: str) -> None: + # Log if a command not working: + try: + p = subprocess.run( + self.commands[command_key].split(), capture_output=True) + self.Log(self.LOG_DEBUG, f"Called {command_key} command: {p}") + + if p.stderr: + self.Log(self.LOG_ERROR, + f"Error during system {command_key}: {p.stderr}") + + except Exception as e: + raise Exception(f'Error during system {command_key}: {str(e)}') + def CallbackShutdown(self, message): - subprocess.Popen( - commands_shutdown[self.os].split(), stdout=subprocess.PIPE) + self.CallCommand(KEY_SHUTDOWN) def CallbackReboot(self, message): - subprocess.Popen( - commands_reboot[self.os].split(), stdout=subprocess.PIPE) + self.CallCommand(KEY_REBOOT) def CallbackSleep(self, message): - subprocess.Popen(self.sleep_command.split(), stdout=subprocess.PIPE) + self.CallCommand(KEY_SLEEP) diff --git a/IoTuring/MyApp/SystemConsts/DesktopEnvironmentDetection.py b/IoTuring/MyApp/SystemConsts/DesktopEnvironmentDetection.py index 4235c7be3..4c0bc41f2 100644 --- a/IoTuring/MyApp/SystemConsts/DesktopEnvironmentDetection.py +++ b/IoTuring/MyApp/SystemConsts/DesktopEnvironmentDetection.py @@ -1,3 +1,4 @@ +import subprocess from IoTuring.MyApp.SystemConsts.OperatingSystemDetection import OperatingSystemDetection as OsD @@ -16,3 +17,18 @@ def IsWayland() -> bool: OsD.GetEnv('WAYLAND_DISPLAY') or OsD.GetEnv('XDG_SESSION_TYPE') == 'wayland' ) + + @staticmethod + def CheckXsetSupport() -> None: + """ Check if system supports xset. Raises exception if not supported """ + if not OsD.CommandExists("xset"): + raise Exception("xset command not found!") + else: + # Check if xset is working: + p = subprocess.run(['xset', 'dpms'], capture_output=True, shell=False) + if p.stderr: + raise Exception(f"Xset dpms error: {p.stderr.decode()}") + elif not OsD.GetEnv('DISPLAY'): + raise Exception('No $DISPLAY environment variable!') + + \ No newline at end of file From d7ab5036ebf7c3dc75525758d51949332929ee63 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Thu, 19 Oct 2023 02:46:48 +0200 Subject: [PATCH 03/11] Notification icon on linux --- IoTuring/Entity/Deployments/Notify/Notify.py | 91 ++++++++++++------- IoTuring/Entity/Deployments/Notify/icon.png | Bin 7850 -> 8370 bytes 2 files changed, 59 insertions(+), 32 deletions(-) mode change 100644 => 100755 IoTuring/Entity/Deployments/Notify/icon.png diff --git a/IoTuring/Entity/Deployments/Notify/Notify.py b/IoTuring/Entity/Deployments/Notify/Notify.py index e2156730c..8339a36d2 100644 --- a/IoTuring/Entity/Deployments/Notify/Notify.py +++ b/IoTuring/Entity/Deployments/Notify/Notify.py @@ -4,18 +4,18 @@ from IoTuring.Configurator.MenuPreset import MenuPreset from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD -import json - import os +import json +import subprocess supports_win = True try: - import tinyWinToast.tinyWinToast as twt + import tinyWinToast.tinyWinToast as twt # type: ignore except: supports_win = False commands = { - OsD.OS_FIXED_VALUE_LINUX: 'notify-send "{}" "{}"', + OsD.OS_FIXED_VALUE_LINUX: 'notify-send "{}" "{}" --icon="ICON_PATH"', OsD.OS_FIXED_VALUE_MACOS: 'osascript -e \'display notification "{}" with title "{}"\'' } @@ -29,12 +29,15 @@ CONFIG_KEY_TITLE = "title" CONFIG_KEY_MESSAGE = "message" +CONFIG_KEY_ICON_PATH = "icon_path" -ICON_FILENAME = "icon.png" +DEFAULT_ICON_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "icon.png") MODE_DATA_VIA_CONFIG = "data_via_config" MODE_DATA_VIA_PAYLOAD = "data_via_payload" + class Notify(Entity): NAME = "Notify" ALLOW_MULTI_INSTANCE = True @@ -42,11 +45,12 @@ class Notify(Entity): # Data is set from configurations if configurations contain both title and message # Otherwise, data is set from payload (even if only one of title or message is set) def Initialize(self): - + # Check if both config is defined or both is empty: if not bool(self.GetConfigurations()[CONFIG_KEY_TITLE]) == bool(self.GetConfigurations()[CONFIG_KEY_MESSAGE]): - raise Exception("Configuration error: Both title and message should be defined, or both should be empty!") - + raise Exception( + "Configuration error: Both title and message should be defined, or both should be empty!") + try: self.config_title = self.GetConfigurations()[CONFIG_KEY_TITLE] self.config_message = self.GetConfigurations()[CONFIG_KEY_MESSAGE] @@ -62,13 +66,20 @@ def Initialize(self): self.Log(self.LOG_INFO, "Using data from configuration") else: self.Log(self.LOG_INFO, "Using data from payload") - + + # Set and check icon path: + self.config_icon_path = self.GetConfigurations()[CONFIG_KEY_ICON_PATH] + + if not os.path.exists(self.config_icon_path): + self.Log( + self.LOG_WARNING, f"Using default icon, custom path not found: {self.config_icon_path}") + self.config_icon_path = DEFAULT_ICON_PATH + # In addition, if data is from payload, we add this info to entity name - # ! Changing the name we recognize the difference in warehouses only using the name + # ! Changing the name we recognize the difference in warehouses only using the name # e.g HomeAssistant warehouse can use the regex syntax with NotifyPaylaod to identify that the component needs the text message - self.NAME = self.NAME + ("Payload" if self.data_mode == MODE_DATA_VIA_PAYLOAD else "") - - self.RegisterEntityCommand(EntityCommand(self, KEY, self.Callback)) + self.NAME = self.NAME + \ + ("Payload" if self.data_mode == MODE_DATA_VIA_PAYLOAD else "") # Prepare the notification system if OsD.IsWindows(): @@ -76,15 +87,24 @@ def Initialize(self): raise Exception( 'Notify not available, have you installed \'tinyWinToast\' on pip ?') - elif OsD.IsLinux() or OsD.IsMacos(): - if not OsD.CommandExists(commands[OsD.GetOs()].split(" ")[0]): + elif OsD.GetOs() in commands: + if not OsD.CommandExists(commands[OsD.GetOs()].split(" ")[0]): raise Exception( f'Command not found {commands[OsD.GetOs()].split(" ")[0]}!' - ) + ) + + # Add icon to command: + if "ICON_PATH" in commands[OsD.GetOs()]: + self.command = commands[OsD.GetOs()].replace( + "ICON_PATH", self.config_icon_path) + else: + self.command = commands[OsD.GetOs()] + else: raise Exception( 'Notify not available for this platorm!') - + + self.RegisterEntityCommand(EntityCommand(self, KEY, self.Callback)) def Callback(self, message): if self.data_mode == MODE_DATA_VIA_PAYLOAD: @@ -106,31 +126,38 @@ def Callback(self, message): # Check only the os (if it's that os, it's supported because if it wasn't supported, # an exception would be thrown in post-inits) if OsD.IsWindows(): - toast_icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ICON_FILENAME) twt.getToast( - title=self.notification_title, + title=self.notification_title, message=self.notification_message, - icon=toast_icon_path, + icon=self.config_icon_path, appId=App.getName(), isMute=False).show() - elif OsD.IsLinux(): - os.system(commands[OsD.GetOs()] - .format(self.notification_title,self.notification_message)) + else: - elif OsD.IsMacos(): - os.system(commands[OsD.GetOs()] - .format(self.notification_message,self.notification_title)) + command = self.command.format( + self.notification_title, self.notification_message) + try: + p = subprocess.run(command, capture_output=True, shell=True) + self.Log(self.LOG_DEBUG, f"Called notify command: {p}") + + if p.stderr: + self.Log(self.LOG_ERROR, + f"Error during notify: {p.stderr.decode()}") + + except Exception as e: + raise Exception('Error during notify: ' + str(e)) - else: - self.Log(self.LOG_WARNING, "No notify command available for this operating system (" + - str(OsD.GetOs()) + ")... Aborting") @classmethod def ConfigurationPreset(cls) -> MenuPreset: preset = MenuPreset() - preset.AddEntry("Notification title - leave empty to send this data via remote message", CONFIG_KEY_TITLE, mandatory=False) + preset.AddEntry("Notification title - leave empty to send this data via remote message", + CONFIG_KEY_TITLE, mandatory=False) # ask for the message only if the title is provided, otherwise don't ask (use display_if_key_value) - preset.AddEntry("Notification message", CONFIG_KEY_MESSAGE, - display_if_key_value={CONFIG_KEY_TITLE: True}, mandatory=True) + preset.AddEntry("Notification message", CONFIG_KEY_MESSAGE, + display_if_key_value={CONFIG_KEY_TITLE: True}, mandatory=True) + # Icon for notification, mac is not supported :( + preset.AddEntry("Path to icon", CONFIG_KEY_ICON_PATH, + mandatory=False, default=DEFAULT_ICON_PATH) return preset diff --git a/IoTuring/Entity/Deployments/Notify/icon.png b/IoTuring/Entity/Deployments/Notify/icon.png old mode 100644 new mode 100755 index ba31199c5fb3ffdb37fa4f0538d045061cafd58b..13ca064c1f615ef09dabb8cf35690f07c62d49fd GIT binary patch literal 8370 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 literal 7850 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

lQBDL>b7hhrNA zF?}_i@e|ioMSF_XQ38meSzo95Lohz;b44*U)J|4uI7gr!+pSV-cyThW>0M!-WwyH zQ2RKKXzVwiZ!C!>zcQJa(k8qrA?R@G#s?&XC)O!HBGdh)g7I}g0Oh@^ZfFFU5rP;Iwy`;7pOdWY?fti&RI zhmb;`PczRkZ{}FPzT8Y3zTjCvlIE@-tVumSXXuCP_bf8$D@G9gxV6O}1B;U5qANqj z^hBNXxp6k=*^z=_Yrj85hgXs}G`U<8%LTf~ftRUc*FkInA39`Gla-N{|!z{rD|Syj|sc@{ukEj>GQp+QrYJP=khONhk7aIR@Vcr6}W$g|xo> z4R4`Z7!uO#Xe-6%@0s5H6(X^D6C0PVpSW1=!I`pxq|B4WMmO2P;GXl2(nMQ`0dRqR zEys`Lydp9m8kI(w^0=Huc(_WFY(QEYAwr)b;#b8CwwXX+54l^~7GKuFtf zyYsQX5VSiVQgQq_?jk&RtAW%sF6BrHc`exXQvh{i^|cG(bSD_eM*F{zok^-e@;A*t z4@>(Hl=EvY?`{Fb(?vkb$RtR6Z|7lJ=C9!=XO}<9p5uY1HE!@x;VT9b7@YC*!>)vd z2HhsA#RljHkd0WWpHU`^2U0$(RgFtLS;MIDgli0J2Ni^L$LmGK)4l9O@6Kbc zPu#-xi_WIwE*e6&B~F2%EhwJ@h3KCJl`n7kldlqF(jOgPL$~=({WRf_DUgdp>xz*nsM14~a}*P)chYC?V2P=Xb4xK34nckg zl?I*)6Ro^po;QrNEl-3SSziZHSJN>etoCl7bN(aq?`KnUKzmo27|5T<YTIir_7@)7pcL-}u zt0A9b1V8dry04aIg&RFOLJ^5j;?}Di8B@uSeAxt4MZ>sGh@|q2w;U!|CCEfo0oh*; zRV*DGGf&zmDx`1ra5N=!n3;KBi(zH6Al?Dn7NZ(8sp-8U4P>3`mV!FzN866E- lLKX92{J(1dqXv19WzUXQ1G-U)B9qT3YRcM5wF*{I{|_8RDm4HA From 0b0f8a97107cd19f03975c1ad5e566518f2c76f2 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Thu, 19 Oct 2023 05:29:14 +0200 Subject: [PATCH 04/11] RunCommand def --- .../Deployments/ActiveWindow/ActiveWindow.py | 17 +++---- .../Deployments/DisplayMode/DisplayMode.py | 12 ++--- IoTuring/Entity/Deployments/Lock/Lock.py | 13 +---- .../Entity/Deployments/Monitor/Monitor.py | 17 +++---- IoTuring/Entity/Deployments/Notify/Notify.py | 12 +---- .../OperatingSystem/OperatingSystem.py | 13 ++--- IoTuring/Entity/Deployments/Power/Power.py | 33 +++++-------- .../Entity/Deployments/Terminal/Terminal.py | 49 +++++-------------- IoTuring/Entity/Deployments/Volume/Volume.py | 18 +++---- IoTuring/Entity/Entity.py | 40 ++++++++++++++- .../SystemConsts/OperatingSystemDetection.py | 16 +++--- 11 files changed, 108 insertions(+), 132 deletions(-) diff --git a/IoTuring/Entity/Deployments/ActiveWindow/ActiveWindow.py b/IoTuring/Entity/Deployments/ActiveWindow/ActiveWindow.py index 2dfdbceb6..a59e2c68c 100644 --- a/IoTuring/Entity/Deployments/ActiveWindow/ActiveWindow.py +++ b/IoTuring/Entity/Deployments/ActiveWindow/ActiveWindow.py @@ -1,5 +1,4 @@ import re -import subprocess from IoTuring.Entity.Entity import Entity from IoTuring.Entity.EntityData import EntitySensor from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD @@ -74,28 +73,26 @@ def GetActiveWindow_Windows(self): return GetWindowText(GetForegroundWindow()) def GetActiveWindow_Linux(self) -> str: - p = subprocess.run( - ['xprop', '-root', '_NET_ACTIVE_WINDOW'], capture_output=True) + p = self.RunCommand("xprop -root _NET_ACTIVE_WINDOW") if p.stdout: - m = re.search(b'^_NET_ACTIVE_WINDOW.* ([\\w]+)$', p.stdout) + m = re.search('^_NET_ACTIVE_WINDOW.* ([\\w]+)$', p.stdout) if m is not None: window_id = m.group(1) - if window_id.decode() == '0x0': + if window_id == '0x0': return 'Unknown' - w = subprocess.run( - ['xprop', '-id', window_id, 'WM_NAME'], capture_output=True) + w = self.RunCommand(f"xprop -id {window_id} WM_NAME") if w.stderr: - return w.stderr.decode() + return w.stderr match = re.match( - b'WM_NAME\\(\\w+\\) = (?P.+)$', w.stdout) + 'WM_NAME\\(\\w+\\) = (?P.+)$', w.stdout) if match is not None: - return match.group('name').decode('UTF-8').strip('"') + return match.group('name').strip('"') return 'Inactive' diff --git a/IoTuring/Entity/Deployments/DisplayMode/DisplayMode.py b/IoTuring/Entity/Deployments/DisplayMode/DisplayMode.py index fab3dd414..ddd419d5c 100644 --- a/IoTuring/Entity/Deployments/DisplayMode/DisplayMode.py +++ b/IoTuring/Entity/Deployments/DisplayMode/DisplayMode.py @@ -1,4 +1,3 @@ -import subprocess import os as sys_os from IoTuring.Entity.Entity import Entity from ctypes import * @@ -19,7 +18,7 @@ def Initialize(self): callback = None if OsD.IsWindows(): sr = sys_os.environ.get('SystemRoot') - if sys_os.path.exists('{}\System32\DisplaySwitch.exe'.format(sr)): + if sys_os.path.exists(r'{}\System32\DisplaySwitch.exe'.format(sr)): callback = self.Callback_Win else: self.Log(self.LOG_ERROR, "Error log:\nOperating system: {}, sr: {}, path exists: {}".format(OsD.GetOs(), sr, sys_os.path.exists('{}\System32\DisplaySwitch.exe'.format(sr)))) @@ -37,8 +36,9 @@ def Callback_Win(self, message): SELECT_EXTEND_MONITOR: "extend"} if message.payload.decode('utf-8') not in parse_select_command: - self.LOG_WARNING("Invalid command: {}".format(message.payload.decode('utf-8'))) + self.Log(self.LOG_WARNING, f"Invalid command: {message.payload.decode('utf-8')}") else: - sr = sys_os.environ.get('SystemRoot') - command = '{}\System32\DisplaySwitch.exe /{}'.format(sr, parse_select_command[message.payload.decode('utf-8')]) - subprocess.Popen(command.split(), stdout=subprocess.PIPE) + sr = OsD.GetEnv('SystemRoot') + command = r'{}\System32\DisplaySwitch.exe /{}'.format(sr, parse_select_command[message.payload.decode('utf-8')]) + self.RunCommand(command=command) + diff --git a/IoTuring/Entity/Deployments/Lock/Lock.py b/IoTuring/Entity/Deployments/Lock/Lock.py index 79c1ba2c1..b9cd5ee8b 100644 --- a/IoTuring/Entity/Deployments/Lock/Lock.py +++ b/IoTuring/Entity/Deployments/Lock/Lock.py @@ -1,8 +1,6 @@ -import subprocess from IoTuring.Entity.Entity import Entity from IoTuring.Entity.EntityData import EntityCommand from IoTuring.MyApp.SystemConsts import DesktopEnvironmentDetection as De -# don't name Os as could be a problem with old configurations that used the Os entity: from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD KEY_LOCK = 'lock' @@ -55,13 +53,4 @@ def Initialize(self): self, KEY_LOCK, self.Callback_Lock)) def Callback_Lock(self, message): - try: - p = subprocess.run(self.command.split(), capture_output=True) - self.Log(self.LOG_DEBUG, f"Called lock command: {p}") - - if p.stderr: - self.Log(self.LOG_ERROR, - f"Error during system lock: {p.stderr.decode()}") - - except Exception as e: - raise Exception('Error during system lock: ' + str(e)) + self.RunCommand(command=self.command) diff --git a/IoTuring/Entity/Deployments/Monitor/Monitor.py b/IoTuring/Entity/Deployments/Monitor/Monitor.py index 71d3d0e5e..ea436ddd8 100644 --- a/IoTuring/Entity/Deployments/Monitor/Monitor.py +++ b/IoTuring/Entity/Deployments/Monitor/Monitor.py @@ -1,4 +1,3 @@ -import subprocess import ctypes import re @@ -12,6 +11,7 @@ KEY_STATE = 'monitor_state' KEY_CMD = 'monitor' + class Monitor(Entity): NAME = "Monitor" @@ -42,26 +42,23 @@ def Callback(self, message): if payloadString == STATE_ON: if OsD.IsWindows(): - ctypes.windll.user32.SendMessageA(0xFFFF, 0x0112, 0xF170, -1) + ctypes.windll.user32.SendMessageA(0xFFFF, 0x0112, 0xF170, -1) # type:ignore elif OsD.IsLinux(): - command = 'xset dpms force on' - subprocess.Popen(command.split(), stdout=subprocess.PIPE) + self.RunCommand(command='xset dpms force on') elif payloadString == STATE_OFF: if OsD.IsWindows(): - ctypes.windll.user32.SendMessageA(0xFFFF, 0x0112, 0xF170, 2) + ctypes.windll.user32.SendMessageA(0xFFFF, 0x0112, 0xF170, 2) # type:ignore elif OsD.IsLinux(): - command = 'xset dpms force off' - subprocess.Popen(command.split(), stdout=subprocess.PIPE) + self.RunCommand(command='xset dpms force off') else: raise Exception('Incorrect payload!') def Update(self): if OsD.IsLinux(): - p = subprocess.run(['xset', 'q'], capture_output=True, shell=False) - outputString = p.stdout.decode() + p = self.RunCommand(command="xset q") monitorState = re.findall( - 'Monitor is (.{2,3})', outputString)[0].upper() + 'Monitor is (.{2,3})', p.stdout)[0].upper() if monitorState in [STATE_OFF, STATE_ON]: self.SetEntitySensorValue(KEY_STATE, monitorState) else: diff --git a/IoTuring/Entity/Deployments/Notify/Notify.py b/IoTuring/Entity/Deployments/Notify/Notify.py index 8339a36d2..edb1f7ded 100644 --- a/IoTuring/Entity/Deployments/Notify/Notify.py +++ b/IoTuring/Entity/Deployments/Notify/Notify.py @@ -6,7 +6,6 @@ import os import json -import subprocess supports_win = True try: @@ -137,17 +136,8 @@ def Callback(self, message): command = self.command.format( self.notification_title, self.notification_message) - try: - p = subprocess.run(command, capture_output=True, shell=True) - self.Log(self.LOG_DEBUG, f"Called notify command: {p}") - - if p.stderr: - self.Log(self.LOG_ERROR, - f"Error during notify: {p.stderr.decode()}") - - except Exception as e: - raise Exception('Error during notify: ' + str(e)) + self.RunCommand(command=command, shell=True) @classmethod def ConfigurationPreset(cls) -> MenuPreset: diff --git a/IoTuring/Entity/Deployments/OperatingSystem/OperatingSystem.py b/IoTuring/Entity/Deployments/OperatingSystem/OperatingSystem.py index 7c98bf70c..f00b78495 100644 --- a/IoTuring/Entity/Deployments/OperatingSystem/OperatingSystem.py +++ b/IoTuring/Entity/Deployments/OperatingSystem/OperatingSystem.py @@ -1,8 +1,6 @@ import platform -import subprocess from IoTuring.Entity.Entity import Entity from IoTuring.Entity.EntityData import EntitySensor -# don't name Os as could be a problem with old configurations that used the Os entity: from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD KEY_OS = 'operating_system' @@ -11,6 +9,7 @@ EXTRA_KEY_BUILD = 'build' EXTRA_KEY_DISTRO = 'distro' + class OperatingSystem(Entity): NAME = "OperatingSystem" @@ -27,7 +26,7 @@ def Initialize(self): extra_attrs = { EXTRA_KEY_RELEASE: platform.release(), EXTRA_KEY_BUILD: platform.version() - } + } if OsD.IsMacos(): extra_attrs.update({ @@ -36,14 +35,10 @@ def Initialize(self): if OsD.IsLinux(): if OsD.CommandExists("lsb_release"): - p_release = subprocess.run(['lsb_release', '-rs'], - capture_output=True, shell=False) - p_id = subprocess.run(['lsb_release', '-is'], - capture_output=True, shell=False) extra_attrs.update({ - EXTRA_KEY_RELEASE: p_release.stdout.decode().strip(), - EXTRA_KEY_DISTRO: p_id.stdout.decode().strip(), + EXTRA_KEY_RELEASE: self.RunCommand("lsb_release -rs").stdout, + EXTRA_KEY_DISTRO: self.RunCommand("lsb_release -is").stdout, EXTRA_KEY_BUILD: platform.release() }) diff --git a/IoTuring/Entity/Deployments/Power/Power.py b/IoTuring/Entity/Deployments/Power/Power.py index 5c5ae3f93..d5eb58e1a 100644 --- a/IoTuring/Entity/Deployments/Power/Power.py +++ b/IoTuring/Entity/Deployments/Power/Power.py @@ -1,5 +1,3 @@ -import subprocess - from IoTuring.Entity.Entity import Entity from IoTuring.Entity.EntityData import EntityCommand from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD @@ -55,7 +53,7 @@ def Initialize(self): if OsD.IsLinux(): for commandtype in self.commands: testcommand = self.commands[commandtype] + " --wtmp-only" - if not subprocess.run(testcommand.split(), capture_output=True).returncode == 0: + if not self.RunCommand(testcommand).returncode == 0: self.commands[commandtype] = "sudo " + \ self.commands[commandtype] @@ -74,25 +72,20 @@ def Initialize(self): self.RegisterEntityCommand(EntityCommand( self, KEY_SLEEP, self.CallbackSleep)) - def CallCommand(self, command_key: str) -> None: - # Log if a command not working: - try: - p = subprocess.run( - self.commands[command_key].split(), capture_output=True) - self.Log(self.LOG_DEBUG, f"Called {command_key} command: {p}") - - if p.stderr: - self.Log(self.LOG_ERROR, - f"Error during system {command_key}: {p.stderr}") - - except Exception as e: - raise Exception(f'Error during system {command_key}: {str(e)}') - def CallbackShutdown(self, message): - self.CallCommand(KEY_SHUTDOWN) + self.RunCommand( + command=self.commands[KEY_SHUTDOWN], + command_name=KEY_SHUTDOWN + ) def CallbackReboot(self, message): - self.CallCommand(KEY_REBOOT) + self.RunCommand( + command=self.commands[KEY_REBOOT], + command_name=KEY_REBOOT + ) def CallbackSleep(self, message): - self.CallCommand(KEY_SLEEP) + self.RunCommand( + command=self.commands[KEY_SLEEP], + command_name=KEY_SLEEP + ) diff --git a/IoTuring/Entity/Deployments/Terminal/Terminal.py b/IoTuring/Entity/Deployments/Terminal/Terminal.py index 4b123409e..6b1381179 100644 --- a/IoTuring/Entity/Deployments/Terminal/Terminal.py +++ b/IoTuring/Entity/Deployments/Terminal/Terminal.py @@ -2,8 +2,7 @@ from IoTuring.Configurator.MenuPreset import MenuPreset from IoTuring.Entity.EntityData import EntityCommand, EntitySensor from IoTuring.Logger.consts import STATE_OFF, STATE_ON -from IoTuring.Entity.ValueFormat import ValueFormatter, ValueFormatterOptions -import subprocess +from IoTuring.Entity.ValueFormat import ValueFormatterOptions import re KEY = "terminal" @@ -213,32 +212,33 @@ def Callback(self, message): self.last_command = self.command # Run the command, collect output for update: - command = self.RunCommand(self.command) - self.last_output = command["message"] - + command = self.RunCommand(self.command, shell=True) + self.last_output = command.stdout or f"Error: {command.stderr}" + def Update(self): if self.has_binary_state or self.has_state: # Run the command, do not log error on binary sensor: command = self.RunCommand(self.config_command_state, - log_errors=not self.has_binary_state) + log_errors=not self.has_binary_state, + shell=True) if self.has_binary_state: - self.state = STATE_ON if command["returncode"] == 0 else STATE_OFF + self.state = STATE_ON if command.returncode == 0 else STATE_OFF elif self.has_state: if self.entity_type == ENTITY_TYPE_KEYS["COVER"]: - cmdout = command["stdout"].lower() + cmdout = command.stdout.lower() if cmdout in COVER_STATES.keys(): self.state = COVER_STATES[cmdout] else: self.Log(self.LOG_ERROR, - f"Invalid state: {command['stdout']}") + f"Invalid state: {cmdout}") else: - self.state = command["stdout"] + self.state = command.stdout # Parse state try: @@ -249,8 +249,10 @@ def Update(self): f"Invalid state: {self.state}") self.SetEntitySensorValue(KEY_STATE, self.state) + + last_output = command.stdout or f"Error: {command.stderr}" self.SetEntitySensorExtraAttribute( - KEY_STATE, "Last state command output", command["message"]) + KEY_STATE, "Last state command output", last_output) if self.has_command: # Set extra attributes: @@ -259,31 +261,6 @@ def Update(self): self.SetEntitySensorExtraAttribute( KEY_STATE, "Last output", self.last_output) - def RunCommand(self, command, log_errors=True): - """Run a command, log, collect output""" - - # Run the command: - p = subprocess.run(command, capture_output=True, - shell=True, universal_newlines=True) - - output = { - "returncode": p.returncode, - "stdout": p.stdout, - "message": "" - } - - # Log output and error: - if p.stderr: - output["message"] = "Error: " + p.stderr - loglevel = self.LOG_ERROR if log_errors else self.LOG_DEBUG - self.Log(loglevel, - f"Error running command '{command}': {p.stderr}") - else: - output["message"] = p.stdout - self.Log(self.LOG_DEBUG, - f"Command '{command}' run, stdout: {p.stdout}") - return output - @classmethod def ConfigurationPreset(cls): preset = MenuPreset() diff --git a/IoTuring/Entity/Deployments/Volume/Volume.py b/IoTuring/Entity/Deployments/Volume/Volume.py index e96baeff7..5d14a29a8 100644 --- a/IoTuring/Entity/Deployments/Volume/Volume.py +++ b/IoTuring/Entity/Deployments/Volume/Volume.py @@ -1,4 +1,3 @@ -import subprocess import re from IoTuring.Entity.Entity import Entity @@ -49,11 +48,10 @@ def Update(self): if OsD.IsMacos(): self.UpdateMac() elif OsD.IsLinux(): - # Example: 'Volume: front-left: 39745 / 61% / -13,03 dB, ... + # Example: 'Volume: front-left: 39745 / 61% / -13,03 dB, ... # Only care about the first percent. - p = subprocess.run("pactl get-sink-volume @DEFAULT_SINK@", - capture_output=True, shell=True, text=True) - self.Log(self.LOG_DEBUG, f"pactl stdout: {p.stdout}") + p = self.RunCommand(command="pactl get-sink-volume @DEFAULT_SINK@", + shell=True) m = re.search(r"/ +(\d{1,3})% /", p.stdout) if m: volume = m.group(1) @@ -67,15 +65,13 @@ def Callback(self, message): if not 0 <= volume <= 100: raise Exception('Incorrect payload!') else: - subprocess.run( - commands[OsD.GetOs()].format(volume), - shell=True, check=True) + self.RunCommand(command=commands[OsD.GetOs()].format(volume), + shell=True) def UpdateMac(self): # result like: output volume:44, input volume:89, alert volume:100, output muted:false - result = subprocess.run( - ['osascript', '-e', 'get volume settings'], capture_output=True, text=True) - result = result.stdout.strip().split(',') + command = self.RunCommand(command=['osascript', '-e', 'get volume settings']) + result = command.stdout.strip().split(',') output_volume = result[0].split(':')[1] input_volume = result[1].split(':')[1] diff --git a/IoTuring/Entity/Entity.py b/IoTuring/Entity/Entity.py index 48552f9c2..53acac258 100644 --- a/IoTuring/Entity/Entity.py +++ b/IoTuring/Entity/Entity.py @@ -1,9 +1,11 @@ from __future__ import annotations +import time +import subprocess + from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject from IoTuring.Exceptions.Exceptions import UnknownEntityKeyException from IoTuring.Logger.LogObject import LogObject from IoTuring.Entity.EntityData import EntityData, EntitySensor, EntityCommand, ExtraAttribute -import time KEY_ENTITY_TAG = 'tag' # from Configurator.Configurator @@ -168,6 +170,42 @@ def SetTagFromConfiguration(self): else: self.tag = "" + def RunCommand(self, + command: str | list, + command_name: str = "", + log_errors: bool = True, + capture_output: bool = True, + text: bool = True, + shell: bool = False, **kwargs) -> subprocess.CompletedProcess: + """Safely call a subprocess. Kwargs are other Subprocess options""" + + try: + if shell == False and isinstance(command, str): + runcommand = command.split() + else: + runcommand = command + + if command_name: + command_name = self.NAME + "-" + command_name + else: + command_name = self.NAME + + p = subprocess.run( + runcommand, capture_output=capture_output, shell=shell, text=text, **kwargs) + + self.Log(self.LOG_DEBUG, f"Called {command_name} command: {p}") + + # Do not log errors: + error_loglevel = self.LOG_ERROR if log_errors else self.LOG_DEBUG + if p.stderr: + self.Log(error_loglevel, + f"Error during {command_name} command: {p.stderr}") + + except Exception as e: + raise Exception(f"Error during {command_name} command: {str(e)}") + + return p + @classmethod def AllowMultiInstance(cls): """ Return True if this Entity can have multiple instances, useful for customizable entities diff --git a/IoTuring/MyApp/SystemConsts/OperatingSystemDetection.py b/IoTuring/MyApp/SystemConsts/OperatingSystemDetection.py index 7dc499060..9048166be 100644 --- a/IoTuring/MyApp/SystemConsts/OperatingSystemDetection.py +++ b/IoTuring/MyApp/SystemConsts/OperatingSystemDetection.py @@ -1,17 +1,20 @@ +from __future__ import annotations + import platform import os import psutil import shutil from IoTuring.MyApp.SystemConsts import consts + class OperatingSystemDetection(): OS_NAME = platform.system() - + from .consts import OS_FIXED_VALUE_LINUX, OS_FIXED_VALUE_MACOS, OS_FIXED_VALUE_WINDOWS @staticmethod def GetOs() -> str: - if OperatingSystemDetection.IsMacos(): + if OperatingSystemDetection.IsMacos(): return OperatingSystemDetection.OS_FIXED_VALUE_MACOS elif OperatingSystemDetection.IsLinux(): return OperatingSystemDetection.OS_FIXED_VALUE_LINUX @@ -24,14 +27,14 @@ def GetOs() -> str: @staticmethod def IsLinux() -> bool: return bool(OperatingSystemDetection.OS_NAME == 'Linux') - + @staticmethod def IsWindows() -> bool: return bool(OperatingSystemDetection.OS_NAME == 'Windows') @staticmethod def IsMacos() -> bool: - return bool(OperatingSystemDetection.OS_NAME == 'Darwin') # It's macOS + return bool(OperatingSystemDetection.OS_NAME == 'Darwin') # It's macOS @staticmethod def GetEnv(envvar) -> str: @@ -42,7 +45,8 @@ def GetEnv(envvar) -> str: if not e: try: # Try if there is another tty with gui: - session_pid = next((u.pid for u in psutil.users() if u.host and "tty" in u.host), None) + session_pid = next( + (u.pid for u in psutil.users() if u.host and "tty" in u.host), None) if session_pid: p = psutil.Process(int(session_pid)) with p.oneshot(): @@ -53,7 +57,7 @@ def GetEnv(envvar) -> str: env_value = e return env_value - + @staticmethod def CommandExists(command) -> bool: """Check if a command exists""" From e84389accc35c8f0e650c0164410a09b8468c07c Mon Sep 17 00:00:00 2001 From: infeeeee Date: Mon, 23 Oct 2023 03:02:21 +0200 Subject: [PATCH 05/11] More readable os detection --- IoTuring/Entity/Deployments/Lock/Lock.py | 6 +-- IoTuring/Entity/Deployments/Notify/Notify.py | 4 +- IoTuring/Entity/Deployments/Power/Power.py | 16 +++--- IoTuring/Entity/Deployments/Volume/Volume.py | 4 +- IoTuring/MyApp/App.py | 4 -- .../SystemConsts/OperatingSystemDetection.py | 49 ++++++++++--------- IoTuring/MyApp/SystemConsts/consts.py | 3 -- 7 files changed, 41 insertions(+), 45 deletions(-) delete mode 100644 IoTuring/MyApp/SystemConsts/consts.py diff --git a/IoTuring/Entity/Deployments/Lock/Lock.py b/IoTuring/Entity/Deployments/Lock/Lock.py index b9cd5ee8b..bc170c32e 100644 --- a/IoTuring/Entity/Deployments/Lock/Lock.py +++ b/IoTuring/Entity/Deployments/Lock/Lock.py @@ -6,13 +6,13 @@ KEY_LOCK = 'lock' commands = { - 'Windows': { + OsD.WINDOWS: { 'base': 'rundll32.exe user32.dll,LockWorkStation' }, - 'macOS': { + OsD.MACOS: { 'base': 'pmset displaysleepnow' }, - 'Linux': { + OsD.LINUX: { 'gnome': 'gnome-screensaver-command -l', 'cinnamon': 'cinnamon-screensaver-command -a', 'i3': 'i3lock', diff --git a/IoTuring/Entity/Deployments/Notify/Notify.py b/IoTuring/Entity/Deployments/Notify/Notify.py index edb1f7ded..5c09098ad 100644 --- a/IoTuring/Entity/Deployments/Notify/Notify.py +++ b/IoTuring/Entity/Deployments/Notify/Notify.py @@ -14,8 +14,8 @@ supports_win = False commands = { - OsD.OS_FIXED_VALUE_LINUX: 'notify-send "{}" "{}" --icon="ICON_PATH"', - OsD.OS_FIXED_VALUE_MACOS: 'osascript -e \'display notification "{}" with title "{}"\'' + OsD.LINUX: 'notify-send "{}" "{}" --icon="ICON_PATH"', + OsD.MACOS: 'osascript -e \'display notification "{}" with title "{}"\'' } diff --git a/IoTuring/Entity/Deployments/Power/Power.py b/IoTuring/Entity/Deployments/Power/Power.py index 58c0391e2..2c2f5ab15 100644 --- a/IoTuring/Entity/Deployments/Power/Power.py +++ b/IoTuring/Entity/Deployments/Power/Power.py @@ -9,21 +9,21 @@ KEY_SLEEP = 'sleep' commands_shutdown = { - 'Windows': 'shutdown /s /t 0', - 'macOS': 'sudo shutdown -h now', - 'Linux': 'poweroff' + OsD.WINDOWS: 'shutdown /s /t 0', + OsD.MACOS: 'sudo shutdown -h now', + OsD.LINUX: 'poweroff' } commands_reboot = { - 'Windows': 'shutdown /r', - 'macOS': 'sudo reboot', - 'Linux': 'reboot' + OsD.WINDOWS: 'shutdown /r', + OsD.MACOS: 'sudo reboot', + OsD.LINUX: 'reboot' } commands_sleep = { - 'Windows': 'rundll32.exe powrprof.dll,SetSuspendState 0,1,0', - 'Linux': 'systemctl suspend', + OsD.WINDOWS: 'rundll32.exe powrprof.dll,SetSuspendState 0,1,0', + OsD.LINUX: 'systemctl suspend', 'Linux_X11': 'xset dpms force standby', } diff --git a/IoTuring/Entity/Deployments/Volume/Volume.py b/IoTuring/Entity/Deployments/Volume/Volume.py index 5d14a29a8..8d5df3496 100644 --- a/IoTuring/Entity/Deployments/Volume/Volume.py +++ b/IoTuring/Entity/Deployments/Volume/Volume.py @@ -16,8 +16,8 @@ value_type=ValueFormatterOptions.TYPE_PERCENTAGE, decimals=0) commands = { - OsD.OS_FIXED_VALUE_LINUX: 'pactl set-sink-volume @DEFAULT_SINK@ {}%', - OsD.OS_FIXED_VALUE_MACOS: 'osascript -e "set volume output volume {}"' + OsD.LINUX: 'pactl set-sink-volume @DEFAULT_SINK@ {}%', + OsD.MACOS: 'osascript -e "set volume output volume {}"' } diff --git a/IoTuring/MyApp/App.py b/IoTuring/MyApp/App.py index 743de742b..102dc944f 100644 --- a/IoTuring/MyApp/App.py +++ b/IoTuring/MyApp/App.py @@ -1,8 +1,4 @@ -import inspect -from IoTuring.Logger.Logger import Logger from importlib_metadata import metadata -import os - class App(): METADATA = metadata('IoTuring') diff --git a/IoTuring/MyApp/SystemConsts/OperatingSystemDetection.py b/IoTuring/MyApp/SystemConsts/OperatingSystemDetection.py index 7dc499060..232e2c373 100644 --- a/IoTuring/MyApp/SystemConsts/OperatingSystemDetection.py +++ b/IoTuring/MyApp/SystemConsts/OperatingSystemDetection.py @@ -2,42 +2,45 @@ import os import psutil import shutil -from IoTuring.MyApp.SystemConsts import consts class OperatingSystemDetection(): OS_NAME = platform.system() - from .consts import OS_FIXED_VALUE_LINUX, OS_FIXED_VALUE_MACOS, OS_FIXED_VALUE_WINDOWS + # Fixed list: + MACOS = "macOS" + WINDOWS = "Windows" + LINUX = "Linux" - @staticmethod - def GetOs() -> str: - if OperatingSystemDetection.IsMacos(): - return OperatingSystemDetection.OS_FIXED_VALUE_MACOS - elif OperatingSystemDetection.IsLinux(): - return OperatingSystemDetection.OS_FIXED_VALUE_LINUX - elif OperatingSystemDetection.IsWindows(): - return OperatingSystemDetection.OS_FIXED_VALUE_WINDOWS + + @classmethod + def GetOs(cls) -> str: + if cls.IsMacos(): + return cls.MACOS + elif cls.IsLinux(): + return cls.LINUX + elif cls.IsWindows(): + return cls.WINDOWS else: raise Exception( - f"Operating system not in the fixed list. Please open a Git issue and warn about this: OS = {OperatingSystemDetection.OS_NAME}") + f"Operating system not in the fixed list. Please open a Git issue and warn about this: OS = {cls.OS_NAME}") - @staticmethod - def IsLinux() -> bool: - return bool(OperatingSystemDetection.OS_NAME == 'Linux') + @classmethod + def IsLinux(cls) -> bool: + return bool(cls.OS_NAME == cls.LINUX) - @staticmethod - def IsWindows() -> bool: - return bool(OperatingSystemDetection.OS_NAME == 'Windows') + @classmethod + def IsWindows(cls) -> bool: + return bool(cls.OS_NAME == cls.WINDOWS) - @staticmethod - def IsMacos() -> bool: - return bool(OperatingSystemDetection.OS_NAME == 'Darwin') # It's macOS + @classmethod + def IsMacos(cls) -> bool: + return bool(cls.OS_NAME == cls.MACOS) - @staticmethod - def GetEnv(envvar) -> str: + @classmethod + def GetEnv(cls, envvar) -> str: """Get envvar, also from different tty on linux""" env_value = "" - if OperatingSystemDetection.IsLinux(): + if cls.IsLinux(): e = os.environ.get(envvar) if not e: try: diff --git a/IoTuring/MyApp/SystemConsts/consts.py b/IoTuring/MyApp/SystemConsts/consts.py deleted file mode 100644 index a57448ca1..000000000 --- a/IoTuring/MyApp/SystemConsts/consts.py +++ /dev/null @@ -1,3 +0,0 @@ -OS_FIXED_VALUE_MACOS = "macOS" -OS_FIXED_VALUE_WINDOWS = "Windows" -OS_FIXED_VALUE_LINUX = "Linux" From 48082da7f80f1b1e7c4e3930720a27d94998ebed Mon Sep 17 00:00:00 2001 From: infeeeee Date: Mon, 27 Nov 2023 03:17:36 +0100 Subject: [PATCH 06/11] New configuration UI --- IoTuring/ClassManager/ClassManager.py | 13 +- IoTuring/Configurator/Configurator.py | 450 ++++++++++-------- IoTuring/Configurator/ConfiguratorIO.py | 10 +- IoTuring/Configurator/MenuPreset.py | 166 ++++--- IoTuring/Configurator/messages.py | 15 + .../Deployments/FileSwitch/FileSwitch.py | 2 +- IoTuring/Entity/Deployments/Notify/Notify.py | 8 +- IoTuring/Entity/Deployments/Power/Power.py | 1 + .../Entity/Deployments/Terminal/Terminal.py | 63 +-- IoTuring/Entity/Entity.py | 30 +- .../HomeAssistantWarehouse.py | 12 +- .../MQTTWarehouse/MQTTWarehouse.py | 6 +- IoTuring/__init__.py | 11 +- pyproject.toml | 3 +- 14 files changed, 444 insertions(+), 346 deletions(-) create mode 100644 IoTuring/Configurator/messages.py diff --git a/IoTuring/ClassManager/ClassManager.py b/IoTuring/ClassManager/ClassManager.py index 0ffedd08c..fb1bab6a5 100644 --- a/IoTuring/ClassManager/ClassManager.py +++ b/IoTuring/ClassManager/ClassManager.py @@ -33,7 +33,7 @@ def __init__(self): # THIS MUST BE IMPLEMENTED IN SUBCLASSES, IS THE CLASS I WANT TO SEARCH !!!! self.baseClass = None - def GetClassFromName(self, wantedName): + def GetClassFromName(self, wantedName) -> type: # From name, load the correct module and extract the class for module in self.modulesFilename: # Search the module file moduleName = self.ModuleNameFromPath(module) @@ -43,28 +43,33 @@ def GetClassFromName(self, wantedName): loadedModule = self.LoadModule(module) # Now get the class return self.GetClassFromModule(loadedModule) - return None + raise Exception(f"No class found: {wantedName}") def LoadModule(self, path): # Get module and load it from the path try: loader = importlib.machinery.SourceFileLoader( self.ModuleNameFromPath(path), path) spec = importlib.util.spec_from_loader(loader.name, loader) + + if not spec: + raise Exception("Spec not found") + module = importlib.util.module_from_spec(spec) loader.exec_module(module) moduleName = os.path.split(path)[1][:-3] sys.modules[moduleName] = module + return module except Exception as e: self.Log(self.LOG_ERROR, "Error while loading module " + path + ": " + str(e)) - return module - # From the module passed, I search for a Class that has classNmae=moduleName + # From the module passed, I search for a Class that has className=moduleName def GetClassFromModule(self, module): for name, obj in inspect.getmembers(module): if inspect.isclass(obj): if(name == module.__name__): return obj + raise Exception(f"No class found: {module.__name__}") # List files in the _path directory and get only files in subfolders def GetModulesFilename(self, _path): diff --git a/IoTuring/Configurator/Configurator.py b/IoTuring/Configurator/Configurator.py index fa08aab9d..51e057253 100644 --- a/IoTuring/Configurator/Configurator.py +++ b/IoTuring/Configurator/Configurator.py @@ -1,3 +1,5 @@ +import os + from IoTuring.Logger.LogObject import LogObject from IoTuring.ClassManager.EntityClassManager import EntityClassManager @@ -5,17 +7,10 @@ from IoTuring.Configurator import ConfiguratorIO -# TODO Find new location for this message -HELP_MESSAGE = f""" -You can find the configuration file in the following path: -\tmacOS\t\t~/Library/Application Support/IoTuring/configurations.json -\tLinux\t\t~/.config/IoTuring/configurations.json -\tWindows\t\t%APPDATA%/IoTuring/configurations.json -\tFallback\t[ioturing_install_path]/Configurator/configurations.json +from IoTuring.Configurator import messages -You can also set your preferred directory by setting the environment variable {ConfiguratorIO.CONFIG_PATH_ENV_VAR} -Configuration will be stored there in the file configurations.json. -""" +from InquirerPy import inquirer +from InquirerPy.separator import Separator BLANK_CONFIGURATION = {'active_entities': [ @@ -29,11 +24,16 @@ KEY_ENTITY_TYPE = "type" KEY_ENTITY_TAG = "tag" -SEPARATOR_CHAR_NUMBER = 120 +CHOICE_GO_BACK = "< Go back" + class Configurator(LogObject): def __init__(self) -> None: + # Clean the screen before first logs: + self.pinned_message = False + self.ClearScreen(pin_next_message=True) + self.configuratorIO = ConfiguratorIO.ConfiguratorIO() self.config = self.LoadConfigurations() @@ -43,124 +43,94 @@ def GetConfigurations(self) -> dict: def Menu(self) -> None: """ UI for Entities and Warehouses settings """ - run_app = False - while(not run_app): - self.PrintSeparator() - print("1 - Manage entities") - print("2 - Manage warehouses") - print("C - Start IoTuring") - print("H - Help") - print("Q - Quit\n") - - choice = False - while not choice: - choice = input("Select your choice: ") - if choice == "1": - choice = True # Valid choice - self.ManageEntities() - elif choice == "2": - choice = True # Valid choice - self.ManageWarehouses() - elif choice == "c" or choice == "C": - choice = True # Valid choice - run_app = True # Will start the program exiting from here - print("") #  Blank line - self.WriteConfigurations() - elif choice == "q" or choice == "Q": - self.WriteConfigurations() - exit(0) - elif choice == "h" or choice == "H": - print(HELP_MESSAGE) - choice = False - else: - print("Invalid choice") - choice = False + + mainMenuChoices = [ + {"name": "Manage entities", "value": self.ManageEntities}, + {"name": "Manage warehouses", "value": self.ManageWarehouses}, + {"name": "Start IoTuring", "value": self.WriteConfigurations}, + {"name": "Help", "value": self.DisplayHelp}, + {"name": "Quit", "value": self.Quit}, + ] + + choice = self.DisplayMenu( + choices=mainMenuChoices, + message="IoTuring configuration", + add_back_choice=False + ) + choice() def ManageEntities(self) -> None: """ UI for Entities settings """ ecm = EntityClassManager() - while(True): - choice = False - while not choice: - self.PrintSeparator() - print( - "Active entities: (enter the entity number to edit its configuration or to disable it)") - i = 0 - for entity in self.config[KEY_ACTIVE_ENTITIES]: - if not KEY_ENTITY_TAG in entity: - print(i+1, "-", entity[KEY_ENTITY_TYPE]) - else: - print(i+1, "-", entity[KEY_ENTITY_TYPE], - "with tag", entity[KEY_ENTITY_TAG]) - i += 1 - print("\nA - Add a new entity") - print("Q - Come back") - choice = input("\nSelect your choice: ") - try: - if choice == 'a' or choice == 'A': - choice = True - self.SelectNewEntity(ecm) - elif choice == "q" or choice == "Q": - return - else: - # If not valid I have a ValueError - choice = int(choice) - choice = choice - 1 # So now chosen entity = active entity in configurations - if choice >= 0 and choice < len(self.config[KEY_ACTIVE_ENTITIES]): - self.ManageSingleEntity( - self.config[KEY_ACTIVE_ENTITIES][choice], ecm) - choice = True - else: - raise ValueError() - except ValueError: - choice = False - print("Please insert a valid choice") - except Exception as e: - choice = False - self.Log(self.LOG_ERROR, - "Error in Entity select menu: " + str(e)) + + manageEntitiesChoices = [] + + for entityConfig in self.config[KEY_ACTIVE_ENTITIES]: + manageEntitiesChoices.append( + {"name": self.GetEntityLabel(entityConfig), + "value": entityConfig} + ) + + manageEntitiesChoices.sort(key=lambda d: d['name']) + + manageEntitiesChoices.extend([ + Separator(), + {"name": "+ Add a new entity", "value": "AddNewEntity"}, + CHOICE_GO_BACK, + ]) + + choice = self.DisplayMenu( + choices=manageEntitiesChoices, + message="Manage entities", + add_back_choice=False) + + if choice == "AddNewEntity": + self.SelectNewEntity(ecm) + elif choice == CHOICE_GO_BACK: + self.Menu() + else: + self.ManageSingleEntity(choice, ecm) def ManageWarehouses(self) -> None: """ UI for Warehouses settings """ wcm = WarehouseClassManager() - while(True): - self.PrintSeparator() - print("Select the warehouse you want to manage (X for enabled):") - availableWarehouses = wcm.ListAvailableClassesNames() - for index, whName in enumerate(availableWarehouses): - if not self.IsWarehouseActive(whName.replace("Warehouse", "")): - print("[ ] " + str(index+1) + " - " + - whName.replace("Warehouse", "")) - else: - print("[X] " + str(index+1) + " - " + - whName.replace("Warehouse", "")) - print(" Q - Come back\n") - choice = False - while not choice: - choice = input("Which one do you want to manage ? ") - if choice == "q" or choice == "Q": - return - else: - try: - choice = int(choice) - 1 - if choice >= 0 and choice < len(availableWarehouses): - self.ManageSingleWarehouse( - availableWarehouses[choice].replace("Warehouse", ""), wcm) - choice = True - else: - raise IndexError("Choice out of warehouses range") - except IndexError: - choice = False - print("Please insert a valid Warehouse index") - except Exception as e: - choice = False - self.Log(self.LOG_ERROR, - "Error in Warehouse select menu: " + str(e)) + manageWhChoices = [] + + availableWarehouses = wcm.ListAvailableClassesNames() + for whName in availableWarehouses: + short_wh_name = whName.replace("Warehouse", "") + + enabled_sign = " " + if self.IsWarehouseActive(short_wh_name): + enabled_sign = "X" + + manageWhChoices.append( + {"name": f"[{enabled_sign}] - {short_wh_name}", + "value": short_wh_name}) + + choice = self.DisplayMenu( + choices=manageWhChoices, + message="Select warehouse to manage (X for enabled)", + ) + + if choice == CHOICE_GO_BACK: + self.Menu() + else: + self.ManageSingleWarehouse(choice, wcm) + + def DisplayHelp(self) -> None: + self.DisplayMessage(messages.HELP_MESSAGE) + self.Menu() + + def Quit(self) -> None: + """ Save configurations and quit """ + self.WriteConfigurations() + exit(0) def LoadConfigurations(self) -> dict: """ Reads the configuration file and returns configuration dictionary. - If not available, returns the blank configuration """ + If not available, returns the blank configuration """ read_config = self.configuratorIO.readConfigurations() if read_config is None: read_config = BLANK_CONFIGURATION @@ -171,109 +141,108 @@ def WriteConfigurations(self) -> None: self.configuratorIO.writeConfigurations(self.config) def ManageSingleWarehouse(self, warehouseName, wcm: WarehouseClassManager): - """ UI for single Warehouse settings """ - print("\nWhat do you want to do with " + warehouseName + "?") - if self.IsWarehouseActive(warehouseName): - print("E - Edit the warehouse settings") - print("R - Remove the warehouse") - else: - print("A - Add the warehouse") - print("Q - Come back") - - choice = input("Select an operation: ") + """UI for single Warehouse settings""" if self.IsWarehouseActive(warehouseName): - if choice == "r" or choice == "R": - if(Configurator.ConfirmQuestion()): - self.RemoveActiveWarehouse(warehouseName) - elif choice == "e" or choice == "E": - self.EditActiveWarehouse(warehouseName, wcm) + manageWhChoices = [ + {"name": "Edit the warehouse settings", "value": "Edit"}, + {"name": "Remove the warehouse", "value": "Remove"} + ] else: - if choice == "a" or choice == "A": - self.AddActiveWarehouse(warehouseName, wcm) + manageWhChoices = [ + {"name": "Add the warehouse", "value": "Add"}] + + choice = self.DisplayMenu( + choices=manageWhChoices, + message=f"Manage warehouse {warehouseName}" + ) + + if choice == CHOICE_GO_BACK: + self.ManageWarehouses() + elif choice == "Edit": + self.EditActiveWarehouse(warehouseName, wcm) + elif choice == "Add": + self.AddActiveWarehouse(warehouseName, wcm) + elif choice == "Remove": + confirm = inquirer.confirm(message="Are you sure?").execute() + + if confirm: + self.RemoveActiveWarehouse(warehouseName) + else: + self.ManageWarehouses() def ManageSingleEntity(self, entityConfig, ecm: EntityClassManager): """ UI to manage an active warehouse (edit config/remove) """ - choice = False - while(not choice): - self.PrintSeparator() - print("What do you want to do with " + - entityConfig[KEY_ENTITY_TYPE] + "?") - print("\nE - Edit the entity settings") - print("R - Remove the entity") - print("Q - Come back") - - choice = input("Select an operation: ") - - if choice == "r" or choice == "R": - if(Configurator.ConfirmQuestion()): - self.RemoveActiveEntity(entityConfig, ecm) - elif choice == "e" or choice == "E": - self.EditActiveEntity(entityConfig, ecm) + + manageEntityChoices = [ + {"name": "Edit the entity settings", "value": "Edit"}, + {"name": "Remove the entity", "value": "Remove"} + ] + + choice = self.DisplayMenu( + choices=manageEntityChoices, + message=f"Manage entity {self.GetEntityLabel(entityConfig)}" + ) + + if choice == CHOICE_GO_BACK: + self.ManageEntities() + elif choice == "Edit": + self.EditActiveEntity(entityConfig, ecm) # type: ignore + elif choice == "Remove": + confirm = inquirer.confirm(message="Are you sure?").execute() + + if confirm: + self.RemoveActiveEntity(entityConfig) + else: + self.ManageEntities() def SelectNewEntity(self, ecm: EntityClassManager): """ UI to add a new Entity """ - choice = False - while not choice: - entityList = ecm.ListAvailableClassesNames() - # Now I remove the entities that are active and that do not allow multi instances - for activeEntity in self.config[KEY_ACTIVE_ENTITIES]: - if not ecm.GetClassFromName(activeEntity[KEY_ENTITY_TYPE]).AllowMultiInstance(): - entityList.remove(activeEntity[KEY_ENTITY_TYPE]) - - # Print entities with their index in order to choose them - self.PrintSeparator() - print("Available entities: ") - print("PS: if you don't see the entity you want, it may be already active and may not accept another version of itself)\n") - i = 0 - for entity in entityList: - print(i+1, "-", entity) - i += 1 - - print("\nQ - Come back") - - choice = input("\nSelect your choice: ") - try: - if choice == "q" or choice == "Q": - return - else: - choice = int(choice) # If not valid I have a ValueError - choice = choice - 1 # So now chosen entity = active entity in configurations - if choice >= 0 and choice < len(entityList): - # WIll also open the configuration menu - self.AddActiveEntity(entityList[choice], ecm) - choice = True - else: - raise ValueError() - except ValueError: - choice = False - print("Please insert a valid choice") - except Exception as e: - choice = False - self.Log(self.LOG_ERROR, - "Error in Entity select menu: " + str(e)) + + entityList = ecm.ListAvailableClassesNames() + # Now I remove the entities that are active and that do not allow multi instances + for activeEntity in self.config[KEY_ACTIVE_ENTITIES]: + if not ecm.GetClassFromName(activeEntity[KEY_ENTITY_TYPE])\ + .AllowMultiInstance(): # type: ignore + entityList.remove(activeEntity[KEY_ENTITY_TYPE]) + + choice = self.DisplayMenu( + choices=sorted(entityList), + message="Available entities:", + instruction="if you don't see the entity you want, it may be already active and may not accept another version of itself" + + ) + + if choice == CHOICE_GO_BACK: + self.ManageEntities() + else: + self.AddActiveEntity(choice, ecm) def AddActiveEntity(self, entityName, ecm: EntityClassManager): """ From entity name, get its class and retrieve the configuration preset, then add to configuration dict """ entityClass = ecm.GetClassFromName(entityName) try: - preset = entityClass.ConfigurationPreset() + preset = entityClass.ConfigurationPreset() # type: ignore if preset.HasQuestions(): # Ask for Tag if the entity allows multi-instance - multi-instance has sense only if a preset is available - if entityClass.AllowMultiInstance(): + if entityClass.AllowMultiInstance(): # type: ignore preset.AddTagQuestion() - preset.PrintRules() + self.DisplayMessage(messages.PRESET_RULES) + self.DisplayMessage(f"Configure {entityName} Entity") preset.AskQuestions() else: - print("No configuration needed for this Entity :)") + self.DisplayMessage( + "No configuration needed for this Entity :)") self.EntityMenuPresetToConfiguration(entityName, preset) except Exception as e: print("Error during entity preset loading: " + str(e)) + self.ManageEntities() + def IsEntityActive(self, entityName) -> bool: """ Return True if an Entity is active """ for entity in self.config[KEY_ACTIVE_ENTITIES]: @@ -281,11 +250,22 @@ def IsEntityActive(self, entityName) -> bool: return True return False - def RemoveActiveEntity(self, entityConfig, ecm: EntityClassManager) -> None: + def GetEntityLabel(self, entityConfig) -> str: + """ Get the type name of entity, add tag if multi""" + entityLabel = entityConfig[KEY_ENTITY_TYPE] + if KEY_ENTITY_TAG in entityConfig: + entityLabel += f" with tag {entityConfig[KEY_ENTITY_TAG]}" + return entityLabel + + def RemoveActiveEntity(self, entityConfig) -> None: """ Remove entity name from the list of active entities if present """ if entityConfig in self.config[KEY_ACTIVE_ENTITIES]: self.config[KEY_ACTIVE_ENTITIES].remove(entityConfig) + self.DisplayMessage( + f"Entity removed: {self.GetEntityLabel(entityConfig)}") + self.ManageEntities() + def IsWarehouseActive(self, warehouseName) -> bool: """ Return True if a warehouse is active """ for wh in self.config[KEY_ACTIVE_WAREHOUSES]: @@ -298,31 +278,42 @@ def AddActiveWarehouse(self, warehouseName, wcm: WarehouseClassManager) -> None: whClass = wcm.GetClassFromName(warehouseName + "Warehouse") try: - preset = whClass.ConfigurationPreset() + preset = whClass.ConfigurationPreset() # type: ignore if preset.HasQuestions(): - preset.PrintRules() + self.DisplayMessage(messages.PRESET_RULES) preset.AskQuestions() else: - print("No configuration needed for this Warehouse :)") + self.DisplayMessage( + "No configuration needed for this Warehouse :)") # Save added settings self.WarehouseMenuPresetToConfiguration(warehouseName, preset) except Exception as e: print("Error during warehouse preset loading: " + str(e)) + self.ManageWarehouses() + def EditActiveWarehouse(self, warehouseName, wcm: WarehouseClassManager) -> None: """ UI for single Warehouse settings edit """ - print("You can't do that at the moment, change the configuration file manually. Sorry for the inconvenience") + self.DisplayMessage( + "You can't do that at the moment, change the configuration file manually. Sorry for the inconvenience") + + self.ManageWarehouses() + + # TODO Implement # WarehouseMenuPresetToConfiguration appends a warehosue to the conf so here I should remove it to read it later # TO implement only when I know how to add removable value while editing configurations - pass # TODO Implement def EditActiveEntity(self, entityConfig, ecm: WarehouseClassManager) -> None: """ UI for single Entity settings edit """ - print("You can't do that at the moment, change the configuration file manually. Sorry for the inconvenience") - pass # TODO Implement + self.DisplayMessage( + "You can't do that at the moment, change the configuration file manually. Sorry for the inconvenience") + + self.ManageEntities() + + # TODO Implement def RemoveActiveWarehouse(self, warehouseName) -> None: """ Remove warehouse name from the list of active warehouses if present """ @@ -330,7 +321,9 @@ def RemoveActiveWarehouse(self, warehouseName) -> None: if warehouseName == wh[KEY_WAREHOUSE_TYPE]: # I remove this wh from the list self.config[KEY_ACTIVE_WAREHOUSES].remove(wh) - return + + self.DisplayMessage(f"Warehouse removed: {warehouseName}") + self.ManageWarehouses() def WarehouseMenuPresetToConfiguration(self, whName, preset) -> None: """ Get a MenuPreset with responses and add the entries to the configurations dict in warehouse part """ @@ -346,13 +339,50 @@ def EntityMenuPresetToConfiguration(self, entityName, preset) -> None: self.config[KEY_ACTIVE_ENTITIES].append(_dict) print("Configuration added for \""+entityName+"\" :)") - def PrintSeparator(self): - print("\n"+SEPARATOR_CHAR_NUMBER*'#') + def ClearScreen(self, pin_next_message=False): + """ Clear the screen on any platform. If self.pinned_message is True, it won't be cleared. + + Args: + pin_next_message (bool, optional): Set self.pinned_message after clear. Defaults to False. + """ + + if not self.pinned_message: + os.system("cls" if os.name == "nt" else "clear") - @staticmethod - def ConfirmQuestion(): - value = input("You sure ? [y/n] ") - if value == "y" or value == "Y": - return True + if pin_next_message: + self.pinned_message = True else: - return False + self.pinned_message = False + + def DisplayMenu(self, choices: list, message: str = "", add_back_choice=True, **kwargs): + """ Wrapper for inquirer.select + + Args: + choices (list): list of strings, dicts, see InquirerPy documentation + message (str, optional): Title of the prompt. Defaults to "". + add_back_choice (bool, optional): Add a go back option at the end. Defaults to True. + + Returns: + The result of the prompt + """ + + if add_back_choice: + choices.extend([ + Separator(), + CHOICE_GO_BACK + ]) + + self.ClearScreen() + choice = inquirer.select( + message=message, choices=choices, **kwargs).execute() + return choice + + def DisplayMessage(self, message: str): + """Display a message on the top of the screen, above menus + + Args: + message (str): The message to display + """ + self.ClearScreen(pin_next_message=True) + print(message) + print() diff --git a/IoTuring/Configurator/ConfiguratorIO.py b/IoTuring/Configurator/ConfiguratorIO.py index dccffde59..7c02d1ce5 100644 --- a/IoTuring/Configurator/ConfiguratorIO.py +++ b/IoTuring/Configurator/ConfiguratorIO.py @@ -9,8 +9,8 @@ # macOS dep (in PyObjC) try: - from AppKit import * - from Foundation import * + from AppKit import * # type:ignore + from Foundation import * # type:ignore macos_support = True except: macos_support = False @@ -42,7 +42,7 @@ def writeConfigurations(self, data): """ Writes configuration data in its file """ self.createFolderPathIfDoesNotExist() with open(self.getFilePath(), "w") as f: - f.write(json.dumps(data)) + f.write(json.dumps(data, indent=4)) self.Log(self.LOG_MESSAGE, "Saved \"" + self.getFilePath() + "\"") def checkConfigurationFileExists(self): @@ -94,8 +94,8 @@ def defaultFolderPath(self): # https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/MacOSXDirectories/MacOSXDirectories.html def macOSFolderPath(self): - paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory,NSUserDomainMask,True) - basePath = (len(paths) > 0 and paths[0]) or NSTemporaryDirectory() + paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory,NSUserDomainMask,True) # type: ignore + basePath = (len(paths) > 0 and paths[0]) or NSTemporaryDirectory() # type: ignore return basePath # https://docs.microsoft.com/en-us/windows/win32/shell/knownfolderid diff --git a/IoTuring/Configurator/MenuPreset.py b/IoTuring/Configurator/MenuPreset.py index 44747a73f..ca8405fd0 100644 --- a/IoTuring/Configurator/MenuPreset.py +++ b/IoTuring/Configurator/MenuPreset.py @@ -1,32 +1,41 @@ from __future__ import annotations +from InquirerPy import inquirer + class QuestionPreset(): - def __init__(self, name, key, default=None, mandatory=False, dependsOn={}, modify_value_callback=None) -> None: + def __init__(self, + name, + key, + default=None, + mandatory=False, + dependsOn={}, + instruction="", + question_type="text", + choices=[] + ) -> None: self.name = name self.key = key self.default = default self.mandatory = mandatory self.dependsOn = dependsOn - self.modify_value_callback = modify_value_callback + self.instruction = instruction + self.question_type = question_type + self.choices = choices self.value = None - # Build the question: - question_parts = [f'Add value for "{self.name}"'] + self.question = self.name if mandatory: - question_parts.append("{!}") - if default is not None: - question_parts.append(f"[{str(default)}]") - - self.question = " ".join(question_parts) + ": " - - def SetValue(self, value) -> None: - """Sanitize and set value for this question""" - - if value and self.modify_value_callback: - value = self.modify_value_callback(value) + self.question += " {!}" - self.value = value + if default is not None: + if self.question_type == "yesno": + self.default = bool( + default.lower() in BooleanAnswers.TRUE_ANSWERS) + else: + self.default = str(default) + else: + self.default = default def ShouldDisplay(self, menupreset: "MenuPreset") -> bool: """Check if this question should be displayed""" @@ -45,7 +54,7 @@ def ShouldDisplay(self, menupreset: "MenuPreset") -> bool: # Value is True or False: if isinstance(value, bool): - if (answered.value == answered.default) != value: + if answered.value: dependency_ok = True # Value must match: @@ -71,7 +80,15 @@ def HasQuestions(self) -> bool: """Check if this preset has any questions to ask""" return bool(self.presets) - def AddEntry(self, name, key, default=None, mandatory=False, display_if_key_value={}, modify_value_callback=None) -> None: + def AddEntry(self, + name, + key, + default=None, + mandatory=False, + display_if_key_value={}, + instruction="", + question_type="text", + choices=[]) -> None: """ Add an entry to the preset with: - key: the key to use in the dict @@ -86,9 +103,17 @@ def AddEntry(self, name, key, default=None, mandatory=False, display_if_key_valu * If the value if False, it will be displayed, if nothing was answered to that question. * In case this won't be displayed, a default value will be used if provided; otherwise won't set this key in the dict) ! Caution: if the entry is not displayed, the mandatory property will be ignored ! - - modify_value_callback: a callback to modify the value before it's set in the dict (called also for a default value). The callback must have the following signature: NAME(value) -> value + - instruction: more text to show + - question_type: text, secret, select or yesno + - choices: only for select question type """ + if question_type not in ["text", "secret", "select", "yesno"]: + raise Exception(f"Unknown question type: {question_type}") + + if question_type == "select" and not choices: + raise Exception(f"Missing choices for question: {name}") + # Add question to presets: self.presets.append( QuestionPreset( @@ -97,42 +122,61 @@ def AddEntry(self, name, key, default=None, mandatory=False, display_if_key_valu default=default, mandatory=mandatory, dependsOn=display_if_key_value, - modify_value_callback=modify_value_callback + instruction=instruction, + question_type=question_type, + choices=choices )) def AskQuestions(self) -> None: """Ask all questions of this preset""" for q_preset in self.presets: try: - value = None # It should be displayed, ask question: if q_preset.ShouldDisplay(self): - value = input(q_preset.question) - - # Mandatory loop: - while value == "" and q_preset.mandatory: - value = input( - "You must provide a value for this key: ") - # Set default: - if value == "": - value = q_preset.default - - # It should not be displayed: - else: - # It's already answered: - if self.GetAnsweredPresetByKey(q_preset.key): - continue - - # Set default value otherwise: - else: - value = q_preset.default - - # Set and sanitize the value: - q_preset.SetValue(value) - # Add to answered questions: - self.results.append(q_preset) + question_options = {} + + if q_preset.mandatory: + def validate(x): return bool(x) + question_options.update({ + "validate": validate, + "invalid_message": "You must provide a value for this key" + }) + + # text: + prompt_function = inquirer.text + + question_options["message"] = q_preset.question + ":" + + if q_preset.default is not None: + question_options["default"] = q_preset.default + + if q_preset.question_type == "secret": + prompt_function = inquirer.secret + + elif q_preset.question_type == "yesno": + prompt_function = inquirer.confirm + question_options.update({ + "filter": lambda x: "Y" if x else "N" + }) + + elif q_preset.question_type == "select": + prompt_function = inquirer.select + question_options.update({ + "choices": q_preset.choices, + "filter": lambda x: x.lower() + }) + + value = prompt_function( + instruction=q_preset.instruction, + **question_options + ).execute() + + if value: + q_preset.value = value + # Add to answered questions: + self.results.append(q_preset) except Exception as e: print("Error while making the question:", e) @@ -148,39 +192,11 @@ def GetDefaults(self) -> dict: """ Get a dict of default values of keys """ return {entry.key: entry.default for entry in self.presets} - @staticmethod - def PrintRules() -> None: - """ Print configuration rules, like a legend for complusory symbol and default values """ - print("\n\t-- Rules --") - print("\t\tIf you see {!} then the value is complusory") - print( - "\t\tIf you see [ ] then the value in the brackets is the default one: leave blank the input to use that value") - print( - "\t\tIf a tag is asked, it is an alias for the entity to recognize it in configurations and warehouses") - print("\t-- End of rules --\n") - def AddTagQuestion(self): """ Add a Tag question (compulsory, no default) to the preset. Useful for entities that must have a tag because of their multi-instance possibility """ - self.AddEntry("Tag", "tag", mandatory=True, - modify_value_callback=normalize_tag) - - @staticmethod - def Callback_NormalizeBoolean(value): - """ Normalize a boolean value to be used, given a string from user input. To be used as MenuPreset callback. """ - if value.lower() in BooleanAnswers.TRUE_ANSWERS: - return True - return False - - @staticmethod - def Callback_LowerAndStripString(value) -> str: - """ Remove spaces from a string end, make lowercase """ - return str(value).lower().strip() - - -def normalize_tag(tag): - """ Normalize a tag to be used safely""" - return tag.lower().replace(" ", "_") + self.AddEntry(name="Tag", key="tag", mandatory=True, + instruction="Alias, to recognize entity in configurations and warehouses") class BooleanAnswers: diff --git a/IoTuring/Configurator/messages.py b/IoTuring/Configurator/messages.py new file mode 100644 index 000000000..ace5559df --- /dev/null +++ b/IoTuring/Configurator/messages.py @@ -0,0 +1,15 @@ +from IoTuring.Configurator import ConfiguratorIO + + +HELP_MESSAGE = f""" +You can find the configuration file in the following path: +\tmacOS\t\t~/Library/Application Support/IoTuring/configurations.json +\tLinux\t\t~/.config/IoTuring/configurations.json +\tWindows\t\t%APPDATA%/IoTuring/configurations.json +\tFallback\t[ioturing_install_path]/Configurator/configurations.json + +You can also set your preferred directory by setting the environment variable {ConfiguratorIO.CONFIG_PATH_ENV_VAR} +Configuration will be stored there in the file configurations.json. +""" + +PRESET_RULES = "Options with this sign are compulsory: {!}" \ No newline at end of file diff --git a/IoTuring/Entity/Deployments/FileSwitch/FileSwitch.py b/IoTuring/Entity/Deployments/FileSwitch/FileSwitch.py index bfe6d09bc..8a0e6a4d0 100644 --- a/IoTuring/Entity/Deployments/FileSwitch/FileSwitch.py +++ b/IoTuring/Entity/Deployments/FileSwitch/FileSwitch.py @@ -46,5 +46,5 @@ def Update(self): @classmethod def ConfigurationPreset(cls) -> MenuPreset: preset = MenuPreset() - preset.AddEntry("Path to file?", CONFIG_KEY_PATH, mandatory=True) + preset.AddEntry("Path to file", CONFIG_KEY_PATH, mandatory=True) return preset diff --git a/IoTuring/Entity/Deployments/Notify/Notify.py b/IoTuring/Entity/Deployments/Notify/Notify.py index 5c09098ad..06623346d 100644 --- a/IoTuring/Entity/Deployments/Notify/Notify.py +++ b/IoTuring/Entity/Deployments/Notify/Notify.py @@ -142,12 +142,12 @@ def Callback(self, message): @classmethod def ConfigurationPreset(cls) -> MenuPreset: preset = MenuPreset() - preset.AddEntry("Notification title - leave empty to send this data via remote message", - CONFIG_KEY_TITLE, mandatory=False) + preset.AddEntry(name="Notification title", key=CONFIG_KEY_TITLE, + instruction="Leave empty to send this data via remote message", mandatory=False) # ask for the message only if the title is provided, otherwise don't ask (use display_if_key_value) - preset.AddEntry("Notification message", CONFIG_KEY_MESSAGE, + preset.AddEntry(name="Notification message", key=CONFIG_KEY_MESSAGE, display_if_key_value={CONFIG_KEY_TITLE: True}, mandatory=True) # Icon for notification, mac is not supported :( - preset.AddEntry("Path to icon", CONFIG_KEY_ICON_PATH, + preset.AddEntry(name="Path to icon", key=CONFIG_KEY_ICON_PATH, mandatory=False, default=DEFAULT_ICON_PATH) return preset diff --git a/IoTuring/Entity/Deployments/Power/Power.py b/IoTuring/Entity/Deployments/Power/Power.py index 2c2f5ab15..99c301363 100644 --- a/IoTuring/Entity/Deployments/Power/Power.py +++ b/IoTuring/Entity/Deployments/Power/Power.py @@ -73,6 +73,7 @@ def Initialize(self): self, KEY_SLEEP, self.Callback)) def Callback(self, message): + # From the topic we can find the command: key = message.topic.split("/")[-1] self.RunCommand( command=self.commands[key], diff --git a/IoTuring/Entity/Deployments/Terminal/Terminal.py b/IoTuring/Entity/Deployments/Terminal/Terminal.py index 649cf0e47..e8da9794e 100644 --- a/IoTuring/Entity/Deployments/Terminal/Terminal.py +++ b/IoTuring/Entity/Deployments/Terminal/Terminal.py @@ -264,59 +264,64 @@ def Update(self): @classmethod def ConfigurationPreset(cls): preset = MenuPreset() - preset.AddEntry("Entity type (payload command, sensor, binary sensor, button, switch or cover)", - CONFIG_KEY_ENTITY_TYPE, mandatory=True, modify_value_callback=MenuPreset.Callback_LowerAndStripString) + preset.AddEntry(name="Select entity type", + key=CONFIG_KEY_ENTITY_TYPE, mandatory=True, + question_type="select", choices=["Payload command", "Sensor", "Binary sensor", "Button", "Switch", "Cover"]) # payload command - preset.AddEntry("Regex for filter the incoming payload: Use ^ as the first and $ as the last character", - CONFIG_KEY_COMMAND_REGEX, mandatory=True, + preset.AddEntry(name="Regex for filter the incoming payload:", + key=CONFIG_KEY_COMMAND_REGEX, mandatory=True, + instruction="Use ^ as the first and $ as the last character", display_if_key_value={CONFIG_KEY_ENTITY_TYPE: "payload command"}) - preset.AddEntry("Maximum command length", CONFIG_KEY_LENGTH, mandatory=False, default="inf", + preset.AddEntry(name="Maximum command length", + key=CONFIG_KEY_LENGTH, mandatory=False, default="inf", display_if_key_value={CONFIG_KEY_ENTITY_TYPE: "payload command"}) # button - preset.AddEntry("Terminal command to run", - CONFIG_KEY_COMMAND_ON, mandatory=True, + preset.AddEntry(name="Terminal command to run", + key=CONFIG_KEY_COMMAND_ON, mandatory=True, display_if_key_value={CONFIG_KEY_ENTITY_TYPE: "button"}) # switch - preset.AddEntry("Terminal command to switch ON", - CONFIG_KEY_COMMAND_ON, mandatory=True, + preset.AddEntry(name="Terminal command to switch ON", + key=CONFIG_KEY_COMMAND_ON, mandatory=True, display_if_key_value={CONFIG_KEY_ENTITY_TYPE: "switch"}) - preset.AddEntry("Terminal command to switch OFF", - CONFIG_KEY_COMMAND_OFF, mandatory=True, + preset.AddEntry(name="Terminal command to switch OFF", + key=CONFIG_KEY_COMMAND_OFF, mandatory=True, display_if_key_value={CONFIG_KEY_ENTITY_TYPE: "switch"}) - preset.AddEntry("Terminal command for STATE of the switch, leave empty for an optimistic switch. The command must return 0 for ON state", - CONFIG_KEY_COMMAND_STATE, mandatory=False, + preset.AddEntry(name="Terminal command for STATE of the switch, leave empty for an optimistic switch.", + instruction="The command must return 0 for ON state.", + key=CONFIG_KEY_COMMAND_STATE, mandatory=False, display_if_key_value={CONFIG_KEY_ENTITY_TYPE: "switch"}) # sensor - preset.AddEntry("Terminal command to get the sensor value", - CONFIG_KEY_COMMAND_STATE, mandatory=True, + preset.AddEntry(name="Terminal command to get the sensor value", + key=CONFIG_KEY_COMMAND_STATE, mandatory=True, display_if_key_value={CONFIG_KEY_ENTITY_TYPE: "sensor"}) - preset.AddEntry("Unit of measurement", - CONFIG_KEY_UNIT, mandatory=False, + preset.AddEntry(name="Unit of measurement", + key=CONFIG_KEY_UNIT, mandatory=False, display_if_key_value={CONFIG_KEY_ENTITY_TYPE: "sensor"}) - preset.AddEntry("Number of decimals", - CONFIG_KEY_DECIMALS, mandatory=False, + preset.AddEntry(name="Number of decimals", + key=CONFIG_KEY_DECIMALS, mandatory=False, display_if_key_value={CONFIG_KEY_ENTITY_TYPE: "sensor"}) # binary sensor - preset.AddEntry("Terminal command, exit code must be 0 for ON state", - CONFIG_KEY_COMMAND_STATE, mandatory=True, + preset.AddEntry(name="Terminal command, exit code must be 0 for ON state", + key=CONFIG_KEY_COMMAND_STATE, mandatory=True, display_if_key_value={CONFIG_KEY_ENTITY_TYPE: "binary sensor"}) # cover - preset.AddEntry("Terminal command to OPEN", - CONFIG_KEY_COMMAND_OPEN, mandatory=True, + preset.AddEntry(name="Terminal command to OPEN", + key=CONFIG_KEY_COMMAND_OPEN, mandatory=True, display_if_key_value={CONFIG_KEY_ENTITY_TYPE: "cover"}) - preset.AddEntry("Terminal command to CLOSE", - CONFIG_KEY_COMMAND_CLOSE, mandatory=True, + preset.AddEntry(name="Terminal command to CLOSE", + key=CONFIG_KEY_COMMAND_CLOSE, mandatory=True, display_if_key_value={CONFIG_KEY_ENTITY_TYPE: "cover"}) - preset.AddEntry("Terminal command to STOP", - CONFIG_KEY_COMMAND_STOP, mandatory=False, + preset.AddEntry(name="Terminal command to STOP", + key=CONFIG_KEY_COMMAND_STOP, mandatory=False, display_if_key_value={CONFIG_KEY_ENTITY_TYPE: "cover"}) - preset.AddEntry("Terminal command for STATE, leave empty for optimistic. Command must return 'opening', 'closing' or 'stopped'", - CONFIG_KEY_COMMAND_STATE, mandatory=False, + preset.AddEntry(name="Terminal command for STATE, leave empty for optimistic.", + key=CONFIG_KEY_COMMAND_STATE, mandatory=False, + instruction="Command must return 'opening', 'closing' or 'stopped'", display_if_key_value={CONFIG_KEY_ENTITY_TYPE: "cover"}) return preset diff --git a/IoTuring/Entity/Entity.py b/IoTuring/Entity/Entity.py index 53acac258..b63f22c32 100644 --- a/IoTuring/Entity/Entity.py +++ b/IoTuring/Entity/Entity.py @@ -174,10 +174,30 @@ def RunCommand(self, command: str | list, command_name: str = "", log_errors: bool = True, - capture_output: bool = True, - text: bool = True, - shell: bool = False, **kwargs) -> subprocess.CompletedProcess: - """Safely call a subprocess. Kwargs are other Subprocess options""" + shell: bool = False, + **kwargs) -> subprocess.CompletedProcess: + """Safely call a subprocess. Kwargs are other Subprocess options + + Args: + command (str | list): The command to call + command_name (str, optional): For logging, if empty entity name will be used. + log_errors (bool, optional): Log stderr of command. Use False when failure is expected. Defaults to True. + shell (bool, optional): Run in shell. Defaults to False. + **kwargs: subprocess args + + Returns: + subprocess.CompletedProcess: See subprocess docs + """ + + # different defaults than in subprocess: + defaults = { + "capture_output": True, + "text": True + } + + for param, value in defaults.items(): + if param not in kwargs: + kwargs[param] = value try: if shell == False and isinstance(command, str): @@ -191,7 +211,7 @@ def RunCommand(self, command_name = self.NAME p = subprocess.run( - runcommand, capture_output=capture_output, shell=shell, text=text, **kwargs) + runcommand, shell=shell, **kwargs) self.Log(self.LOG_DEBUG, f"Called {command_name} command: {p}") diff --git a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py index 069f001bb..88950f7f6 100644 --- a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py +++ b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py @@ -444,10 +444,10 @@ def ConfigurationPreset(cls) -> MenuPreset: CONFIG_KEY_ADDRESS, mandatory=True) preset.AddEntry("Port", CONFIG_KEY_PORT, default=1883) preset.AddEntry("Client name", CONFIG_KEY_NAME, mandatory=True) - preset.AddEntry("Username", CONFIG_KEY_USERNAME, default="") - preset.AddEntry("Password", CONFIG_KEY_PASSWORD, default="") - preset.AddEntry("Add computer name to entity name ? Y/N", - CONFIG_KEY_ADD_NAME_TO_ENTITY, default="Y") - preset.AddEntry("Use tag as entity name for multi instance entities? Y/N", - CONFIG_KEY_USE_TAG_AS_ENTITY_NAME, default="N") + preset.AddEntry("Username", CONFIG_KEY_USERNAME) + preset.AddEntry("Password", CONFIG_KEY_PASSWORD, question_type="secret") + preset.AddEntry("Add computer name to entity name", + CONFIG_KEY_ADD_NAME_TO_ENTITY, default="Y", question_type="yesno") + preset.AddEntry("Use tag as entity name for multi instance entities", + CONFIG_KEY_USE_TAG_AS_ENTITY_NAME, default="N", question_type="yesno") return preset diff --git a/IoTuring/Warehouse/Deployments/MQTTWarehouse/MQTTWarehouse.py b/IoTuring/Warehouse/Deployments/MQTTWarehouse/MQTTWarehouse.py index 6c56f29c3..f9e5cd7f7 100644 --- a/IoTuring/Warehouse/Deployments/MQTTWarehouse/MQTTWarehouse.py +++ b/IoTuring/Warehouse/Deployments/MQTTWarehouse/MQTTWarehouse.py @@ -83,7 +83,7 @@ def ConfigurationPreset(cls) -> MenuPreset: preset.AddEntry("Address", CONFIG_KEY_ADDRESS, mandatory=True) preset.AddEntry("Port", CONFIG_KEY_PORT, default=1883) preset.AddEntry("Client name", CONFIG_KEY_NAME, default=App.getName()) - preset.AddEntry("Username", CONFIG_KEY_USERNAME, default="") - preset.AddEntry("Password", CONFIG_KEY_PASSWORD, default="") - preset.AddEntry("Add units to values (Y/N)", CONFIG_KEY_ADD_UNITS, default="Y", modify_value_callback=MenuPreset.Callback_NormalizeBoolean) + preset.AddEntry("Username", CONFIG_KEY_USERNAME) + preset.AddEntry("Password", CONFIG_KEY_PASSWORD, question_type="secret") + preset.AddEntry("Add units to values", CONFIG_KEY_ADD_UNITS, default="Y", question_type="yesno") return preset diff --git a/IoTuring/__init__.py b/IoTuring/__init__.py index 230c0963e..4ef19f71b 100644 --- a/IoTuring/__init__.py +++ b/IoTuring/__init__.py @@ -16,7 +16,6 @@ def loop(): - signal.signal(signal.SIGINT, Exit_SIGINT_handler) # Start logger: logger = Logger() @@ -27,7 +26,13 @@ def loop(): if not configurator.configuratorIO.checkConfigurationFileExists(): # If the file doesn't exist, check if it's in the old location configurator.configuratorIO.checkConfigurationFileInOldLocation() - configurator.Menu() + try: + configurator.Menu() + except KeyboardInterrupt: + logger.Log(Logger.LOG_WARNING, "Configurator", "Configuration NOT saved") + Exit_SIGINT_handler() + + signal.signal(signal.SIGINT, Exit_SIGINT_handler) logger.Log(Logger.LOG_INFO, "App", App()) # Print App info logger.Log(Logger.LOG_INFO, "Configurator", @@ -60,7 +65,7 @@ def loop(): while(True): time.sleep(1) -def Exit_SIGINT_handler(sig, frame): +def Exit_SIGINT_handler(sig=None, frame=None): logger = Logger() logger.Log(Logger.LOG_INFO, "Main", "Application closed by SigInt", printToConsole=False) # to file diff --git a/pyproject.toml b/pyproject.toml index b8a9eb2da..d53224ea9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "IoTuring" -version = "2023.10.1" +version = "2023.11.1" description = "Simple and powerful cross-platform script to control your pc and share statistics using communication protocols like MQTT and home control hubs like HomeAssistant." readme = "README.md" requires-python = ">=3.7" @@ -26,6 +26,7 @@ dependencies = [ "PyYAML", "importlib_metadata", "requests", + "InquirerPy", "PyObjC; sys_platform == 'darwin'", "IoTuring-applesmc; sys_platform == 'darwin'", "tinyWinToast; sys_platform == 'win32'" From b42074283444682b51cc9e25dc4600b5ebc5951b Mon Sep 17 00:00:00 2001 From: infeeeee Date: Mon, 27 Nov 2023 07:34:53 +0100 Subject: [PATCH 07/11] Escape to cancel in configurator --- IoTuring/ClassManager/ClassManager.py | 4 +-- IoTuring/Configurator/Configurator.py | 39 ++++++++++++++++++++++----- IoTuring/Configurator/MenuPreset.py | 24 ++++++++++++++--- IoTuring/Configurator/messages.py | 27 ++++++++++++------- IoTuring/Exceptions/Exceptions.py | 6 +++++ 5 files changed, 80 insertions(+), 20 deletions(-) diff --git a/IoTuring/ClassManager/ClassManager.py b/IoTuring/ClassManager/ClassManager.py index fb1bab6a5..b23e54f36 100644 --- a/IoTuring/ClassManager/ClassManager.py +++ b/IoTuring/ClassManager/ClassManager.py @@ -33,7 +33,7 @@ def __init__(self): # THIS MUST BE IMPLEMENTED IN SUBCLASSES, IS THE CLASS I WANT TO SEARCH !!!! self.baseClass = None - def GetClassFromName(self, wantedName) -> type: + def GetClassFromName(self, wantedName) -> type|None: # From name, load the correct module and extract the class for module in self.modulesFilename: # Search the module file moduleName = self.ModuleNameFromPath(module) @@ -43,7 +43,7 @@ def GetClassFromName(self, wantedName) -> type: loadedModule = self.LoadModule(module) # Now get the class return self.GetClassFromModule(loadedModule) - raise Exception(f"No class found: {wantedName}") + return None def LoadModule(self, path): # Get module and load it from the path try: diff --git a/IoTuring/Configurator/Configurator.py b/IoTuring/Configurator/Configurator.py index 51e057253..1a8b89b98 100644 --- a/IoTuring/Configurator/Configurator.py +++ b/IoTuring/Configurator/Configurator.py @@ -1,6 +1,8 @@ import os from IoTuring.Logger.LogObject import LogObject +from IoTuring.Exceptions.Exceptions import UserCancelledException + from IoTuring.ClassManager.EntityClassManager import EntityClassManager from IoTuring.ClassManager.WarehouseClassManager import WarehouseClassManager @@ -54,7 +56,7 @@ def Menu(self) -> None: choice = self.DisplayMenu( choices=mainMenuChoices, - message="IoTuring configuration", + message="IoTuring configurator", add_back_choice=False ) choice() @@ -202,8 +204,15 @@ def SelectNewEntity(self, ecm: EntityClassManager): entityList = ecm.ListAvailableClassesNames() # Now I remove the entities that are active and that do not allow multi instances for activeEntity in self.config[KEY_ACTIVE_ENTITIES]: - if not ecm.GetClassFromName(activeEntity[KEY_ENTITY_TYPE])\ - .AllowMultiInstance(): # type: ignore + # Malformed entities, from different versions in config, just skip + entityClass = ecm.GetClassFromName( + activeEntity[KEY_ENTITY_TYPE]) + + if entityClass is None: + continue + + # not multi, remove: + if not entityClass.AllowMultiInstance(): # type: ignore entityList.remove(activeEntity[KEY_ENTITY_TYPE]) choice = self.DisplayMenu( @@ -238,6 +247,9 @@ def AddActiveEntity(self, entityName, ecm: EntityClassManager): "No configuration needed for this Entity :)") self.EntityMenuPresetToConfiguration(entityName, preset) + except UserCancelledException: + self.DisplayMessage("Configuration cancelled", force_clear=True) + except Exception as e: print("Error during entity preset loading: " + str(e)) @@ -290,6 +302,10 @@ def AddActiveWarehouse(self, warehouseName, wcm: WarehouseClassManager) -> None: # Save added settings self.WarehouseMenuPresetToConfiguration(warehouseName, preset) + + except UserCancelledException: + self.DisplayMessage("Configuration cancelled", force_clear=True) + except Exception as e: print("Error during warehouse preset loading: " + str(e)) @@ -373,16 +389,27 @@ def DisplayMenu(self, choices: list, message: str = "", add_back_choice=True, ** ]) self.ClearScreen() - choice = inquirer.select( - message=message, choices=choices, **kwargs).execute() + prompt = inquirer.select( + message=message, choices=choices, **kwargs) + + if CHOICE_GO_BACK in choices: + @prompt.register_kb("escape") + def _handle_esc(event): + prompt.content_control.selection["value"] = CHOICE_GO_BACK + prompt._handle_enter(event) + + choice = prompt.execute() return choice - def DisplayMessage(self, message: str): + def DisplayMessage(self, message: str, force_clear=False): """Display a message on the top of the screen, above menus Args: message (str): The message to display + force_clear (bool): clear screen regardless previous """ + if force_clear: + self.pinned_message = False self.ClearScreen(pin_next_message=True) print(message) print() diff --git a/IoTuring/Configurator/MenuPreset.py b/IoTuring/Configurator/MenuPreset.py index ca8405fd0..7930b4a2c 100644 --- a/IoTuring/Configurator/MenuPreset.py +++ b/IoTuring/Configurator/MenuPreset.py @@ -2,6 +2,8 @@ from InquirerPy import inquirer +from IoTuring.Exceptions.Exceptions import UserCancelledException + class QuestionPreset(): def __init__(self, @@ -75,6 +77,7 @@ class MenuPreset(): def __init__(self) -> None: self.presets: list[QuestionPreset] = [] self.results: list[QuestionPreset] = [] + self.cancelled = False def HasQuestions(self) -> bool: """Check if this preset has any questions to ask""" @@ -130,6 +133,11 @@ def AddEntry(self, def AskQuestions(self) -> None: """Ask all questions of this preset""" for q_preset in self.presets: + # if the previous question was cancelled: + + if self.cancelled: + raise UserCancelledException + try: # It should be displayed, ask question: @@ -148,7 +156,7 @@ def validate(x): return bool(x) prompt_function = inquirer.text question_options["message"] = q_preset.question + ":" - + if q_preset.default is not None: question_options["default"] = q_preset.default @@ -168,10 +176,19 @@ def validate(x): return bool(x) "filter": lambda x: x.lower() }) - value = prompt_function( + prompt = prompt_function( instruction=q_preset.instruction, **question_options - ).execute() + ) + + @prompt.register_kb("escape") + def _handle_esc(event): + prompt._mandatory = False + prompt._handle_skip(event) + # exception raised here catched by inquirer. + self.cancelled = True + + value = prompt.execute() if value: q_preset.value = value @@ -181,6 +198,7 @@ def validate(x): return bool(x) except Exception as e: print("Error while making the question:", e) + def GetAnsweredPresetByKey(self, key: str) -> QuestionPreset | None: return next((entry for entry in self.results if entry.key == key), None) diff --git a/IoTuring/Configurator/messages.py b/IoTuring/Configurator/messages.py index ace5559df..e42f246c1 100644 --- a/IoTuring/Configurator/messages.py +++ b/IoTuring/Configurator/messages.py @@ -2,14 +2,23 @@ HELP_MESSAGE = f""" -You can find the configuration file in the following path: -\tmacOS\t\t~/Library/Application Support/IoTuring/configurations.json -\tLinux\t\t~/.config/IoTuring/configurations.json -\tWindows\t\t%APPDATA%/IoTuring/configurations.json -\tFallback\t[ioturing_install_path]/Configurator/configurations.json - -You can also set your preferred directory by setting the environment variable {ConfiguratorIO.CONFIG_PATH_ENV_VAR} -Configuration will be stored there in the file configurations.json. +1. Configuration file + +\tYou can find the configuration file in the following path: +\t\tmacOS\t\t~/Library/Application Support/IoTuring/configurations.json +\t\tLinux\t\t~/.config/IoTuring/configurations.json +\t\tWindows\t\t%APPDATA%/IoTuring/configurations.json +\t\tFallback\t[ioturing_install_path]/Configurator/configurations.json\t +\tYou can also set your preferred directory by setting the environment variable {ConfiguratorIO.CONFIG_PATH_ENV_VAR} +\tConfiguration will be stored there in the file configurations.json. + +2.Configurator menu + +\tUse Escape to go back to the previous menu +\tUse ctrl+C to exit without saving """ -PRESET_RULES = "Options with this sign are compulsory: {!}" \ No newline at end of file +PRESET_RULES = """ +Options with this sign are compulsory: {!} +Use Escape to cancel +""" \ No newline at end of file diff --git a/IoTuring/Exceptions/Exceptions.py b/IoTuring/Exceptions/Exceptions.py index 55a35b659..9c1590814 100644 --- a/IoTuring/Exceptions/Exceptions.py +++ b/IoTuring/Exceptions/Exceptions.py @@ -2,3 +2,9 @@ class UnknownEntityKeyException(Exception): def __init__(self, *args: object) -> None: super().__init__(*args) self.message = "This key isn't registered in any entity data" + + +class UserCancelledException(Exception): + def __init__(self, *args: object) -> None: + super().__init__(*args) + self.message = "User cancelled the ongoing process" \ No newline at end of file From 3045481a4449b2f771f077b7a1f1469721bf118f Mon Sep 17 00:00:00 2001 From: infeeeee Date: Mon, 27 Nov 2023 08:52:17 +0100 Subject: [PATCH 08/11] Argparse --- IoTuring/MyApp/App.py | 5 ++-- IoTuring/__init__.py | 55 +++++++++++++++++++++++++++++-------------- 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/IoTuring/MyApp/App.py b/IoTuring/MyApp/App.py index 102dc944f..08df928ce 100644 --- a/IoTuring/MyApp/App.py +++ b/IoTuring/MyApp/App.py @@ -12,8 +12,9 @@ class App(): # "Project-URL": "documentation, https://github.com/richibrics/IoTuring", # "Project-URL": "repository, https://github.com/richibrics/IoTuring", # "Project-URL": "changelog, https://github.com/richibrics/IoTuring/releases", - URL_HOMEPAGE = METADATA.get_all("Project-URL")[0].split(', ')[1].strip() - URL_RELEASES = METADATA.get_all("Project-URL")[-1].split(', ')[1].strip() + URLS = METADATA.get_all("Project-URL") or "" + URL_HOMEPAGE = URLS[0].split(', ')[1].strip() + URL_RELEASES = URLS[-1].split(', ')[1].strip() @staticmethod def getName() -> str: diff --git a/IoTuring/__init__.py b/IoTuring/__init__.py index 4ef19f71b..2e65396f0 100644 --- a/IoTuring/__init__.py +++ b/IoTuring/__init__.py @@ -6,39 +6,55 @@ from IoTuring.Entity.EntityManager import EntityManager from IoTuring.Logger.Logger import Logger from IoTuring.Logger.Colors import Colors -import sys import signal import os import time +import argparse warehouses = [] entities = [] def loop(): - + + parser = argparse.ArgumentParser( + prog=App.getName(), + description=App.getDescription(), + epilog="Start without argument for normal use" + ) + + parser.add_argument("-v", "--version", + action="version", + version=f"{App.getName()} {App.getVersion()}" + ) + + parser.add_argument("-c", "--configurator", + help="enter configuration mode", + action="store_true") + + args = parser.parse_args() + # Start logger: logger = Logger() configurator = Configurator() - # add -c to configure with the menu - if len(sys.argv) > 1 and sys.argv[1] == "-c": - if not configurator.configuratorIO.checkConfigurationFileExists(): + if args.configurator: + if not configurator.configuratorIO.checkConfigurationFileExists(): # If the file doesn't exist, check if it's in the old location configurator.configuratorIO.checkConfigurationFileInOldLocation() try: configurator.Menu() except KeyboardInterrupt: - logger.Log(Logger.LOG_WARNING, "Configurator", "Configuration NOT saved") + logger.Log(Logger.LOG_WARNING, "Configurator", + "Configuration NOT saved") Exit_SIGINT_handler() - + signal.signal(signal.SIGINT, Exit_SIGINT_handler) logger.Log(Logger.LOG_INFO, "App", App()) # Print App info logger.Log(Logger.LOG_INFO, "Configurator", "Run the script with -c to enter configuration mode") - eM = EntityManager() # These will be done from the configuration reader @@ -62,24 +78,27 @@ def loop(): # on Windows a SIGINT signal can't be catched otherwise. # Daemon mode involves thread exit when main ends. So # I need main to never end - while(True): + while (True): time.sleep(1) + def Exit_SIGINT_handler(sig=None, frame=None): logger = Logger() - logger.Log(Logger.LOG_INFO, "Main", "Application closed by SigInt", printToConsole=False) # to file - + logger.Log(Logger.LOG_INFO, "Main", "Application closed by SigInt", + printToConsole=False) # to file + messages = ["Exiting...", "Thanks for using IoTuring !"] - print("") # New line + print("") # New line for message in messages: text = "" - if(Logger.checkTerminalSupportsColors()): + if (Logger.checkTerminalSupportsColors()): text += Colors.cyan - text += message - if(Logger.checkTerminalSupportsColors()): + text += message + if (Logger.checkTerminalSupportsColors()): text += Colors.reset - logger.Log(Logger.LOG_INFO, "Main", text, writeToFile=False) # to terminal - + logger.Log(Logger.LOG_INFO, "Main", text, + writeToFile=False) # to terminal + logger.CloseFile() - os._exit(0) \ No newline at end of file + os._exit(0) From 52d1f395696ede5145ba813b89727bc2361744e0 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Mon, 27 Nov 2023 09:36:42 +0100 Subject: [PATCH 09/11] Python 3.8 update, docker link fix --- IoTuring/MyApp/App.py | 2 +- README.md | 10 +++++----- docker-compose.yaml | 4 +++- pyproject.toml | 3 +-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/IoTuring/MyApp/App.py b/IoTuring/MyApp/App.py index 08df928ce..0467394ce 100644 --- a/IoTuring/MyApp/App.py +++ b/IoTuring/MyApp/App.py @@ -1,4 +1,4 @@ -from importlib_metadata import metadata +from importlib.metadata import metadata class App(): METADATA = metadata('IoTuring') diff --git a/README.md b/README.md index 9532dbd30..0ba876e98 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ But the most important thing: **works on all OSs and all architectures ! Windows ### Who knows how it works -Using pip (on Python >= 3.7) install the IoTuring package +Using pip (on Python >= 3.8) install the IoTuring package ```shell pip install IoTuring @@ -38,7 +38,7 @@ Configure with `IoTuring -c` or `python -m IoTuring -c` #### Requirements -- [Python 3.7+](https://www.python.org/downloads/) +- [Python 3.8+](https://www.python.org/downloads/) - [Pip](https://www.makeuseof.com/tag/install-pip-for-python/) Some platforms may need other software for some entities. @@ -109,7 +109,7 @@ python -m IoTuring Run the configurator: ```shell -docker run -it -v ./.config/IoTuring/:/config ghcr.io/richibrics/ioturing:latest IoTuring -c +docker run -it -v ./.config/IoTuring/:/config richibrics/ioturing:latest IoTuring -c ``` Enable the `Console Warehouse` to see logs! @@ -117,7 +117,7 @@ Enable the `Console Warehouse` to see logs! Run detached after configuration: ```shell -docker run -d -v ./.config/IoTuring/:/config ghcr.io/richibrics/ioturing:latest +docker run -d -v ./.config/IoTuring/:/config richibrics/ioturing:latest ``` For a docker compose example see [docker-compose.yaml](./docker-compose.yaml). Create configuration manually or with the command above! @@ -163,7 +163,7 @@ All sensors and switches will be available to be added to your dashboard in your | Volume | control audio volume | ![mac](https://raw.githubusercontent.com/richibrics/IoTuring/main/docs/images/mac.png) ![linux](https://raw.githubusercontent.com/richibrics/IoTuring/main/docs/images/linux.png) | -\* To use the features from Power entity on Linux and macOS you need to give permissions to your user to shutdown and reboot without sudo password. +\* To use the features from Power entity on macOS and on some Linux distros you need to give permissions to your user to shutdown and reboot without sudo password. You can easily do that by using the following terminal command: ```shell diff --git a/docker-compose.yaml b/docker-compose.yaml index dc397de37..334e7a6c3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,6 +1,8 @@ services: ioturing: - image: ghcr.io/richibrics/ioturing:latest + image: richibrics/ioturing:latest + # Or from Github: + # image: ghcr.io/richibrics/ioturing:latest container_name: ioturing volumes: - ~/.config/IoTuring/:/config diff --git a/pyproject.toml b/pyproject.toml index d53224ea9..a418fe9a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "IoTuring" version = "2023.11.1" description = "Simple and powerful cross-platform script to control your pc and share statistics using communication protocols like MQTT and home control hubs like HomeAssistant." readme = "README.md" -requires-python = ">=3.7" +requires-python = ">=3.8" license = {file = "COPYING"} keywords = ["iot","mqtt","monitor"] authors = [ @@ -24,7 +24,6 @@ dependencies = [ "paho-mqtt", "psutil", "PyYAML", - "importlib_metadata", "requests", "InquirerPy", "PyObjC; sys_platform == 'darwin'", From 7dea5fd89084a1c0dda99d822bacad985175efe1 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Mon, 27 Nov 2023 13:52:10 +0100 Subject: [PATCH 10/11] AppSettings --- IoTuring/Configurator/Configurator.py | 70 ++++++++++++- IoTuring/Configurator/ConfiguratorLoader.py | 16 ++- IoTuring/Configurator/ConfiguratorObject.py | 3 +- IoTuring/Configurator/MenuPreset.py | 107 +++++++++++--------- IoTuring/Logger/Logger.py | 7 +- IoTuring/Logger/consts.py | 2 - IoTuring/MyApp/AppSettings.py | 48 +++++++++ IoTuring/Warehouse/Warehouse.py | 5 +- IoTuring/__init__.py | 3 + 9 files changed, 198 insertions(+), 63 deletions(-) create mode 100644 IoTuring/MyApp/AppSettings.py diff --git a/IoTuring/Configurator/Configurator.py b/IoTuring/Configurator/Configurator.py index 1a8b89b98..da885b8ec 100644 --- a/IoTuring/Configurator/Configurator.py +++ b/IoTuring/Configurator/Configurator.py @@ -1,25 +1,30 @@ import os +from IoTuring.Configurator.MenuPreset import QuestionPreset from IoTuring.Logger.LogObject import LogObject from IoTuring.Exceptions.Exceptions import UserCancelledException - from IoTuring.ClassManager.EntityClassManager import EntityClassManager from IoTuring.ClassManager.WarehouseClassManager import WarehouseClassManager from IoTuring.Configurator import ConfiguratorIO - from IoTuring.Configurator import messages +from IoTuring.MyApp.AppSettings import AppSettings + from InquirerPy import inquirer from InquirerPy.separator import Separator -BLANK_CONFIGURATION = {'active_entities': [ - {"type": "AppInfo"}], 'active_warehouses': []} +BLANK_CONFIGURATION = { + 'active_entities': [{"type": "AppInfo"}], + 'active_warehouses': [], + 'app_settings': {} +} KEY_ACTIVE_ENTITIES = "active_entities" KEY_ACTIVE_WAREHOUSES = "active_warehouses" +KEY_APP_SETTINGS = "app_settings" KEY_WAREHOUSE_TYPE = "type" @@ -49,6 +54,7 @@ def Menu(self) -> None: mainMenuChoices = [ {"name": "Manage entities", "value": self.ManageEntities}, {"name": "Manage warehouses", "value": self.ManageWarehouses}, + {"name": "App Settings", "value": self.ManageSettings}, {"name": "Start IoTuring", "value": self.WriteConfigurations}, {"name": "Help", "value": self.DisplayHelp}, {"name": "Quit", "value": self.Quit}, @@ -121,6 +127,55 @@ def ManageWarehouses(self) -> None: else: self.ManageSingleWarehouse(choice, wcm) + def ManageSettings(self): + preset = AppSettings.ConfigurationPreset() + + settingsChoices = [] + + for entry in preset.presets: + # Load config instead of default: + if entry.key in self.config[KEY_APP_SETTINGS]: + value = self.config[KEY_APP_SETTINGS][entry.key] + else: + value = entry.default + + settingsChoices.append({ + "name": f"{entry.name}: {value}", + "value": entry.key + }) + + choice = self.DisplayMenu( + choices=settingsChoices, + message="Select setting to edit", + add_back_choice=True) + + if choice == CHOICE_GO_BACK: + self.Menu() + else: + q_preset = preset.GetPresetByKey(choice) + if q_preset: + self.ManageSingleSetting(q_preset) + else: + self.DisplayMessage(f"Question preset not found: {choice}") + self.ManageSettings() + + + def ManageSingleSetting(self, q_preset:QuestionPreset): + + # Load config as default: + if q_preset.key in self.config[KEY_APP_SETTINGS]: + q_preset.default = self.config[KEY_APP_SETTINGS][q_preset.key] + + value = q_preset.Ask() + + + if value: + # Add to config: + self.config[KEY_APP_SETTINGS][q_preset.key] = value + + self.ManageSettings() + + def DisplayHelp(self) -> None: self.DisplayMessage(messages.HELP_MESSAGE) self.Menu() @@ -136,6 +191,10 @@ def LoadConfigurations(self) -> dict: read_config = self.configuratorIO.readConfigurations() if read_config is None: read_config = BLANK_CONFIGURATION + + # Add AppSettings to old configurations: + if not KEY_APP_SETTINGS in read_config: + read_config[KEY_APP_SETTINGS] = {} return read_config def WriteConfigurations(self) -> None: @@ -388,6 +447,9 @@ def DisplayMenu(self, choices: list, message: str = "", add_back_choice=True, ** CHOICE_GO_BACK ]) + if "max_height" not in kwargs: + kwargs["max_height"] = "100%" + self.ClearScreen() prompt = inquirer.select( message=message, choices=choices, **kwargs) diff --git a/IoTuring/Configurator/ConfiguratorLoader.py b/IoTuring/Configurator/ConfiguratorLoader.py index f072aaa8d..994860868 100644 --- a/IoTuring/Configurator/ConfiguratorLoader.py +++ b/IoTuring/Configurator/ConfiguratorLoader.py @@ -1,10 +1,11 @@ from __future__ import annotations from IoTuring.Entity.Entity import Entity from IoTuring.Logger.LogObject import LogObject -from IoTuring.Configurator.Configurator import KEY_ENTITY_TYPE, Configurator, KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES, KEY_WAREHOUSE_TYPE +from IoTuring.Configurator.Configurator import KEY_ENTITY_TYPE, Configurator, KEY_ACTIVE_ENTITIES, KEY_ACTIVE_WAREHOUSES, KEY_WAREHOUSE_TYPE, KEY_APP_SETTINGS from IoTuring.ClassManager.WarehouseClassManager import WarehouseClassManager from IoTuring.ClassManager.EntityClassManager import EntityClassManager from IoTuring.Warehouse.Warehouse import Warehouse +from IoTuring.MyApp.AppSettings import AppSettings class ConfiguratorLoader(LogObject): @@ -60,3 +61,16 @@ def LoadEntities(self) -> list[Entity]: # - for each one: # - pass the configuration to the warehouse function that uses the configuration to init the Warehouse # - append the Warehouse to the list + + + def LoadAppSettings(self) -> None: + """ Load app settings from config and defafults to AppSettings.Settings class attribute """ + if not KEY_APP_SETTINGS in self.configurations: + self.configurations[KEY_APP_SETTINGS] = {} + + appSettings = AppSettings(self.configurations[KEY_APP_SETTINGS]) + appSettings.AddMissingDefaultConfigs() + + # Add configs to class: + AppSettings.Settings = appSettings.GetConfigurations() + \ No newline at end of file diff --git a/IoTuring/Configurator/ConfiguratorObject.py b/IoTuring/Configurator/ConfiguratorObject.py index 6dfbf9b18..c432db41f 100644 --- a/IoTuring/Configurator/ConfiguratorObject.py +++ b/IoTuring/Configurator/ConfiguratorObject.py @@ -1,5 +1,4 @@ -from IoTuring.Configurator.MenuPreset import BooleanAnswers -from IoTuring.Configurator.MenuPreset import MenuPreset +from IoTuring.Configurator.MenuPreset import BooleanAnswers, MenuPreset class ConfiguratorObject: diff --git a/IoTuring/Configurator/MenuPreset.py b/IoTuring/Configurator/MenuPreset.py index 7930b4a2c..3f2cdf54c 100644 --- a/IoTuring/Configurator/MenuPreset.py +++ b/IoTuring/Configurator/MenuPreset.py @@ -4,6 +4,7 @@ from IoTuring.Exceptions.Exceptions import UserCancelledException + class QuestionPreset(): def __init__(self, @@ -71,6 +72,58 @@ def ShouldDisplay(self, menupreset: "MenuPreset") -> bool: return should_display + def Ask(self, menupreset: "MenuPreset | None" = None): + """Ask a single question preset""" + + question_options = {} + + if self.mandatory: + def validate(x): return bool(x) + question_options.update({ + "validate": validate, + "invalid_message": "You must provide a value for this key" + }) + + # text: + prompt_function = inquirer.text + + question_options["message"] = self.question + ":" + + if self.default is not None: + question_options["default"] = self.default + + if self.question_type == "secret": + prompt_function = inquirer.secret + + elif self.question_type == "yesno": + prompt_function = inquirer.confirm + question_options.update({ + "filter": lambda x: "Y" if x else "N" + }) + + elif self.question_type == "select": + prompt_function = inquirer.select + question_options.update({ + "choices": self.choices + }) + + prompt = prompt_function( + instruction=self.instruction, + **question_options + ) + + @prompt.register_kb("escape") + def _handle_esc(event): + prompt._mandatory = False + prompt._handle_skip(event) + # exception raised here catched by inquirer. + if menupreset: + menupreset.cancelled = True + + value = prompt.execute() + + return value + class MenuPreset(): @@ -133,62 +186,16 @@ def AddEntry(self, def AskQuestions(self) -> None: """Ask all questions of this preset""" for q_preset in self.presets: + # if the previous question was cancelled: - if self.cancelled: raise UserCancelledException try: - # It should be displayed, ask question: if q_preset.ShouldDisplay(self): - question_options = {} - - if q_preset.mandatory: - def validate(x): return bool(x) - question_options.update({ - "validate": validate, - "invalid_message": "You must provide a value for this key" - }) - - # text: - prompt_function = inquirer.text - - question_options["message"] = q_preset.question + ":" - - if q_preset.default is not None: - question_options["default"] = q_preset.default - - if q_preset.question_type == "secret": - prompt_function = inquirer.secret - - elif q_preset.question_type == "yesno": - prompt_function = inquirer.confirm - question_options.update({ - "filter": lambda x: "Y" if x else "N" - }) - - elif q_preset.question_type == "select": - prompt_function = inquirer.select - question_options.update({ - "choices": q_preset.choices, - "filter": lambda x: x.lower() - }) - - prompt = prompt_function( - instruction=q_preset.instruction, - **question_options - ) - - @prompt.register_kb("escape") - def _handle_esc(event): - prompt._mandatory = False - prompt._handle_skip(event) - # exception raised here catched by inquirer. - self.cancelled = True - - value = prompt.execute() + value = q_preset.Ask(self) if value: q_preset.value = value @@ -198,10 +205,12 @@ def _handle_esc(event): except Exception as e: print("Error while making the question:", e) - def GetAnsweredPresetByKey(self, key: str) -> QuestionPreset | None: return next((entry for entry in self.results if entry.key == key), None) + def GetPresetByKey(self, key: str) -> QuestionPreset | None: + return next((entry for entry in self.presets if entry.key == key), None) + def GetDict(self) -> dict: """ Get a dict with keys and responses""" return {entry.key: entry.value for entry in self.results} diff --git a/IoTuring/Logger/Logger.py b/IoTuring/Logger/Logger.py index f3ceea78d..39dc74c69 100644 --- a/IoTuring/Logger/Logger.py +++ b/IoTuring/Logger/Logger.py @@ -8,6 +8,9 @@ import json import threading +from IoTuring.MyApp.AppSettings import AppSettings, CONFIG_KEY_CONSOLE_LOG_LEVEL, CONFIG_KEY_FILE_LOG_LEVEL + + class Singleton(type): """ Metaclass for singleton classes """ @@ -122,12 +125,12 @@ def LogList(self, loglevel, source, message_list: list, *args): # Both print and save to file def PrintAndSave(self, string, loglevel: LogLevel, printToConsole=True, writeToFile=True) -> None: # Override log level from envvar: - console_level = consts.CONSOLE_LOG_LEVEL + console_level = AppSettings.Settings[CONFIG_KEY_CONSOLE_LOG_LEVEL] if os.getenv("IOTURING_LOG_LEVEL"): console_level = str(os.getenv("IOTURING_LOG_LEVEL")) console_log_level = LogLevel(console_level) - file_log_level = LogLevel(consts.FILE_LOG_LEVEL) + file_log_level = LogLevel(AppSettings.Settings[CONFIG_KEY_FILE_LOG_LEVEL]) if printToConsole and int(loglevel) <= int(console_log_level): self.ColoredPrint(string, loglevel) diff --git a/IoTuring/Logger/consts.py b/IoTuring/Logger/consts.py index 8678c2e5a..c7d5916ae 100644 --- a/IoTuring/Logger/consts.py +++ b/IoTuring/Logger/consts.py @@ -56,7 +56,5 @@ # before those spaces I add this string LONG_MESSAGE_PRESTRING_CHAR = ' ' -CONSOLE_LOG_LEVEL = "LOG_INFO" -FILE_LOG_LEVEL = "LOG_INFO" MESSAGE_WIDTH = 95 diff --git a/IoTuring/MyApp/AppSettings.py b/IoTuring/MyApp/AppSettings.py new file mode 100644 index 000000000..cc3f3b74c --- /dev/null +++ b/IoTuring/MyApp/AppSettings.py @@ -0,0 +1,48 @@ +from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject +from IoTuring.Configurator.MenuPreset import MenuPreset + +from IoTuring.Logger import consts + +CONFIG_KEY_CONSOLE_LOG_LEVEL = "console_log_level" +CONFIG_KEY_FILE_LOG_LEVEL = "console_file_level" + + +CONFIG_KEY_UPDATE_INTERVAL = "update_interval" +CONFIG_KEY_SLOW_INTERVAL = "slow_interval" + +DEFAULT_LOG_LEVEL = "LOG_INFO" + +LogLevelChoices = [{"name": l["string"], "value": l["const"]} + for l in consts.LOG_LEVELS] +# LogLevelChoices = [l["const"] for l in consts.LOG_LEVELS] + + +class AppSettings(ConfiguratorObject): + # Default log levels, so Logging can start before configuration is loaded + Settings = { + CONFIG_KEY_CONSOLE_LOG_LEVEL: DEFAULT_LOG_LEVEL, + CONFIG_KEY_FILE_LOG_LEVEL: DEFAULT_LOG_LEVEL + } + + @classmethod + def ConfigurationPreset(cls): + preset = MenuPreset() + + preset.AddEntry(name="Console log level", key=CONFIG_KEY_CONSOLE_LOG_LEVEL, + question_type="select", mandatory=True, default=DEFAULT_LOG_LEVEL, + instruction="IOTURING_LOG_LEVEL envvar overwrites this setting!", + choices=LogLevelChoices) + + preset.AddEntry(name="File log level", key=CONFIG_KEY_FILE_LOG_LEVEL, + question_type="select", mandatory=True, default=DEFAULT_LOG_LEVEL, + choices=LogLevelChoices) + + preset.AddEntry(name="Main update interval in seconds", + key=CONFIG_KEY_UPDATE_INTERVAL, mandatory=True, + question_type="text", default="10") + + preset.AddEntry(name="Secondary update interval in minutes", + key=CONFIG_KEY_SLOW_INTERVAL, mandatory=True, + question_type="text", default="10") + + return preset diff --git a/IoTuring/Warehouse/Warehouse.py b/IoTuring/Warehouse/Warehouse.py index 82a84855e..4994f5f9d 100644 --- a/IoTuring/Warehouse/Warehouse.py +++ b/IoTuring/Warehouse/Warehouse.py @@ -3,18 +3,17 @@ from IoTuring.Logger.LogObject import LogObject from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject from IoTuring.Entity.EntityManager import EntityManager +from IoTuring.MyApp.AppSettings import AppSettings, CONFIG_KEY_UPDATE_INTERVAL from threading import Thread import time -DEFAULT_LOOP_TIMEOUT = 10 - class Warehouse(LogObject, ConfiguratorObject): NAME = "Unnamed" def __init__(self, configurations) -> None: - self.loopTimeout = DEFAULT_LOOP_TIMEOUT + self.loopTimeout = float(AppSettings.Settings[CONFIG_KEY_UPDATE_INTERVAL]) self.configurations = configurations def Start(self) -> None: diff --git a/IoTuring/__init__.py b/IoTuring/__init__.py index 2e65396f0..cbcafe64d 100644 --- a/IoTuring/__init__.py +++ b/IoTuring/__init__.py @@ -51,6 +51,9 @@ def loop(): signal.signal(signal.SIGINT, Exit_SIGINT_handler) + # Load AppSettings: + ConfiguratorLoader(configurator).LoadAppSettings() + logger.Log(Logger.LOG_INFO, "App", App()) # Print App info logger.Log(Logger.LOG_INFO, "Configurator", "Run the script with -c to enter configuration mode") From f132a4229eb20ca64d24c15d425e87d6ac467366 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Mon, 27 Nov 2023 18:33:28 +0100 Subject: [PATCH 11/11] Fix tests, update from other branch --- IoTuring/ClassManager/ClassManager.py | 4 +++- IoTuring/Configurator/Configurator.py | 10 +++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/IoTuring/ClassManager/ClassManager.py b/IoTuring/ClassManager/ClassManager.py index b23e54f36..e7f4dc9f8 100644 --- a/IoTuring/ClassManager/ClassManager.py +++ b/IoTuring/ClassManager/ClassManager.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os from pathlib import Path from os import path @@ -33,7 +35,7 @@ def __init__(self): # THIS MUST BE IMPLEMENTED IN SUBCLASSES, IS THE CLASS I WANT TO SEARCH !!!! self.baseClass = None - def GetClassFromName(self, wantedName) -> type|None: + def GetClassFromName(self, wantedName) -> type | None: # From name, load the correct module and extract the class for module in self.modulesFilename: # Search the module file moduleName = self.ModuleNameFromPath(module) diff --git a/IoTuring/Configurator/Configurator.py b/IoTuring/Configurator/Configurator.py index da885b8ec..e1a9d1043 100644 --- a/IoTuring/Configurator/Configurator.py +++ b/IoTuring/Configurator/Configurator.py @@ -1,4 +1,5 @@ import os + from IoTuring.Configurator.MenuPreset import QuestionPreset from IoTuring.Logger.LogObject import LogObject @@ -81,16 +82,15 @@ def ManageEntities(self) -> None: manageEntitiesChoices.sort(key=lambda d: d['name']) - manageEntitiesChoices.extend([ - Separator(), + manageEntitiesChoices = [ {"name": "+ Add a new entity", "value": "AddNewEntity"}, - CHOICE_GO_BACK, - ]) + Separator() + ] + manageEntitiesChoices choice = self.DisplayMenu( choices=manageEntitiesChoices, message="Manage entities", - add_back_choice=False) + add_back_choice=True) if choice == "AddNewEntity": self.SelectNewEntity(ecm)