diff --git a/IoTuring/Configurator/Configuration.py b/IoTuring/Configurator/Configuration.py new file mode 100644 index 000000000..69fbb6a93 --- /dev/null +++ b/IoTuring/Configurator/Configuration.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +from IoTuring.ClassManager.consts import KEY_ENTITY, KEY_WAREHOUSE + +CONFIG_CLASS = { + KEY_ENTITY: "active_entities", + KEY_WAREHOUSE: "active_warehouses" +} + +BLANK_CONFIGURATION = { + CONFIG_CLASS[KEY_ENTITY]: [{"type": "AppInfo"}], + CONFIG_CLASS[KEY_WAREHOUSE]: [] +} + + +CONFIG_KEY_TAG = "tag" +CONFIG_KEY_TYPE = "type" + + +class SingleConfiguration: + """Single configuration of an entity or warehouse""" + + def __init__(self, config_class: str, config_dict: dict) -> None: + """Create a new SingleConfiguration + + Args: + config_class (str): CONFIG_CLASS of the config + config_dict (dict): All options as in config file + """ + self.config_class = config_class + self.config_type = config_dict.pop(CONFIG_KEY_TYPE) + self.configurations = config_dict + + def GetType(self) -> str: + """Get the type name of entity or warehouse (e.g. Cpu, Battery, HomeAssistant)""" + return self.config_type + + def GetTag(self) -> str: + """Get the tag of entity""" + if CONFIG_KEY_TAG in self.configurations: + return self.configurations[CONFIG_KEY_TAG] + else: + return "" + + def GetLabel(self) -> str: + """Get the type name of this configuration, add tag if multi""" + + label = self.GetType() + + if self.GetTag(): + label += f" with tag {self.GetTag()}" + + return label + + def GetLongName(self) -> str: + """ Get the type with the category name at the end (e.g. CpuEntity, HomeAssistantWarehouse)""" + + # Add category name to the end + return str(self.GetType() + self.GetClassKey().capitalize()) + + def GetClassKey(self) -> str: + """Get the CLASS_KEY of this configuration, e.g. KEY_ENTITY, KEY_WAREHOUSE""" + return [i for i in CONFIG_CLASS if CONFIG_CLASS[i] == self.config_class][0] + + def GetConfigValue(self, config_key: str): + """Get the value of a config key + + Args: + config_key (str): The key of the configuration + + Raises: + ValueError: If the key is not found + + Returns: + The value of the key. + """ + if config_key in self.configurations: + return self.configurations[config_key] + else: + raise ValueError("Config key not set") + + def UpdateConfigValue(self, config_key: str, config_value: str) -> None: + """Update the value of the configuration. Overwrites existing value + + Args: + config_key (str): The key of the configuration + config_value (str): The preferred value + """ + self.configurations[config_key] = config_value + + def HasConfigKey(self, config_key: str) -> bool: + """Check if key has a value + + Args: + config_key (str): The key of the configuration + + Returns: + bool: If it has a value + """ + return bool(config_key in self.configurations) + + def ToDict(self, include_type: bool = True) -> dict: + """ SingleConfiguration as a dict, as it would be saved to a file """ + full_dict = self.configurations + if include_type: + full_dict[CONFIG_KEY_TYPE] = self.GetType() + return full_dict + + +class FullConfiguration: + """Full configuration of all classes""" + + def __init__(self, config_dict: dict | None) -> None: + """Initialize from dict or create blank + + Args: + config_dict (dict | None): The config as dict or None to create blank + """ + + config_dict = config_dict or BLANK_CONFIGURATION + self.configs = [] + + for config_class, single_configs in config_dict.items(): + for single_config_dict in single_configs: + + self.configs.append(SingleConfiguration( + config_class, single_config_dict)) + + def GetConfigsOfClass(self, class_key: str) -> list[SingleConfiguration]: + """Return all configurations of class + + Args: + class_key (str): CLASS_KEY of the class: e.g. KEY_ENTITY, KEY_WAREHOUSE + + Returns: + list: Configurations in the class. Empty list if none found. + """ + return [config for config in self.configs if config.config_class == CONFIG_CLASS[class_key]] + + def GetConfigsOfType(self, config_type: str) -> list[SingleConfiguration]: + """Return all configs with the given type. + + For example all configurations of Notify entity. + + Args: + config_type (str): The type of config to return + + Returns: + list: Configurations of the given type. Empty list if none found. + """ + + return [config for config in self.configs if config.GetType() == config_type] + + def RemoveActiveConfiguration(self, config: SingleConfiguration) -> None: + """Remove a configuration from the list of active configurations""" + if config in self.configs: + self.configs.remove(config) + else: + raise ValueError("Configuration not found") + + def AddConfiguration(self, class_key: str, config_type: str, single_config_dict: dict) -> None: + """Add a new configuration to the list of active configurations + + Args: + class_key (str): CLASS_KEY of the class: e.g. KEY_ENTITY, KEY_WAREHOUSE + config_type (str): The type of the configuration + single_config_dict (dict): all settings as a dict + """ + + config_class = CONFIG_CLASS[class_key] + single_config_dict[CONFIG_KEY_TYPE] = config_type + + self.configs.append(SingleConfiguration( + config_class, single_config_dict)) + + def ToDict(self) -> dict: + """Full configuration as a dict, for saving to file """ + config_dict = {} + for class_key in CONFIG_CLASS: + + config_dict[CONFIG_CLASS[class_key]] = [] + + for single_config in self.GetConfigsOfClass(class_key): + config_dict[CONFIG_CLASS[class_key]].append( + single_config.ToDict()) + + return config_dict diff --git a/IoTuring/Configurator/Configurator.py b/IoTuring/Configurator/Configurator.py index 06954df18..c1fd1877f 100644 --- a/IoTuring/Configurator/Configurator.py +++ b/IoTuring/Configurator/Configurator.py @@ -3,6 +3,9 @@ import shutil import sys +from IoTuring.Configurator.MenuPreset import QuestionPreset +from IoTuring.Configurator.Configuration import FullConfiguration, SingleConfiguration + from IoTuring.Logger.LogObject import LogObject from IoTuring.Exceptions.Exceptions import UserCancelledException @@ -17,17 +20,6 @@ from InquirerPy.separator import Separator -BLANK_CONFIGURATION = {'active_entities': [ - {"type": "AppInfo"}], 'active_warehouses': []} - -KEY_ACTIVE_ENTITIES = "active_entities" -KEY_ACTIVE_WAREHOUSES = "active_warehouses" - -KEY_WAREHOUSE_TYPE = "type" - -KEY_ENTITY_TYPE = "type" -KEY_ENTITY_TAG = "tag" - CHOICE_GO_BACK = "< Go back" @@ -37,12 +29,12 @@ def __init__(self) -> None: self.pinned_lines = 1 + # Load configuration from file: self.configuratorIO = ConfiguratorIO.ConfiguratorIO() - self.config = self.LoadConfigurations() + config_dict_from_file = self.configuratorIO.readConfigurations() - def GetConfigurations(self) -> dict: - """ Return a copy of the configurations dict""" - return self.config.copy() # Safe return + # Create FullConfiguration object: + self.config = FullConfiguration(config_dict_from_file) def CheckFile(self) -> None: """ Make sure config file exists or can be created """ @@ -114,17 +106,17 @@ def ManageEntities(self) -> None: manageEntitiesChoices = [] - for entityConfig in self.config[KEY_ACTIVE_ENTITIES]: + for entityConfig in self.config.GetConfigsOfClass(KEY_ENTITY): manageEntitiesChoices.append( - {"name": self.GetEntityLabel(entityConfig), + {"name": entityConfig.GetLabel(), "value": entityConfig} ) - manageEntitiesChoices.sort(key=lambda d: d['name']) + manageEntitiesChoices.sort(key=lambda d: d['name']) manageEntitiesChoices = [ CHOICE_GO_BACK, - {"name": "+ Add a new entity", "value": "AddNewEntity"}, + {"name": "+ Add a new entity", "value": "SelectNewEntity"}, {"name": "? Unsupported entities", "value": "UnsupportedEntities"}, Separator() ] + manageEntitiesChoices @@ -134,14 +126,16 @@ def ManageEntities(self) -> None: message="Manage entities", add_back_choice=False) - if choice == "AddNewEntity": + if choice == "SelectNewEntity": self.SelectNewEntity(ecm) elif choice == "UnsupportedEntities": self.ShowUnsupportedEntities(ecm) elif choice == CHOICE_GO_BACK: self.Menu() else: - self.ManageSingleEntity(choice, ecm) + entityClass = ecm.GetClassFromName(choice.GetType()) + self.ManageActiveConfiguration(entityClass, choice) + self.ManageEntities() def ManageWarehouses(self) -> None: """ UI for Warehouses settings """ @@ -152,8 +146,7 @@ def ManageWarehouses(self) -> None: availableWarehouses = wcm.ListAvailableClasses() for whClass in availableWarehouses: - enabled_sign = "X" \ - if self.IsWarehouseActive(whClass.NAME) else " " + enabled_sign = "X" if self.IsClassActive(whClass) else " " manageWhChoices.append( {"name": f"[{enabled_sign}] - {whClass.NAME}", @@ -167,9 +160,19 @@ def ManageWarehouses(self) -> None: if choice == CHOICE_GO_BACK: self.Menu() else: - self.ManageSingleWarehouse(choice) + if self.IsClassActive(choice): + whConfig = self.config.GetConfigsOfType(choice.NAME)[0] + self.ManageActiveConfiguration(choice, whConfig) + self.ManageWarehouses() + else: + self.AddNewWarehouse(choice) + + def IsClassActive(self, typeClass) -> bool: + """Check if class has an active configuration """ + return bool(self.config.GetConfigsOfType(typeClass.NAME)) def DisplayHelp(self) -> None: + """" Display the help message, and load the main menu """ self.DisplayMessage(messages.HELP_MESSAGE) # Help message is too long: self.pinned_lines = 1 @@ -180,100 +183,72 @@ def Quit(self) -> None: self.WriteConfigurations() sys.exit(0) - def LoadConfigurations(self) -> dict: - """ Reads the configuration file and returns configuration dictionary. - If not available, returns the blank configuration """ - read_config = self.configuratorIO.readConfigurations() - if read_config is None: - read_config = BLANK_CONFIGURATION - return read_config - def WriteConfigurations(self) -> None: """ Save to configurations file """ - self.configuratorIO.writeConfigurations(self.config) + self.configuratorIO.writeConfigurations(self.config.ToDict()) - def ManageSingleWarehouse(self, whClass): - """UI for single Warehouse settings""" + def AddNewWarehouse(self, whClass) -> None: + """UI to add a new warehouse""" - if self.IsWarehouseActive(whClass.NAME): - manageWhChoices = [ - {"name": "Edit the warehouse settings", "value": "Edit"}, - {"name": "Remove the warehouse", "value": "Remove"} - ] - else: - manageWhChoices = [ - {"name": "Add the warehouse", "value": "Add"}] + choices = [ + {"name": "Add the warehouse", "value": "Add"}] choice = self.DisplayMenu( - choices=manageWhChoices, + choices=choices, message=f"Manage warehouse {whClass.NAME}" ) if choice == CHOICE_GO_BACK: self.ManageWarehouses() - elif choice == "Edit": - self.EditActiveWarehouse(whClass.NAME) elif choice == "Add": - self.AddActiveWarehouse(whClass) - elif choice == "Remove": - confirm = inquirer.confirm(message="Are you sure?").execute() - - if confirm: - self.RemoveActiveWarehouse(whClass.NAME) - else: - self.ManageWarehouses() - - def ManageSingleEntity(self, entityConfig, ecm: ClassManager): - """ UI to manage an active warehouse (edit config/remove) """ + self.AddNewConfiguration(whClass) + self.ManageWarehouses() - manageEntityChoices = [ - {"name": "Edit the entity settings", "value": "Edit"}, - {"name": "Remove the entity", "value": "Remove"} + def ManageActiveConfiguration(self, typeClass, single_config: SingleConfiguration) -> None: + choices = [ + {"name": f"Edit the {typeClass.GetClassKey()} settings", "value": "Edit"}, + {"name": f"Remove the {typeClass.GetClassKey()}", "value": "Remove"} ] choice = self.DisplayMenu( - choices=manageEntityChoices, - message=f"Manage entity {self.GetEntityLabel(entityConfig)}" + choices=choices, + message=f"Manage {typeClass.GetClassKey()} {typeClass.NAME}" ) if choice == CHOICE_GO_BACK: - self.ManageEntities() + return elif choice == "Edit": - self.EditActiveEntity(entityConfig, ecm) # type: ignore - elif choice == "Remove": - confirm = inquirer.confirm(message="Are you sure?").execute() + self.EditActiveConfiguration( + typeClass, single_config) + self.ManageActiveConfiguration(typeClass, single_config) - if confirm: - self.RemoveActiveEntity(entityConfig) - else: - self.ManageEntities() + elif choice == "Remove": + self.RemoveActiveConfiguration(single_config) def SelectNewEntity(self, ecm: ClassManager): - """ UI to add a new Entity """ + """ UI to select new Entity to add """ - # entity classnames without unsupported entities: - entityList = [ - e.NAME for e in ecm.ListAvailableClasses() if e.SystemSupported()] + # entity classes without unsupported entities: + entityClasses = [ + e for e in ecm.ListAvailableClasses() if e.SystemSupported()] - # Now I remove the entities that are active and that do not allow multi instances - for activeEntity in self.config[KEY_ACTIVE_ENTITIES]: - entityClass = ecm.GetClassFromName( - activeEntity[KEY_ENTITY_TYPE]) + entityChoices = [] - # Malformed entities, from different versions in config, just skip: - if entityClass is None: - continue + for entityClass in entityClasses: - # If the Allow Multi Instance option was changed: - if activeEntity[KEY_ENTITY_TYPE] not in entityList: - continue + # If already added append only if multi allowed: + if self.IsClassActive(entityClass): + if not entityClass.AllowMultiInstance(): + continue - # not multi, remove: - if not entityClass.AllowMultiInstance(): # type: ignore - entityList.remove(activeEntity[KEY_ENTITY_TYPE]) + entityChoices.append( + {"name": entityClass.NAME, "value": entityClass} + ) + + entityChoices.sort(key=lambda d: d['name']) choice = self.DisplayMenu( - choices=sorted(entityList), + choices=entityChoices, message="Available entities:", instruction="if you don't see the entity, it may be already active and not accept another activation, or not supported by your system" ) @@ -281,7 +256,8 @@ def SelectNewEntity(self, ecm: ClassManager): if choice == CHOICE_GO_BACK: self.ManageEntities() else: - self.AddActiveEntity(choice, ecm) + self.AddNewConfiguration(choice) + self.ManageEntities() def ShowUnsupportedEntities(self, ecm: ClassManager): """ UI to show unsupported entities """ @@ -303,137 +279,117 @@ def ShowUnsupportedEntities(self, ecm: ClassManager): self.ManageEntities() - def AddActiveEntity(self, entityName, ecm: ClassManager): - """ From entity name, get its class and retrieve the configuration preset, then add to configuration dict """ - entityClass = ecm.GetClassFromName(entityName) - try: - if not entityClass: - raise Exception(f"Entityclass not found: {entityName}") + def RemoveActiveConfiguration(self, singleConfig: SingleConfiguration) -> None: + """ Remove configuration (wh or entity) """ + confirm = inquirer.confirm(message="Are you sure?").execute() + if confirm: + self.config.RemoveActiveConfiguration(singleConfig) + self.DisplayMessage( + f"{singleConfig.GetClassKey().capitalize()} removed: {singleConfig.GetLabel()}") - preset = entityClass.ConfigurationPreset() + def AddNewConfiguration(self, typeClass) -> None: + """Add a wh or Entity to configuration. + + Args: + typeClass: the WH or Entity class to add + """ + try: + preset = typeClass.ConfigurationPreset() 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 typeClass.AllowMultiInstance(): preset.AddTagQuestion() self.DisplayMessage(messages.PRESET_RULES) - self.DisplayMessage(f"Configure {entityName} Entity") + self.DisplayMessage( + f"Configure {typeClass.NAME} {typeClass.GetClassKey()}") preset.AskQuestions() self.ClearScreen(force_clear=True) else: self.DisplayMessage( - "No configuration needed for this Entity :)") + f"No configuration needed for this {typeClass.GetClassKey()} :)") + + self.config.AddConfiguration( + typeClass.GetClassKey(), typeClass.NAME, preset.GetDict()) - 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)) + print( + f"Error during {typeClass.GetClassKey()} 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]: - if entityName == entity[KEY_ENTITY_TYPE]: - return True - return False - - 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 EditActiveConfiguration(self, typeClass, single_config: SingleConfiguration) -> None: + """ UI for changing settings """ + preset = typeClass.ConfigurationPreset() - def IsWarehouseActive(self, warehouseName) -> bool: - """ Return True if a warehouse is active """ - for wh in self.config[KEY_ACTIVE_WAREHOUSES]: - if warehouseName == wh[KEY_WAREHOUSE_TYPE]: - return True - return False + if preset.HasQuestions(): - def AddActiveWarehouse(self, whClass) -> None: - """ Add warehouse to the preferences using a menu with the warehouse preset if available """ + choices = [] - try: - preset = whClass.ConfigurationPreset() # type: ignore + # Add tag: + if typeClass.AllowMultiInstance(): + preset.AddTagQuestion() - if preset.HasQuestions(): - self.DisplayMessage(messages.PRESET_RULES) - preset.AskQuestions() - self.ClearScreen(force_clear=True) + for entry in preset.presets: + if entry.ShouldDisplay(single_config.ToDict(include_type=False)): - else: - self.DisplayMessage( - "No configuration needed for this Warehouse :)") + # Load config instead of default: + if single_config.HasConfigKey(entry.key): + value = single_config.GetConfigValue(entry.key) + if entry.question_type == "secret": + value = "*" * len(value) + else: + value = entry.default - # Save added settings - self.WarehouseMenuPresetToConfiguration(whClass.NAME, preset) + # Nice display for None: + if value is None: + value = "" - except UserCancelledException: - self.DisplayMessage("Configuration cancelled", force_clear=True) + choices.append({ + "name": f"{entry.name}: {value}", + "value": entry.key + }) - except Exception as e: - print("Error during warehouse preset loading: " + str(e)) + choice = self.DisplayMenu( + choices=choices, + message="Select config to edit") - self.ManageWarehouses() + if choice == CHOICE_GO_BACK: + return + else: + q_preset = preset.GetPresetByKey(choice) + if q_preset: + self.EditSinglePreset(q_preset, single_config) + self.EditActiveConfiguration(typeClass, single_config) + else: + self.DisplayMessage(f"Question preset not found: {choice}") - def EditActiveWarehouse(self, warehouseName) -> None: - """ UI for single Warehouse settings edit """ - self.DisplayMessage( - "You can't do that at the moment, change the configuration file manually. Sorry for the inconvenience") + else: + self.DisplayMessage( + f"No configuration for this {single_config.GetClassKey().capitalize()} :)") - self.ManageWarehouses() + def EditSinglePreset(self, q_preset: QuestionPreset, single_config: SingleConfiguration): + """ UI for changing a single setting """ - # 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 + try: + # Load config as default: + if single_config.HasConfigKey(q_preset.key): + if q_preset.default and q_preset.question_type != "yesno": + q_preset.instruction = f"Default: {q_preset.default}" + q_preset.default = single_config.GetConfigValue(q_preset.key) - def EditActiveEntity(self, entityConfig, ecm: ClassManager) -> None: - """ UI for single Entity settings edit """ - self.DisplayMessage( - "You can't do that at the moment, change the configuration file manually. Sorry for the inconvenience") + value = q_preset.Ask() - self.ManageEntities() + # If no default and not changed, do not save: + if value or q_preset.default is not None: + # Add to config: + single_config.UpdateConfigValue(q_preset.key, value) - # TODO Implement - - def RemoveActiveWarehouse(self, warehouseName) -> None: - """ Remove warehouse name from the list of active warehouses if present """ - for wh in self.config[KEY_ACTIVE_WAREHOUSES]: - if warehouseName == wh[KEY_WAREHOUSE_TYPE]: - # I remove this wh from the list - self.config[KEY_ACTIVE_WAREHOUSES].remove(wh) - - 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 """ - _dict = preset.GetDict() - _dict[KEY_WAREHOUSE_TYPE] = whName.replace("Warehouse", "") - self.config[KEY_ACTIVE_WAREHOUSES].append(_dict) - self.DisplayMessage("Configuration added for \""+whName+"\" :)") - - def EntityMenuPresetToConfiguration(self, entityName, preset) -> None: - """ Get a MenuPreset with responses and add the entries to the configurations dict in entity part """ - _dict = preset.GetDict() - _dict[KEY_ENTITY_TYPE] = entityName - self.config[KEY_ACTIVE_ENTITIES].append(_dict) - self.DisplayMessage("Configuration added for \""+entityName+"\" :)") + except UserCancelledException: + self.DisplayMessage("Configuration cancelled", force_clear=True) def ClearScreen(self, force_clear=False): """ Clear the screen on any platform. If self.pinned_lines greater than zero, it won't be cleared. diff --git a/IoTuring/Configurator/ConfiguratorLoader.py b/IoTuring/Configurator/ConfiguratorLoader.py index 152642eab..54f07d029 100644 --- a/IoTuring/Configurator/ConfiguratorLoader.py +++ b/IoTuring/Configurator/ConfiguratorLoader.py @@ -3,7 +3,7 @@ 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 Configurator from IoTuring.ClassManager.ClassManager import ClassManager, KEY_ENTITY, KEY_WAREHOUSE from IoTuring.Warehouse.Warehouse import Warehouse @@ -12,28 +12,28 @@ class ConfiguratorLoader(LogObject): configurator = None def __init__(self, configurator: Configurator) -> None: - self.configurations = configurator.GetConfigurations() + self.configurations = configurator.config # Return list of instances initialized using their configurations def LoadWarehouses(self) -> list[Warehouse]: warehouses = [] wcm = ClassManager(KEY_WAREHOUSE) - if not KEY_ACTIVE_WAREHOUSES in self.configurations: + if not self.configurations.GetConfigsOfClass(KEY_WAREHOUSE): self.Log( self.LOG_ERROR, "You have to enable at least one warehouse: configure it using -c argument") sys.exit("No warehouse enabled") - for activeWarehouse in self.configurations[KEY_ACTIVE_WAREHOUSES]: + for whConfig in self.configurations.GetConfigsOfClass(KEY_WAREHOUSE): # Get WareHouse named like in config type field, then init it with configs and add it to warehouses list - whClass = wcm.GetClassFromName( - activeWarehouse[KEY_WAREHOUSE_TYPE]+"Warehouse") + whClass = wcm.GetClassFromName(whConfig.GetLongName()) if whClass is None: - self.Log(self.LOG_ERROR, "Can't find " + - activeWarehouse[KEY_WAREHOUSE_TYPE] + " warehouse, check your configurations.") + self.Log( + self.LOG_ERROR, f"Can't find {whConfig.GetType} warehouse, check your configurations.") else: - whClass(activeWarehouse).AddMissingDefaultConfigs() - self.Log(self.LOG_DEBUG, f"Full configuration with defaults: {whClass(activeWarehouse).configurations}") - warehouses.append(whClass(activeWarehouse)) + wh = whClass(whConfig) + self.Log( + self.LOG_DEBUG, f"Full configuration with defaults: {wh.configurations.ToDict()}") + warehouses.append(wh) return warehouses # warehouses[0].AddEntity(eM.NewEntity(eM.EntityNameToClass("Username")).getInstance()): may be useful @@ -41,19 +41,20 @@ def LoadWarehouses(self) -> list[Warehouse]: def LoadEntities(self) -> list[Entity]: entities = [] ecm = ClassManager(KEY_ENTITY) - if not KEY_ACTIVE_ENTITIES in self.configurations: + if not self.configurations.GetConfigsOfClass(KEY_ENTITY): self.Log( self.LOG_ERROR, "You have to enable at least one entity: configure it using -c argument") sys.exit("No entity enabled") - for activeEntity in self.configurations[KEY_ACTIVE_ENTITIES]: - entityClass = ecm.GetClassFromName(activeEntity[KEY_ENTITY_TYPE]) + for entityConfig in self.configurations.GetConfigsOfClass(KEY_ENTITY): + entityClass = ecm.GetClassFromName(entityConfig.GetType()) if entityClass is None: - self.Log(self.LOG_ERROR, "Can't find " + - activeEntity[KEY_ENTITY_TYPE] + " entity, check your configurations.") + self.Log( + self.LOG_ERROR, f"Can't find {entityConfig.GetType()} entity, check your configurations.") else: - entityClass(activeEntity).AddMissingDefaultConfigs() - self.Log(self.LOG_DEBUG, f"Full configuration with defaults: {entityClass(activeEntity).configurations}") - entities.append(entityClass(activeEntity)) # Entity instance + ec = entityClass(entityConfig) + self.Log( + self.LOG_DEBUG, f"Full configuration with defaults: {ec.configurations.ToDict()}") + entities.append(ec) # Entity instance return entities # How Warehouse configurations works: diff --git a/IoTuring/Configurator/ConfiguratorObject.py b/IoTuring/Configurator/ConfiguratorObject.py index 6dfbf9b18..a308cfa25 100644 --- a/IoTuring/Configurator/ConfiguratorObject.py +++ b/IoTuring/Configurator/ConfiguratorObject.py @@ -1,21 +1,35 @@ -from IoTuring.Configurator.MenuPreset import BooleanAnswers -from IoTuring.Configurator.MenuPreset import MenuPreset +from IoTuring.Configurator.MenuPreset import BooleanAnswers, MenuPreset +from IoTuring.Configurator.Configuration import SingleConfiguration, CONFIG_CLASS class ConfiguratorObject: """ Base class for configurable classes """ + NAME = "Unnamed" + ALLOW_MULTI_INSTANCE = False - def __init__(self, configurations) -> None: - self.configurations = configurations + def __init__(self, single_configuration: SingleConfiguration) -> None: + self.configurations = single_configuration - def GetConfigurations(self) -> dict: - """ Safe return configurations dict """ - return self.configurations.copy() + # Add missing default values: + preset = self.ConfigurationPreset() + defaults = preset.GetDefaults() + + if defaults: + for default_key, default_value in defaults.items(): + if not self.GetConfigurations().HasConfigKey(default_key): + self.GetConfigurations().UpdateConfigValue(default_key, default_value) + + def GetConfigurations(self) -> SingleConfiguration: + """ Safe return single_configuration object """ + if self.configurations: + return self.configurations + else: + raise Exception(f"Configuration not loaded for {self.NAME}") def GetFromConfigurations(self, key): - """ Get value from confiugurations with key (if not present raise Exception) """ - if key in self.GetConfigurations(): - return self.GetConfigurations()[key] + """ Get value from confiugurations with key (if not present raise Exception).""" + if self.GetConfigurations().HasConfigKey(key): + return self.GetConfigurations().GetConfigValue(key) else: raise Exception("Can't find key " + key + " in configurations") @@ -27,17 +41,21 @@ def GetTrueOrFalseFromConfigurations(self, key) -> bool: else: return False - def AddMissingDefaultConfigs(self) -> None: - """ If some default values are missing add them to the running configuration""" - preset = self.ConfigurationPreset() - defaults = preset.GetDefaults() - - if defaults: - for default_key in defaults: - if default_key not in self.GetConfigurations(): - self.configurations[default_key] = defaults[default_key] - @classmethod def ConfigurationPreset(cls) -> MenuPreset: """ Prepare a preset to manage settings insert/edit for the warehouse or entity """ return MenuPreset() + + @classmethod + def AllowMultiInstance(cls): + """ Return True if this Entity can have multiple instances, useful for customizable entities + These entities are the ones that must have a tag to be recognized """ + return cls.ALLOW_MULTI_INSTANCE + + @classmethod + def GetClassKey(cls) -> str: + """Get the CLASS_KEY of this configuration, e.g. KEY_ENTITY, KEY_WAREHOUSE""" + class_key = cls.__bases__[0].__name__.lower() + if class_key not in CONFIG_CLASS: + raise Exception(f"Invalid class {class_key}") + return class_key diff --git a/IoTuring/Configurator/MenuPreset.py b/IoTuring/Configurator/MenuPreset.py index 72c709a03..3c671242c 100644 --- a/IoTuring/Configurator/MenuPreset.py +++ b/IoTuring/Configurator/MenuPreset.py @@ -28,11 +28,24 @@ def __init__(self, self.value = None self.question = self.name - if mandatory: + + # yesno question cannot be mandatory: + if self.question_type == "yesno": + self.mandatory = False + + # Add mandatory mark: + if self.mandatory: self.question += " {!}" - def ShouldDisplay(self, menupreset: "MenuPreset") -> bool: - """Check if this question should be displayed""" + def ShouldDisplay(self, answer_dict: dict) -> bool: + """Check if this question should be displayed + + Args: + answer_dict (dict): A dict of previous answers + + Returns: + bool: If this question should be displayed + """ should_display = True @@ -40,19 +53,16 @@ def ShouldDisplay(self, menupreset: "MenuPreset") -> bool: if self.dependsOn: dependencies_ok = [] for key, value in self.dependsOn.items(): - answered = menupreset.GetAnsweredPresetByKey(key) dependency_ok = False - - # Found the key in results: - if answered: + if key in answer_dict: # Value is True or False: if isinstance(value, bool): - if answered.value: + if answer_dict[key]: dependency_ok = True # Value must match: - elif isinstance(value, str) and answered.value == value: + elif isinstance(value, str) and answer_dict[key] == value: dependency_ok = True dependencies_ok.append(dependency_ok) @@ -63,6 +73,82 @@ def ShouldDisplay(self, menupreset: "MenuPreset") -> bool: return should_display + def Ask(self): + """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" + }) + + question_options["message"] = self.question + ":" + + if self.default is not None: + # yesno questions need boolean default: + if self.question_type == "yesno": + question_options["default"] = \ + bool(str(self.default).lower() + in BooleanAnswers.TRUE_ANSWERS) + elif self.question_type == "integer": + question_options["default"] = int(self.default) + else: + question_options["default"] = self.default + else: + if self.question_type == "integer": + # The default integer is 0, overwrite to None: + question_options["default"] = None + + # text: + prompt_function = inquirer.text + + 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 + }) + + elif self.question_type == "integer": + prompt_function = inquirer.number + question_options["float_allowed"] = False + + elif self.question_type == "filepath": + prompt_function = inquirer.filepath + + # Ask the question: + prompt = prompt_function( + instruction=self.instruction, + **question_options + ) + + self.cancelled = False + + @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 self.cancelled: + raise UserCancelledException + + return value + class MenuPreset(): @@ -130,77 +216,9 @@ def AskQuestions(self) -> None: 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" - }) - - question_options["message"] = q_preset.question + ":" - - if q_preset.default is not None: - # yesno questions need boolean default: - if q_preset.question_type == "yesno": - question_options["default"] = \ - bool(str(q_preset.default).lower() - in BooleanAnswers.TRUE_ANSWERS) - elif q_preset.question_type == "integer": - question_options["default"] = int(q_preset.default) - else: - question_options["default"] = q_preset.default - else: - if q_preset.question_type == "integer": - # The default default is 0, overwrite to None: - question_options["default"] = None - - # text: - prompt_function = inquirer.text - - 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 - }) - - elif q_preset.question_type == "integer": - prompt_function = inquirer.number - question_options["float_allowed"] = False - - elif q_preset.question_type == "filepath": - prompt_function = inquirer.filepath - - # Create the prompt: - prompt = prompt_function( - instruction=q_preset.instruction, - **question_options - ) - - # Handle escape keypress: - @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 self.cancelled: - raise UserCancelledException + if q_preset.ShouldDisplay(self.GetDict()): + + value = q_preset.Ask() if value: q_preset.value = value @@ -212,8 +230,9 @@ def _handle_esc(event): except Exception as e: print(f"Error while making question for {q_preset.name}:", 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: + """Get the QuestionPreset of this key. Returns None if not found""" + return next((entry for entry in self.presets if entry.key == key), None) def GetDict(self) -> dict: """ Get a dict with keys and responses""" diff --git a/IoTuring/Entity/Entity.py b/IoTuring/Entity/Entity.py index 94505945d..1ee44d46d 100644 --- a/IoTuring/Entity/Entity.py +++ b/IoTuring/Entity/Entity.py @@ -3,27 +3,25 @@ import subprocess from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject +from IoTuring.Configurator.Configuration import SingleConfiguration from IoTuring.Exceptions.Exceptions import UnknownEntityKeyException from IoTuring.Logger.LogObject import LogObject from IoTuring.Entity.EntityData import EntityData, EntitySensor, EntityCommand, ExtraAttribute from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD -KEY_ENTITY_TAG = 'tag' # from Configurator.Configurator - DEFAULT_UPDATE_TIMEOUT = 10 -class Entity(LogObject, ConfiguratorObject): - NAME = "Unnamed" - ALLOW_MULTI_INSTANCE = False +class Entity(ConfiguratorObject, LogObject): + + def __init__(self, single_configuration: SingleConfiguration) -> None: + super().__init__(single_configuration) - def __init__(self, configurations) -> None: # Prepare the entity self.entitySensors = [] self.entityCommands = [] - self.configurations = configurations - self.SetTagFromConfiguration() + self.tag = self.GetConfigurations().GetTag() # When I update the values this number changes (randomly) so each warehouse knows I have updated self.valuesID = 0 @@ -145,7 +143,7 @@ def GetEntityName(self) -> str: def GetEntityTag(self) -> str: """ Return entity identifier tag """ - return self.tag # Set from SetTagFromConfiguration on entity init + return self.tag def GetEntityNameWithTag(self) -> str: """ Return entity name and tag combined (or name alone if no tag is present) """ @@ -163,13 +161,6 @@ def GetEntityId(self) -> str: def LogSource(self): return self.GetEntityId() - def SetTagFromConfiguration(self): - """ Set tag from configuration or set it blank if not present there """ - if self.GetConfigurations() is not None and KEY_ENTITY_TAG in self.GetConfigurations(): - self.tag = self.GetFromConfigurations(KEY_ENTITY_TAG) - else: - self.tag = "" - def RunCommand(self, command: str | list, command_name: str = "", @@ -226,12 +217,6 @@ def RunCommand(self, return p - @classmethod - def AllowMultiInstance(cls): - """ Return True if this Entity can have multiple instances, useful for customizable entities - These entities are the ones that must have a tag to be recognized """ - return cls.ALLOW_MULTI_INSTANCE - @classmethod def CheckSystemSupport(cls): """Must be implemented in subclasses. Raise an exception if system not supported.""" diff --git a/IoTuring/Warehouse/Warehouse.py b/IoTuring/Warehouse/Warehouse.py index 82a84855e..1acd5bb19 100644 --- a/IoTuring/Warehouse/Warehouse.py +++ b/IoTuring/Warehouse/Warehouse.py @@ -2,6 +2,7 @@ from IoTuring.Entity.Entity import Entity from IoTuring.Logger.LogObject import LogObject from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject +from IoTuring.Configurator.Configuration import SingleConfiguration from IoTuring.Entity.EntityManager import EntityManager from threading import Thread @@ -10,12 +11,12 @@ DEFAULT_LOOP_TIMEOUT = 10 -class Warehouse(LogObject, ConfiguratorObject): - NAME = "Unnamed" +class Warehouse(ConfiguratorObject, LogObject): + + def __init__(self, single_configuration: SingleConfiguration) -> None: + super().__init__(single_configuration) - def __init__(self, configurations) -> None: self.loopTimeout = DEFAULT_LOOP_TIMEOUT - self.configurations = configurations def Start(self) -> None: """ Initial configuration and start the thread that will loop the Warehouse.Loop() function""" @@ -35,7 +36,7 @@ def ShouldCallLoop(self) -> bool: def LoopThread(self) -> None: """ Entry point of the warehouse thread, will run Loop() periodically """ self.Loop() # First call without sleep before - while(True): + while (True): if self.ShouldCallLoop(): self.Loop() @@ -56,4 +57,3 @@ def GetWarehouseId(self) -> str: def LogSource(self): return self.GetWarehouseId() -