diff --git a/IoTuring/ClassManager/ClassManager.py b/IoTuring/ClassManager/ClassManager.py index 0ffedd08c..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): + 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) @@ -50,21 +52,26 @@ def LoadModule(self, path): # Get module and load it from the path 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..e1a9d1043 100644 --- a/IoTuring/Configurator/Configurator.py +++ b/IoTuring/Configurator/Configurator.py @@ -1,39 +1,47 @@ +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 -# 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.MyApp.AppSettings import AppSettings -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': [ - {"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" 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,127 +51,150 @@ 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": "App Settings", "value": self.ManageSettings}, + {"name": "Start IoTuring", "value": self.WriteConfigurations}, + {"name": "Help", "value": self.DisplayHelp}, + {"name": "Quit", "value": self.Quit}, + ] + + choice = self.DisplayMenu( + choices=mainMenuChoices, + message="IoTuring configurator", + 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 = [ + {"name": "+ Add a new entity", "value": "AddNewEntity"}, + Separator() + ] + manageEntitiesChoices + + choice = self.DisplayMenu( + choices=manageEntitiesChoices, + message="Manage entities", + add_back_choice=True) + + 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 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() + + 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 + + # 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: @@ -171,109 +202,118 @@ 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]: + # 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( + 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 UserCancelledException: + self.DisplayMessage("Configuration cancelled", force_clear=True) + 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 +321,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 +349,46 @@ 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 UserCancelledException: + self.DisplayMessage("Configuration cancelled", force_clear=True) + 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 +396,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 +414,64 @@ 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 + ]) + + if "max_height" not in kwargs: + kwargs["max_height"] = "100%" + + self.ClearScreen() + 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, 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/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/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 44747a73f..3f2cdf54c 100644 --- a/IoTuring/Configurator/MenuPreset.py +++ b/IoTuring/Configurator/MenuPreset.py @@ -1,32 +1,44 @@ from __future__ import annotations +from InquirerPy import inquirer + +from IoTuring.Exceptions.Exceptions import UserCancelledException + + 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) + ": " + self.question += " {!}" - 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.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 +57,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: @@ -60,18 +72,79 @@ 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(): 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""" 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 +159,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 +178,29 @@ 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 + # if the previous question was cancelled: + if self.cancelled: + raise UserCancelledException + + try: # 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 + value = q_preset.Ask(self) - # 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) + 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) @@ -140,6 +208,9 @@ def AskQuestions(self) -> None: 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} @@ -148,39 +219,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..e42f246c1 --- /dev/null +++ b/IoTuring/Configurator/messages.py @@ -0,0 +1,24 @@ +from IoTuring.Configurator import ConfiguratorIO + + +HELP_MESSAGE = f""" +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: {!} +Use Escape to cancel +""" \ No newline at end of file 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..7eea923ca 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 * @@ -18,8 +17,8 @@ class DisplayMode(Entity): def Initialize(self): callback = None if OsD.IsWindows(): - sr = sys_os.environ.get('SystemRoot') - if sys_os.path.exists('{}\System32\DisplaySwitch.exe'.format(sr)): + sr = OsD.GetEnv('SystemRoot') + 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/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/Lock/Lock.py b/IoTuring/Entity/Deployments/Lock/Lock.py index 79c1ba2c1..bc170c32e 100644 --- a/IoTuring/Entity/Deployments/Lock/Lock.py +++ b/IoTuring/Entity/Deployments/Lock/Lock.py @@ -1,20 +1,18 @@ -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' 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', @@ -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..06623346d 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: @@ -15,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 "{}"\'' } @@ -137,27 +136,18 @@ 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: 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/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..99c301363 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 @@ -11,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', } @@ -43,19 +41,19 @@ def Initialize(self): if self.os in commands_shutdown: self.commands[KEY_SHUTDOWN] = commands_shutdown[self.os] self.RegisterEntityCommand(EntityCommand( - self, KEY_SHUTDOWN, self.CallbackShutdown)) + self, KEY_SHUTDOWN, self.Callback)) # Reboot if self.os in commands_reboot: self.commands[KEY_REBOOT] = commands_reboot[self.os] self.RegisterEntityCommand(EntityCommand( - self, KEY_REBOOT, self.CallbackReboot)) + self, KEY_REBOOT, self.Callback)) # 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: + if not self.RunCommand(testcommand).returncode == 0: self.commands[commandtype] = "sudo " + \ self.commands[commandtype] @@ -72,27 +70,12 @@ def Initialize(self): 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): - self.CallCommand(KEY_SHUTDOWN) - - def CallbackReboot(self, message): - self.CallCommand(KEY_REBOOT) - - def CallbackSleep(self, message): - self.CallCommand(KEY_SLEEP) + 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], + command_name=key + ) diff --git a/IoTuring/Entity/Deployments/Terminal/Terminal.py b/IoTuring/Entity/Deployments/Terminal/Terminal.py index 4b123409e..e8da9794e 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,8 +212,8 @@ 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 = f"Error: {command.stderr}" if command.stderr else command.stdout def Update(self): @@ -222,23 +221,24 @@ def Update(self): # 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) + self.SetEntitySensorExtraAttribute( - KEY_STATE, "Last state command output", command["message"]) + KEY_STATE, "Last state command output", + f"Error: {command.stderr}" if command.stderr else command.stdout) if self.has_command: # Set extra attributes: @@ -259,87 +261,67 @@ 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() - 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/Deployments/Volume/Volume.py b/IoTuring/Entity/Deployments/Volume/Volume.py index e96baeff7..8d5df3496 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 @@ -17,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 {}"' } @@ -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..b63f22c32 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,62 @@ def SetTagFromConfiguration(self): else: self.tag = "" + def RunCommand(self, + command: str | list, + command_name: str = "", + log_errors: bool = True, + 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): + runcommand = command.split() + else: + runcommand = command + + if command_name: + command_name = self.NAME + "-" + command_name + else: + command_name = self.NAME + + p = subprocess.run( + runcommand, shell=shell, **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/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 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/App.py b/IoTuring/MyApp/App.py index 743de742b..0467394ce 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 - +from importlib.metadata import metadata class App(): METADATA = metadata('IoTuring') @@ -16,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/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/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" 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/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 230c0963e..cbcafe64d 100644 --- a/IoTuring/__init__.py +++ b/IoTuring/__init__.py @@ -6,34 +6,58 @@ 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(): - signal.signal(signal.SIGINT, Exit_SIGINT_handler) - + + 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() - 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) + + # 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") - eM = EntityManager() # These will be done from the configuration reader @@ -57,24 +81,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, 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 - + 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) 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 b8a9eb2da..a418fe9a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,9 @@ [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" +requires-python = ">=3.8" license = {file = "COPYING"} keywords = ["iot","mqtt","monitor"] authors = [ @@ -24,8 +24,8 @@ dependencies = [ "paho-mqtt", "psutil", "PyYAML", - "importlib_metadata", "requests", + "InquirerPy", "PyObjC; sys_platform == 'darwin'", "IoTuring-applesmc; sys_platform == 'darwin'", "tinyWinToast; sys_platform == 'win32'"