├── .dockerignore ├── .github └── workflows │ ├── build-release-publish-with-vtag.yml │ ├── pytest.yml │ └── repostats.yml ├── .gitignore ├── COPYING ├── Dockerfile ├── IoTuring ├── ClassManager │ ├── ClassManager.py │ └── consts.py ├── Configurator │ ├── Configuration.py │ ├── Configurator.py │ ├── ConfiguratorIO.py │ ├── ConfiguratorLoader.py │ ├── ConfiguratorObject.py │ ├── MenuPreset.py │ └── messages.py ├── Entity │ ├── Deployments │ │ ├── ActiveWindow │ │ │ └── ActiveWindow.py │ │ ├── AppInfo │ │ │ └── AppInfo.py │ │ ├── Battery │ │ │ └── Battery.py │ │ ├── BootTime │ │ │ └── BootTime.py │ │ ├── Cpu │ │ │ └── Cpu.py │ │ ├── DesktopEnvironment │ │ │ └── DesktopEnvironment.py │ │ ├── Disk │ │ │ └── Disk.py │ │ ├── DisplayMode │ │ │ └── DisplayMode.py │ │ ├── Fanspeed │ │ │ └── Fanspeed.py │ │ ├── FileSwitch │ │ │ └── FileSwitch.py │ │ ├── Hostname │ │ │ └── Hostname.py │ │ ├── Lock │ │ │ └── Lock.py │ │ ├── Monitor │ │ │ └── Monitor.py │ │ ├── Notify │ │ │ ├── Notify.py │ │ │ └── icon.png │ │ ├── OperatingSystem │ │ │ └── OperatingSystem.py │ │ ├── Power │ │ │ └── Power.py │ │ ├── Ram │ │ │ └── Ram.py │ │ ├── Temperature │ │ │ └── Temperature.py │ │ ├── Terminal │ │ │ └── Terminal.py │ │ ├── Time │ │ │ └── Time.py │ │ ├── UpTime │ │ │ └── UpTime.py │ │ ├── Username │ │ │ └── Username.py │ │ ├── Volume │ │ │ └── Volume.py │ │ └── Wifi │ │ │ └── Wifi.py │ ├── Entity.py │ ├── EntityData.py │ ├── EntityManager.py │ ├── ToImplement │ │ ├── Brightness │ │ │ ├── Brightness.py │ │ │ └── settings.yaml │ │ ├── CpuTemperature │ │ │ ├── CpuTemperatures.py │ │ │ └── settings.yaml │ │ ├── Network │ │ │ ├── Network.py │ │ │ └── settings.yaml │ │ ├── Screenshot │ │ │ ├── Screenshot.py │ │ │ └── settings.yaml │ │ └── State │ │ │ ├── State.py │ │ │ └── settings.yaml │ └── ValueFormat │ │ ├── ValueFormatter.py │ │ ├── ValueFormatterOptions.py │ │ └── __init__.py ├── Exceptions │ └── Exceptions.py ├── Logger │ ├── Colors.py │ ├── LogLevel.py │ ├── LogObject.py │ ├── Logger.py │ └── consts.py ├── MyApp │ ├── App.py │ └── SystemConsts │ │ ├── DesktopEnvironmentDetection.py │ │ ├── OperatingSystemDetection.py │ │ ├── TerminalDetection.py │ │ └── __init__.py ├── Protocols │ └── MQTTClient │ │ ├── MQTTClient.py │ │ └── TopicCallback.py ├── Settings │ ├── Deployments │ │ ├── AppSettings │ │ │ └── AppSettings.py │ │ └── LogSettings │ │ │ └── LogSettings.py │ ├── Settings.py │ └── SettingsManager.py ├── Warehouse │ ├── Deployments │ │ ├── ConsoleWarehouse │ │ │ └── ConsoleWarehouse.py │ │ ├── HomeAssistantWarehouse │ │ │ ├── HomeAssistantWarehouse.py │ │ │ └── entities.yaml │ │ └── MQTTWarehouse │ │ │ └── MQTTWarehouse.py │ ├── README.md │ └── Warehouse.py ├── __init__.py └── __main__.py ├── README.md ├── docker-compose.yaml ├── docs ├── Autostart tips.md ├── DEVELOPMENT.md ├── IoTuring-restart.service ├── IoTuring.service └── images │ ├── device_info.png │ ├── homeassistant-demo.gif │ ├── linux.png │ ├── mac.png │ └── win.png ├── pyproject.toml ├── tests.Dockerfile └── tests ├── ClassManager └── test_ClassManager.py └── Entity └── Deployments └── Temperature └── test_Temperature.py /.dockerignore: -------------------------------------------------------------------------------- 1 | */*/*.pyc 2 | IoTuring/Logger/Logs 3 | IoTuring/Configurator/configurations.json* 4 | IoTuring/Configurator/dontmoveconf.itg* 5 | commands_topic.txt 6 | .vscode 7 | .venv 8 | build 9 | docs 10 | */*/.DS_Store 11 | IoTuring.egg-info 12 | .pytest_cache 13 | docker-compose.yaml -------------------------------------------------------------------------------- /.github/workflows/build-release-publish-with-vtag.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Build, release and publish" 3 | 4 | on: 5 | push: 6 | tags: 7 | - "v*" 8 | 9 | jobs: 10 | tagged-release: 11 | name: "Tagged Release" 12 | runs-on: "ubuntu-latest" 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Python 17 | uses: actions/setup-python@v3 18 | with: 19 | python-version: '3.8' 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install build 24 | - name: Build package 25 | run: python -m build 26 | - uses: "marvinpinto/action-automatic-releases@latest" 27 | with: 28 | repo_token: "${{ secrets.OWNER_GH_API_TOKEN }}" 29 | prerelease: false 30 | files: dist/* 31 | - name: Publish package 32 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 33 | with: 34 | user: __token__ 35 | password: ${{ secrets.PYPI_API_TOKEN }} 36 | 37 | build-docker: 38 | name: "Build Docker" 39 | needs: tagged-release 40 | runs-on: "ubuntu-latest" 41 | 42 | steps: 43 | - uses: actions/checkout@v3 44 | name: Check out code 45 | 46 | - uses: mr-smithers-excellent/docker-build-push@v6 47 | name: Build & push Docker image 48 | with: 49 | image: ioturing 50 | addLatest: true 51 | registry: ghcr.io 52 | username: ${{ github.actor }} 53 | password: ${{ secrets.GITHUB_TOKEN }} 54 | - uses: mr-smithers-excellent/docker-build-push@v6 55 | with: 56 | image: richibrics/ioturing 57 | addLatest: true 58 | registry: docker.io 59 | username: ${{ secrets.DOCKER_USERNAME }} 60 | password: ${{ secrets.DOCKER_PASSWORD }} 61 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Test" 3 | 4 | on: 5 | pull_request: 6 | workflow_dispatch: 7 | push: 8 | 9 | jobs: 10 | pytest: 11 | name: "Test" 12 | runs-on: "ubuntu-latest" 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Python 17 | uses: actions/setup-python@v3 18 | with: 19 | python-version: '3.8' 20 | - name: Install linux dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install .[test] 24 | - name: Test 25 | run: python -m pytest 26 | -------------------------------------------------------------------------------- /.github/workflows/repostats.yml: -------------------------------------------------------------------------------- 1 | on: 2 | schedule: 3 | # Run this once per day, towards the end of the day for keeping the most 4 | # recent data point most meaningful (hours are interpreted in UTC). 5 | - cron: "50 23 * * *" 6 | workflow_dispatch: # Allow for running this manually. 7 | 8 | jobs: 9 | j1: 10 | name: repostats-for-nice-project 11 | if: github.repository == 'richibrics/IoTuring' 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: run-ghrs 15 | uses: jgehrcke/github-repo-stats@RELEASE 16 | with: 17 | ghtoken: ${{ secrets.ghrs_github_api_token }} 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | IoTuring/Logger/Logs 3 | nogit.* 4 | commands_topic.txt 5 | .vscode 6 | .DS_Store 7 | IoTuring/Configurator/configurations.json* 8 | IoTuring/Configurator/dontmoveconf.itg* 9 | .venv 10 | build 11 | *.egg-info 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | 3 | WORKDIR /root/IoTuring 4 | 5 | COPY . . 6 | 7 | RUN pip install --no-cache-dir . 8 | 9 | ARG IOTURING_CONFIG_DIR=/config 10 | ENV IOTURING_CONFIG_DIR=${IOTURING_CONFIG_DIR} 11 | 12 | VOLUME ${IOTURING_CONFIG_DIR} 13 | 14 | CMD ["IoTuring"] -------------------------------------------------------------------------------- /IoTuring/ClassManager/ClassManager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import importlib.util 4 | import importlib.machinery 5 | import sys 6 | import inspect 7 | from pathlib import Path 8 | 9 | from IoTuring.ClassManager.consts import * 10 | from IoTuring.Logger.LogObject import LogObject 11 | from IoTuring.MyApp.App import App 12 | 13 | 14 | class ClassManager(LogObject): 15 | """Base class for ClassManagers 16 | 17 | This class is used to find and load classes without importing them 18 | The important this is that the class is inside a folder that exactly the same name of the Class and of the file (obviously not talking about extensions) 19 | """ 20 | 21 | def __init__(self, class_key:str) -> None: 22 | 23 | if class_key not in CLASS_PATH: 24 | raise Exception(f"Invalid class key {class_key}") 25 | else: 26 | self.classesRelativePath = CLASS_PATH[class_key] 27 | 28 | # Store loaded classes here: 29 | self.loadedClasses = [] 30 | 31 | # Collect paths 32 | self.moduleFilePaths = self.GetModuleFilePaths() 33 | 34 | def GetModuleFilePaths(self) -> list[Path]: 35 | """Get the paths of of python files of this class 36 | 37 | Raises: 38 | Exception: If path not defined or exists 39 | FileNotFoundError: No module in the dir 40 | 41 | Returns: 42 | list[Path]: List of paths of python files 43 | """ 44 | 45 | if not self.classesRelativePath: 46 | raise Exception("Path to deployments not defined") 47 | 48 | # Get the absolute path of the dir of files: 49 | classesRootPath = App.getRootPath().joinpath(self.classesRelativePath) 50 | 51 | if not classesRootPath.exists: 52 | raise Exception(f"Path does not exist: {classesRootPath}") 53 | 54 | self.Log(self.LOG_DEBUG, 55 | f'Looking for python files in "{classesRootPath}"...') 56 | 57 | python_files = classesRootPath.rglob("*.py") 58 | 59 | # Check if a py files are in a folder with the same name !!! (same without extension) 60 | filepaths = [f for f in python_files if f.stem == f.parent.stem] 61 | 62 | if not filepaths: 63 | raise FileNotFoundError( 64 | f"No module files found in {classesRootPath}") 65 | 66 | self.Log(self.LOG_DEBUG, 67 | f"Found {str(len(filepaths))} modules files") 68 | 69 | return filepaths 70 | 71 | def GetClassFromName(self, wantedName: str) -> type | None: 72 | """Get the class of given name, and load it 73 | 74 | Args: 75 | wantedName (str): The name to look for 76 | 77 | Returns: 78 | type | None: The class if found, None if not found 79 | """ 80 | 81 | # Check from already loaded classes: 82 | module_class = next( 83 | (m for m in self.loadedClasses if m.__name__ == wantedName), None) 84 | 85 | if module_class: 86 | return module_class 87 | 88 | modulePath = next( 89 | (m for m in self.moduleFilePaths if m.stem == wantedName), None) 90 | 91 | if modulePath: 92 | 93 | loadedModule = self.LoadModule(modulePath) 94 | loadedClass = self.GetClassFromModule(loadedModule) 95 | self.loadedClasses.append(loadedClass) 96 | return loadedClass 97 | 98 | else: 99 | return None 100 | 101 | def LoadModule(self, module_path: Path): # Get module and load it from the path 102 | try: 103 | loader = importlib.machinery.SourceFileLoader( 104 | module_path.stem, str(module_path)) 105 | spec = importlib.util.spec_from_loader(loader.name, loader) 106 | 107 | if not spec: 108 | raise Exception("Spec not found") 109 | 110 | module = importlib.util.module_from_spec(spec) 111 | loader.exec_module(module) 112 | sys.modules[module_path.stem] = module 113 | return module 114 | except Exception as e: 115 | self.Log(self.LOG_ERROR, 116 | f"Error while loading module {module_path.stem}: {str(e)}") 117 | 118 | # From the module passed, I search for a Class that has className=moduleName 119 | def GetClassFromModule(self, module): 120 | for name, obj in inspect.getmembers(module): 121 | if inspect.isclass(obj): 122 | if (name == module.__name__): 123 | return obj 124 | raise Exception(f"No class found: {module.__name__}") 125 | 126 | def ListAvailableClasses(self) -> list: 127 | """Get all classes of this ClassManager 128 | 129 | Returns: 130 | list: The list of classes 131 | """ 132 | 133 | return [self.GetClassFromName(f.stem) for f in self.moduleFilePaths] 134 | -------------------------------------------------------------------------------- /IoTuring/ClassManager/consts.py: -------------------------------------------------------------------------------- 1 | # Class keys: 2 | KEY_ENTITY = "entity" 3 | KEY_WAREHOUSE = "warehouse" 4 | KEY_SETTINGS = "settings" 5 | 6 | CLASS_PATH = { 7 | KEY_ENTITY: "Entity/Deployments", 8 | KEY_WAREHOUSE: "Warehouse/Deployments", 9 | KEY_SETTINGS: "Settings/Deployments" 10 | } 11 | -------------------------------------------------------------------------------- /IoTuring/Configurator/Configuration.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from IoTuring.ClassManager.consts import KEY_ENTITY, KEY_WAREHOUSE, KEY_SETTINGS 4 | from IoTuring.Exceptions.Exceptions import UnknownConfigKeyException 5 | 6 | CONFIG_CLASS = { 7 | KEY_ENTITY: "active_entities", 8 | KEY_WAREHOUSE: "active_warehouses", 9 | KEY_SETTINGS: "settings" 10 | } 11 | 12 | BLANK_CONFIGURATION = { 13 | CONFIG_CLASS[KEY_ENTITY]: [{"type": "AppInfo"}] 14 | } 15 | 16 | 17 | CONFIG_KEY_TAG = "tag" 18 | CONFIG_KEY_TYPE = "type" 19 | 20 | 21 | class SingleConfiguration: 22 | """Single configuration of an entity or warehouse""" 23 | 24 | def __init__(self, config_class: str, config_dict: dict) -> None: 25 | """Create a new SingleConfiguration 26 | 27 | Args: 28 | config_class (str): CONFIG_CLASS of the config 29 | config_dict (dict): All options as in config file 30 | """ 31 | self.config_class = config_class 32 | self.config_type = config_dict.pop(CONFIG_KEY_TYPE) 33 | self.configurations = config_dict 34 | 35 | def GetType(self) -> str: 36 | """Get the type name of entity or warehouse (e.g. Cpu, Battery, HomeAssistant)""" 37 | return self.config_type 38 | 39 | def GetTag(self) -> str: 40 | """Get the tag of entity""" 41 | if CONFIG_KEY_TAG in self.configurations: 42 | return self.configurations[CONFIG_KEY_TAG] 43 | else: 44 | return "" 45 | 46 | def GetLabel(self) -> str: 47 | """Get the type name of this configuration, add tag if multi""" 48 | 49 | label = self.GetType() 50 | 51 | if self.GetTag(): 52 | label += f" with tag {self.GetTag()}" 53 | 54 | return label 55 | 56 | def GetLongName(self) -> str: 57 | """ Get the type with the category name at the end (e.g. CpuEntity, HomeAssistantWarehouse)""" 58 | 59 | # Add category name to the end 60 | return str(self.GetType() + self.GetClassKey().capitalize()) 61 | 62 | def GetClassKey(self) -> str: 63 | """Get the CLASS_KEY of this configuration, e.g. KEY_ENTITY, KEY_WAREHOUSE""" 64 | return [i for i in CONFIG_CLASS if CONFIG_CLASS[i] == self.config_class][0] 65 | 66 | def GetConfigValue(self, config_key: str): 67 | """Get the value of a config key 68 | 69 | Args: 70 | config_key (str): The key of the configuration 71 | 72 | Raises: 73 | UnknownConfigKeyException: If the key is not found 74 | 75 | Returns: 76 | The value of the key. 77 | """ 78 | if config_key in self.configurations: 79 | return self.configurations[config_key] 80 | else: 81 | raise UnknownConfigKeyException(config_key) 82 | 83 | def UpdateConfigValue(self, config_key: str, config_value: str) -> None: 84 | """Update the value of the configuration. Overwrites existing value 85 | 86 | Args: 87 | config_key (str): The key of the configuration 88 | config_value (str): The preferred value 89 | """ 90 | self.configurations[config_key] = config_value 91 | 92 | def HasConfigKey(self, config_key: str) -> bool: 93 | """Check if key has a value 94 | 95 | Args: 96 | config_key (str): The key of the configuration 97 | 98 | Returns: 99 | bool: If it has a value 100 | """ 101 | return bool(config_key in self.configurations) 102 | 103 | def ToDict(self, include_type: bool = True) -> dict: 104 | """ SingleConfiguration as a dict, as it would be saved to a file """ 105 | full_dict = self.configurations 106 | if include_type: 107 | full_dict[CONFIG_KEY_TYPE] = self.GetType() 108 | return full_dict 109 | 110 | 111 | class FullConfiguration: 112 | """Full configuration of all classes""" 113 | 114 | def __init__(self, config_dict: dict | None) -> None: 115 | """Initialize from dict or create blank 116 | 117 | Args: 118 | config_dict (dict | None): The config as dict or None to create blank 119 | """ 120 | 121 | config_dict = config_dict or BLANK_CONFIGURATION 122 | self.configs = [] 123 | 124 | for config_class, single_configs in config_dict.items(): 125 | for single_config_dict in single_configs: 126 | 127 | self.configs.append(SingleConfiguration( 128 | config_class, single_config_dict)) 129 | 130 | def GetConfigsOfClass(self, class_key: str) -> list[SingleConfiguration]: 131 | """Return all configurations of class 132 | 133 | Args: 134 | class_key (str): CLASS_KEY of the class: e.g. KEY_ENTITY, KEY_WAREHOUSE 135 | 136 | Returns: 137 | list: Configurations in the class. Empty list if none found. 138 | """ 139 | return [config for config in self.configs if config.config_class == CONFIG_CLASS[class_key]] 140 | 141 | def GetConfigsOfType(self, config_type: str) -> list[SingleConfiguration]: 142 | """Return all configs with the given type. 143 | 144 | For example all configurations of Notify entity. 145 | 146 | Args: 147 | config_type (str): The type of config to return 148 | 149 | Returns: 150 | list: Configurations of the given type. Empty list if none found. 151 | """ 152 | 153 | return [config for config in self.configs if config.GetType() == config_type] 154 | 155 | def RemoveActiveConfiguration(self, config: SingleConfiguration) -> None: 156 | """Remove a configuration from the list of active configurations""" 157 | if config in self.configs: 158 | self.configs.remove(config) 159 | else: 160 | raise ValueError("Configuration not found") 161 | 162 | def AddConfiguration(self, class_key: str, config_type: str, single_config_dict: dict) -> None: 163 | """Add a new configuration to the list of active configurations 164 | 165 | Args: 166 | class_key (str): CLASS_KEY of the class: e.g. KEY_ENTITY, KEY_WAREHOUSE 167 | config_type (str): The type of the configuration 168 | single_config_dict (dict): all settings as a dict 169 | """ 170 | 171 | config_class = CONFIG_CLASS[class_key] 172 | single_config_dict[CONFIG_KEY_TYPE] = config_type 173 | 174 | self.configs.append(SingleConfiguration( 175 | config_class, single_config_dict)) 176 | 177 | def ToDict(self) -> dict: 178 | """Full configuration as a dict, for saving to file """ 179 | config_dict = {} 180 | for class_key in CONFIG_CLASS: 181 | 182 | config_dict[CONFIG_CLASS[class_key]] = [] 183 | 184 | for single_config in self.GetConfigsOfClass(class_key): 185 | config_dict[CONFIG_CLASS[class_key]].append( 186 | single_config.ToDict()) 187 | 188 | return config_dict 189 | -------------------------------------------------------------------------------- /IoTuring/Configurator/ConfiguratorIO.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | import inspect 4 | import sys 5 | 6 | from IoTuring.Logger.LogObject import LogObject 7 | from IoTuring.MyApp.App import App # App name 8 | from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD 9 | 10 | # macOS dep (in PyObjC) 11 | try: 12 | from AppKit import * # type:ignore 13 | from Foundation import * # type:ignore 14 | macos_support = True 15 | except: 16 | macos_support = False 17 | 18 | CONFIG_PATH_ENV_VAR = "IOTURING_CONFIG_DIR" 19 | 20 | CONFIGURATION_FILE_NAME = "configurations.json" 21 | 22 | # read ConfiguratorIO.checkConfigurationFileInOldLocation for more information 23 | DONT_MOVE_FILE_FILENAME = "dontmoveconf.itg" 24 | 25 | 26 | class ConfiguratorIO(LogObject): 27 | def __init__(self): 28 | self.directoryName = App.getName() 29 | 30 | def readConfigurations(self): 31 | """ Returns configurations dictionary. If does not exist the file where it should be stored, return None. """ 32 | config = None 33 | try: 34 | with open(self.getFilePath(), "r", encoding="utf-8") as f: 35 | config = json.loads(f.read()) 36 | self.Log(self.LOG_INFO, f"Loaded \"{self.getFilePath()}\"") 37 | except FileNotFoundError: 38 | self.Log(self.LOG_WARNING, f"It seems you don't have a configuration yet. Use configuration mode (-c) to enable your favourite entities and warehouses.\ 39 | \nConfigurations will be saved in \"{str(self.getFolderPath())}\"") 40 | except Exception as e: 41 | self.Log(self.LOG_ERROR, f"Error opening configuration file: {str(e)}") 42 | sys.exit(str(e)) 43 | return config 44 | 45 | def writeConfigurations(self, data): 46 | """ Writes configuration data in its file """ 47 | try: 48 | self.createFolderPathIfDoesNotExist() 49 | with open(self.getFilePath(), "w", encoding="utf-8") as f: 50 | f.write(json.dumps(data, indent=4, ensure_ascii=False)) 51 | self.Log(self.LOG_INFO, f"Saved \"{str(self.getFilePath())}\"") 52 | except Exception as e: 53 | self.Log(self.LOG_ERROR, f"Error saving configuration file: {str(e)}") 54 | sys.exit(str(e)) 55 | 56 | def checkConfigurationFileExists(self) -> bool: 57 | """ Returns True if the configuration file exists in the correct folder, False otherwise. """ 58 | try: 59 | return self.getFilePath().exists() and self.getFilePath().is_file() 60 | except: 61 | return False 62 | 63 | def getFilePath(self) -> Path: 64 | """ Returns the path to the configurations file. """ 65 | return self.getFolderPath().joinpath(CONFIGURATION_FILE_NAME) 66 | 67 | def createFolderPathIfDoesNotExist(self): 68 | """ Check if file exists, if not check if path exists: if not create both folder and file otherwise just the file """ 69 | if not self.getFolderPath().exists(): 70 | self.getFolderPath().mkdir(parents=True) 71 | 72 | def getFolderPath(self) -> Path: 73 | """ Returns the path to the configurations file. If the directory where the file 74 | will be stored doesn't exist, it will be created. """ 75 | 76 | folderPath = self.defaultFolderPath() 77 | try: 78 | # Use path from environment variable if present, otherwise os specific folders, otherwise use default path 79 | envvarPath = OsD.GetEnv(CONFIG_PATH_ENV_VAR) 80 | if envvarPath and len(envvarPath) > 0: 81 | folderPath = Path(envvarPath) 82 | else: 83 | if OsD.IsMacos() and macos_support: 84 | folderPath = self.macOSFolderPath().joinpath(self.directoryName) 85 | elif OsD.IsWindows(): 86 | folderPath = self.windowsFolderPath().joinpath(self.directoryName) 87 | elif OsD.IsLinux(): 88 | folderPath = self.linuxFolderPath().joinpath(self.directoryName) 89 | 90 | except: 91 | pass # default folder path will be used 92 | 93 | return folderPath 94 | 95 | def defaultFolderPath(self) -> Path: 96 | return Path(inspect.getfile(ConfiguratorIO)).parent 97 | 98 | # https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/MacOSXDirectories/MacOSXDirectories.html 99 | def macOSFolderPath(self) -> Path: 100 | paths = NSSearchPathForDirectoriesInDomains( # type: ignore 101 | NSApplicationSupportDirectory, NSUserDomainMask, True) # type: ignore 102 | basePath = (len(paths) > 0 and paths[0]) \ 103 | or NSTemporaryDirectory() # type: ignore 104 | return Path(basePath) 105 | 106 | # https://docs.microsoft.com/en-us/windows/win32/shell/knownfolderid 107 | def windowsFolderPath(self) -> Path: 108 | return Path(OsD.GetEnv("APPDATA")) 109 | 110 | # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html 111 | def linuxFolderPath(self) -> Path: 112 | if OsD.GetEnv("XDG_CONFIG_HOME"): 113 | path = Path(OsD.GetEnv("XDG_CONFIG_HOME")) 114 | else: 115 | path = Path(OsD.GetEnv("HOME")).joinpath(".config") 116 | return path 117 | 118 | # In versions prior to 2022.12.2, the configurations file was stored in the same folder as this file: 119 | def oldFolderPath(self) -> Path: 120 | return self.defaultFolderPath() 121 | 122 | def shouldMoveOldConfig(self) -> bool: 123 | """ Checks if a config file is in the old folder path and should be moved to the new location. 124 | 125 | Returns: 126 | bool: True if the config should be moved. False if old path = current chosen path or should not be moved 127 | """ 128 | 129 | # This check is not done if old path = current chosen path: 130 | if self.oldFolderPath() == self.getFolderPath(): 131 | return False 132 | 133 | # Old config does not exist: 134 | if not self.oldFolderPath().joinpath(CONFIGURATION_FILE_NAME).exists(): 135 | return False 136 | # Old config and dont move file exists: 137 | elif self.oldFolderPath().joinpath(DONT_MOVE_FILE_FILENAME).exists(): 138 | return False 139 | 140 | return True 141 | 142 | def manageOldConfig(self, moveFile: bool) -> None: 143 | """Move config file from old location, or create dontmove file 144 | 145 | Args: 146 | moveFile (bool): True: move the file. False: Create dontmove file 147 | """ 148 | 149 | try: 150 | if moveFile: 151 | # create folder if not exists 152 | self.createFolderPathIfDoesNotExist() 153 | # copy file from old to new location 154 | self.oldFolderPath().joinpath(CONFIGURATION_FILE_NAME).rename(self.getFilePath()) 155 | self.Log(self.LOG_INFO, 156 | f"Copied to \"{str(self.getFilePath())}\"") 157 | else: 158 | # create dont move file 159 | with open(self.oldFolderPath().joinpath(DONT_MOVE_FILE_FILENAME), "w") as f: 160 | f.write(" ".join([ 161 | "This file is here to remember you that you don't want to move the configuration file into the new location.", 162 | "If you want to move it, delete this file and run the script in -c mode." 163 | ])) 164 | self.Log(self.LOG_INFO, " ".join([ 165 | "You won't be asked again. A new blank configuration will be used;", 166 | f"if you want to move the existing configuration file, delete \"{self.oldFolderPath().joinpath(DONT_MOVE_FILE_FILENAME)}", 167 | "and run the script in -c mode." 168 | ])) 169 | except Exception as e: 170 | self.Log(self.LOG_ERROR, f"Error saving file: {str(e)}") 171 | sys.exit(str(e)) 172 | -------------------------------------------------------------------------------- /IoTuring/Configurator/ConfiguratorLoader.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | if TYPE_CHECKING: 4 | from IoTuring.Entity.Entity import Entity 5 | from IoTuring.Warehouse.Warehouse import Warehouse 6 | from IoTuring.Settings.Settings import Settings 7 | 8 | import sys 9 | 10 | from IoTuring.Logger.LogObject import LogObject 11 | from IoTuring.Configurator.Configurator import Configurator 12 | from IoTuring.ClassManager.ClassManager import ClassManager, KEY_ENTITY, KEY_WAREHOUSE, KEY_SETTINGS 13 | 14 | 15 | class ConfiguratorLoader(LogObject): 16 | 17 | def __init__(self, configurator: Configurator) -> None: 18 | self.configurations = configurator.config 19 | 20 | # Return list of instances initialized using their configurations 21 | def LoadWarehouses(self) -> list[Warehouse]: 22 | warehouses = [] 23 | wcm = ClassManager(KEY_WAREHOUSE) 24 | if not self.configurations.GetConfigsOfClass(KEY_WAREHOUSE): 25 | self.Log( 26 | self.LOG_ERROR, "You have to enable at least one warehouse: configure it using -c argument") 27 | sys.exit("No warehouse enabled") 28 | for whConfig in self.configurations.GetConfigsOfClass(KEY_WAREHOUSE): 29 | # Get WareHouse named like in config type field, then init it with configs and add it to warehouses list 30 | whClass = wcm.GetClassFromName(whConfig.GetLongName()) 31 | 32 | if whClass is None: 33 | self.Log( 34 | self.LOG_ERROR, f"Can't find {whConfig.GetType} warehouse, check your configurations.") 35 | else: 36 | wh = whClass(whConfig) 37 | self.Log( 38 | self.LOG_DEBUG, f"Full configuration with defaults: {wh.configurations.ToDict()}") 39 | warehouses.append(wh) 40 | return warehouses 41 | 42 | # warehouses[0].AddEntity(eM.NewEntity(eM.EntityNameToClass("Username")).getInstance()): may be useful 43 | # Return list of entities initialized 44 | def LoadEntities(self) -> list[Entity]: 45 | entities = [] 46 | ecm = ClassManager(KEY_ENTITY) 47 | if not self.configurations.GetConfigsOfClass(KEY_ENTITY): 48 | self.Log( 49 | self.LOG_ERROR, "You have to enable at least one entity: configure it using -c argument") 50 | sys.exit("No entity enabled") 51 | for entityConfig in self.configurations.GetConfigsOfClass(KEY_ENTITY): 52 | entityClass = ecm.GetClassFromName(entityConfig.GetType()) 53 | if entityClass is None: 54 | self.Log( 55 | self.LOG_ERROR, f"Can't find {entityConfig.GetType()} entity, check your configurations.") 56 | else: 57 | ec = entityClass(entityConfig) 58 | self.Log( 59 | self.LOG_DEBUG, f"Full configuration with defaults: {ec.configurations.ToDict()}") 60 | entities.append(ec) # Entity instance 61 | return entities 62 | 63 | # How Warehouse configurations works: 64 | # - in Main I have a Configurator() 65 | # - then I create a ConfiguratorLoader(), passing the Configurator() 66 | # - the CL reads the configuration for each active Warehouse 67 | # - for each one: 68 | # - pass the configuration to the warehouse function that uses the configuration to init the Warehouse 69 | # - append the Warehouse to the list 70 | 71 | def LoadSettings(self, early_init:bool = False) -> list[Settings]: 72 | """ Load all Settings classes 73 | 74 | Args: 75 | early_init (bool, optional): True when loaded before configurator menu, False when added to SettingsManager. Defaults to False. 76 | 77 | Returns: 78 | list[Settings]: Loaded classes 79 | """ 80 | 81 | settings = [] 82 | scm = ClassManager(KEY_SETTINGS) 83 | settingsClasses = scm.ListAvailableClasses() 84 | 85 | for sClass in settingsClasses: 86 | 87 | # Check if config was saved: 88 | savedConfigs = self.configurations\ 89 | .GetConfigsOfType(sClass.NAME) 90 | 91 | if savedConfigs: 92 | sc = sClass(savedConfigs[0], early_init) 93 | 94 | # Fallback to default: 95 | else: 96 | sc = sClass(sClass.GetDefaultConfigurations(), early_init) 97 | 98 | settings.append(sc) 99 | return settings 100 | 101 | # How Settings configurations work: 102 | # - Settings' configs are added to SettingsManager singleton on main __init__ 103 | # - For advanced usage custom loading could be set up in the class' __init()__ 104 | # - Setting classes has classmethods to get their configs from SettingsManager Singleton, 105 | # SettingsManager shouldn't be called directly, e.g: 106 | # AppSettings.GetFromSettingsConfigurations(CONFIG_KEY_UPDATE_INTERVAL) 107 | -------------------------------------------------------------------------------- /IoTuring/Configurator/ConfiguratorObject.py: -------------------------------------------------------------------------------- 1 | from IoTuring.Configurator.MenuPreset import BooleanAnswers, MenuPreset 2 | from IoTuring.Configurator.Configuration import SingleConfiguration, CONFIG_CLASS, CONFIG_KEY_TYPE 3 | from IoTuring.Exceptions.Exceptions import UnknownConfigKeyException 4 | 5 | 6 | 7 | class ConfiguratorObject: 8 | """ Base class for configurable classes """ 9 | NAME = "Unnamed" 10 | ALLOW_MULTI_INSTANCE = False 11 | 12 | def __init__(self, single_configuration: SingleConfiguration) -> None: 13 | self.configurations = single_configuration 14 | 15 | # Add missing default values: 16 | defaults = self.ConfigurationPreset().GetDefaults() 17 | 18 | if defaults: 19 | for default_key, default_value in defaults.items(): 20 | if not self.GetConfigurations().HasConfigKey(default_key): 21 | self.GetConfigurations().UpdateConfigValue(default_key, default_value) 22 | 23 | def GetConfigurations(self) -> SingleConfiguration: 24 | """ Safe return single_configuration object """ 25 | if self.configurations: 26 | return self.configurations 27 | else: 28 | raise Exception(f"Configuration not loaded for {self.NAME}") 29 | 30 | def GetFromConfigurations(self, key): 31 | """ Get value from confiugurations with key (if not present raise Exception).""" 32 | if self.GetConfigurations().HasConfigKey(key): 33 | return self.GetConfigurations().GetConfigValue(key) 34 | else: 35 | raise UnknownConfigKeyException(key) 36 | 37 | def GetTrueOrFalseFromConfigurations(self, key) -> bool: 38 | """ Get boolean value from confiugurations with key (if not present raise Exception) """ 39 | value = self.GetFromConfigurations(key).lower() 40 | if value in BooleanAnswers.TRUE_ANSWERS: 41 | return True 42 | else: 43 | return False 44 | 45 | @classmethod 46 | def ConfigurationPreset(cls) -> MenuPreset: 47 | """ Prepare a preset to manage settings insert/edit for the warehouse or entity """ 48 | return MenuPreset() 49 | 50 | @classmethod 51 | def AllowMultiInstance(cls): 52 | """ Return True if this Entity can have multiple instances, useful for customizable entities 53 | These entities are the ones that must have a tag to be recognized """ 54 | return cls.ALLOW_MULTI_INSTANCE 55 | 56 | @classmethod 57 | def GetClassKey(cls) -> str: 58 | """Get the CLASS_KEY of this configuration, e.g. KEY_ENTITY, KEY_WAREHOUSE""" 59 | class_key = cls.__bases__[0].__name__.lower() 60 | if class_key not in CONFIG_CLASS: 61 | raise Exception(f"Invalid class {class_key}") 62 | return class_key 63 | 64 | @classmethod 65 | def GetDefaultConfigurations(cls) -> SingleConfiguration: 66 | """Get the default configuration of this class""" 67 | 68 | # Get default configs as dict: 69 | config_dict = cls.ConfigurationPreset().GetDefaults() 70 | config_dict[CONFIG_KEY_TYPE] = cls.NAME 71 | 72 | return SingleConfiguration(cls.GetClassKey(), config_dict=config_dict) 73 | -------------------------------------------------------------------------------- /IoTuring/Configurator/MenuPreset.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from InquirerPy import inquirer 4 | 5 | from IoTuring.Exceptions.Exceptions import UserCancelledException 6 | 7 | 8 | class QuestionPreset(): 9 | 10 | def __init__(self, 11 | name, 12 | key, 13 | default=None, 14 | mandatory=False, 15 | dependsOn={}, 16 | instruction="", 17 | question_type="text", 18 | choices=[] 19 | ) -> None: 20 | self.name = name 21 | self.key = key 22 | self.default = default 23 | self.mandatory = mandatory 24 | self.dependsOn = dependsOn 25 | self.instruction = instruction 26 | self.question_type = question_type 27 | self.choices = choices 28 | self.value = None 29 | 30 | self.question = self.name 31 | 32 | # yesno question cannot be mandatory: 33 | if self.question_type == "yesno": 34 | self.mandatory = False 35 | 36 | # Add mandatory mark: 37 | if self.mandatory: 38 | self.question += " {!}" 39 | 40 | def ShouldDisplay(self, answer_dict: dict) -> bool: 41 | """Check if this question should be displayed 42 | 43 | Args: 44 | answer_dict (dict): A dict of previous answers 45 | 46 | Returns: 47 | bool: If this question should be displayed 48 | """ 49 | 50 | should_display = True 51 | 52 | # Check if it has dependencies: 53 | if self.dependsOn: 54 | dependencies_ok = [] 55 | for key, value in self.dependsOn.items(): 56 | dependency_ok = False 57 | if key in answer_dict: 58 | 59 | # Value is True or False: 60 | if isinstance(value, bool): 61 | if answer_dict[key]: 62 | dependency_ok = True 63 | 64 | # Value must match: 65 | elif isinstance(value, str) and answer_dict[key] == value: 66 | dependency_ok = True 67 | 68 | dependencies_ok.append(dependency_ok) 69 | 70 | # All should be True: 71 | if not all(dependencies_ok): 72 | should_display = False 73 | 74 | return should_display 75 | 76 | def Ask(self): 77 | """Ask a single question preset""" 78 | 79 | question_options = {} 80 | 81 | if self.mandatory: 82 | def validate(x): return bool(x) 83 | question_options.update({ 84 | "validate": validate, 85 | "invalid_message": "You must provide a value for this key" 86 | }) 87 | 88 | question_options["message"] = self.question + ":" 89 | 90 | if self.default is not None: 91 | # yesno questions need boolean default: 92 | if self.question_type == "yesno": 93 | question_options["default"] = \ 94 | bool(str(self.default).lower() 95 | in BooleanAnswers.TRUE_ANSWERS) 96 | elif self.question_type == "integer": 97 | question_options["default"] = int(self.default) 98 | else: 99 | question_options["default"] = self.default 100 | else: 101 | if self.question_type == "integer": 102 | # The default integer is 0, overwrite to None: 103 | question_options["default"] = None 104 | 105 | # text: 106 | prompt_function = inquirer.text 107 | 108 | if self.question_type == "secret": 109 | prompt_function = inquirer.secret 110 | 111 | elif self.question_type == "yesno": 112 | prompt_function = inquirer.confirm 113 | question_options.update({ 114 | "filter": lambda x: "Y" if x else "N" 115 | }) 116 | 117 | elif self.question_type == "select": 118 | prompt_function = inquirer.select 119 | question_options.update({ 120 | "choices": self.choices 121 | }) 122 | 123 | elif self.question_type == "integer": 124 | prompt_function = inquirer.number 125 | question_options["float_allowed"] = False 126 | 127 | elif self.question_type == "filepath": 128 | prompt_function = inquirer.filepath 129 | 130 | # Ask the question: 131 | prompt = prompt_function( 132 | instruction=self.instruction, 133 | **question_options 134 | ) 135 | 136 | self.cancelled = False 137 | 138 | @prompt.register_kb("escape") 139 | def _handle_esc(event): 140 | prompt._mandatory = False 141 | prompt._handle_skip(event) 142 | # exception raised here catched by inquirer. 143 | self.cancelled = True 144 | 145 | value = prompt.execute() 146 | 147 | if self.cancelled: 148 | raise UserCancelledException 149 | 150 | return value 151 | 152 | 153 | class MenuPreset(): 154 | 155 | def __init__(self) -> None: 156 | self.presets: list[QuestionPreset] = [] 157 | self.results: list[QuestionPreset] = [] 158 | self.cancelled = False 159 | 160 | def HasQuestions(self) -> bool: 161 | """Check if this preset has any questions to ask""" 162 | return bool(self.presets) 163 | 164 | def AddEntry(self, 165 | name, 166 | key, 167 | default=None, 168 | mandatory=False, 169 | display_if_key_value={}, 170 | instruction="", 171 | question_type="text", 172 | choices=[]) -> None: 173 | """ 174 | Add an entry to the preset with: 175 | - key: the key to use in the dict 176 | - name: the name to display to the user 177 | - default: the default value to use if the user doesn't provide one (works only if mandatory=False) 178 | - mandatory: if the user must provide a value for this entry 179 | - display_if_key_value: dict of a key and value another entry (that must preceed this) that will enable this one: 180 | * Default empty dict: the entry will be always displayed 181 | * In the dict each key is a key of a previous entry. 182 | * If the value is a string, this entry will be enabled, if the value is the same as the of answer that question 183 | * If the value is True this entry will be enabled if anything was answered 184 | * If the value if False, it will be displayed, if nothing was answered to that question. 185 | * In case this won't be displayed, a default value will be used if provided; otherwise won't set this key in the dict) 186 | ! Caution: if the entry is not displayed, the mandatory property will be ignored ! 187 | - instruction: more text to show 188 | - question_type: text, secret, integer, filepath, select or yesno 189 | - choices: only for select question type 190 | """ 191 | 192 | if question_type not in ["text", "secret", "select", "yesno", "integer", "filepath"]: 193 | raise Exception(f"Unknown question type: {question_type}") 194 | 195 | if question_type == "select" and not choices: 196 | raise Exception(f"Missing choices for question: {name}") 197 | 198 | # Add question to presets: 199 | self.presets.append( 200 | QuestionPreset( 201 | name=name, 202 | key=key, 203 | default=default, 204 | mandatory=mandatory, 205 | dependsOn=display_if_key_value, 206 | instruction=instruction, 207 | question_type=question_type, 208 | choices=choices 209 | )) 210 | 211 | def AskQuestions(self) -> None: 212 | """Ask all questions of this preset""" 213 | for q_preset in self.presets: 214 | # if the previous question was cancelled: 215 | 216 | try: 217 | 218 | # It should be displayed, ask question: 219 | if q_preset.ShouldDisplay(self.GetDict()): 220 | 221 | value = q_preset.Ask() 222 | 223 | if value: 224 | q_preset.value = value 225 | # Add to answered questions: 226 | self.results.append(q_preset) 227 | 228 | except UserCancelledException: 229 | raise UserCancelledException 230 | except Exception as e: 231 | print(f"Error while making question for {q_preset.name}:", e) 232 | 233 | def GetPresetByKey(self, key: str) -> QuestionPreset | None: 234 | """Get the QuestionPreset of this key. Returns None if not found""" 235 | return next((entry for entry in self.presets if entry.key == key), None) 236 | 237 | def GetDict(self) -> dict: 238 | """ Get a dict with keys and responses""" 239 | return {entry.key: entry.value for entry in self.results} 240 | 241 | def GetDefaults(self) -> dict: 242 | """ Get a dict of default values of keys """ 243 | return {entry.key: entry.default for entry in self.presets} 244 | 245 | def AddTagQuestion(self): 246 | """ Add a Tag question (compulsory, no default) to the preset. 247 | Useful for entities that must have a tag because of their multi-instance possibility """ 248 | self.AddEntry(name="Tag", key="tag", mandatory=True, 249 | instruction="Alias, to recognize entity in configurations and warehouses") 250 | 251 | 252 | class BooleanAnswers: 253 | TRUE_ANSWERS = ["y", "yes", "t", "true", "ok", "okay"] # all lower ! 254 | -------------------------------------------------------------------------------- /IoTuring/Configurator/messages.py: -------------------------------------------------------------------------------- 1 | from IoTuring.Configurator import ConfiguratorIO 2 | 3 | 4 | HELP_MESSAGE = f""" 5 | 1. Configuration file 6 | 7 | \tYou can find the configuration file in the following path: 8 | \t\tmacOS\t\t~/Library/Application Support/IoTuring/configurations.json 9 | \t\tLinux\t\t~/.config/IoTuring/configurations.json 10 | \t\tWindows\t\t%APPDATA%/IoTuring/configurations.json 11 | \t\tFallback\t[ioturing_install_path]/Configurator/configurations.json\t 12 | \tYou can also set your preferred directory by setting the environment variable {ConfiguratorIO.CONFIG_PATH_ENV_VAR} 13 | \tConfiguration will be stored there in the file configurations.json. 14 | 15 | 2.Configurator menu 16 | 17 | \tUse Escape to go back to the previous menu 18 | \tUse ctrl+C to exit without saving 19 | """ 20 | 21 | PRESET_RULES = """Options with this sign are compulsory: {!} 22 | Use Escape to cancel""" -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/ActiveWindow/ActiveWindow.py: -------------------------------------------------------------------------------- 1 | import re 2 | from IoTuring.Entity.Entity import Entity 3 | from IoTuring.Entity.EntityData import EntitySensor 4 | from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD 5 | from IoTuring.MyApp.SystemConsts import DesktopEnvironmentDetection as De 6 | 7 | 8 | # Windows dep 9 | try: 10 | from win32gui import GetWindowText, GetForegroundWindow # type: ignore 11 | windows_support = True 12 | except BaseException: 13 | windows_support = False 14 | 15 | # macOS dep 16 | try: 17 | from AppKit import NSWorkspace # type: ignore 18 | macos_support = True 19 | except BaseException: 20 | macos_support = False 21 | 22 | KEY = 'active_window' 23 | 24 | 25 | class ActiveWindow(Entity): 26 | NAME = "ActiveWindow" 27 | 28 | def Initialize(self): 29 | 30 | UpdateFunction = { 31 | OsD.LINUX: self.GetActiveWindow_Linux, 32 | OsD.WINDOWS: self.GetActiveWindow_Windows, 33 | OsD.MACOS: self.GetActiveWindow_macOS 34 | } 35 | 36 | self.UpdateSpecificFunction = UpdateFunction[OsD.GetOs()] 37 | 38 | self.RegisterEntitySensor(EntitySensor(self, KEY)) 39 | 40 | def Update(self): 41 | if self.UpdateSpecificFunction: 42 | self.SetEntitySensorValue(KEY, str(self.UpdateSpecificFunction())) 43 | 44 | def GetActiveWindow_macOS(self): 45 | try: 46 | curr_app = NSWorkspace.sharedWorkspace().activeApplication() 47 | curr_app_name = curr_app['NSApplicationName'] 48 | return curr_app_name # Better choice beacuse on Mac the window title is a bit buggy 49 | except BaseException: 50 | return "Inactive" 51 | 52 | def GetActiveWindow_Windows(self): 53 | return GetWindowText(GetForegroundWindow()) 54 | 55 | def GetActiveWindow_Linux(self) -> str: 56 | p = self.RunCommand("xprop -root _NET_ACTIVE_WINDOW") 57 | 58 | if p.stdout: 59 | m = re.search('^_NET_ACTIVE_WINDOW.* ([\\w]+)$', p.stdout) 60 | 61 | if m is not None: 62 | window_id = m.group(1) 63 | 64 | if window_id == '0x0': 65 | return 'Unknown' 66 | 67 | w = self.RunCommand(f"xprop -id {window_id} WM_NAME") 68 | 69 | if w.stderr: 70 | return w.stderr 71 | 72 | match = re.match( 73 | 'WM_NAME\\(\\w+\\) = (?P.+)$', w.stdout) 74 | 75 | if match is not None: 76 | return match.group('name').strip('"') 77 | 78 | return 'Inactive' 79 | 80 | @classmethod 81 | def CheckSystemSupport(cls): 82 | if OsD.IsLinux(): 83 | if De.IsWayland(): 84 | raise Exception("Wayland is not supported") 85 | elif not OsD.CommandExists("xprop"): 86 | raise Exception("No xprop command found!") 87 | 88 | elif OsD.IsWindows() or OsD.IsMacos(): 89 | 90 | if (OsD.IsWindows() and not windows_support) or\ 91 | (OsD.IsMacos() and not macos_support): 92 | raise Exception("Unsatisfied dependencies for this entity") 93 | 94 | else: 95 | raise cls.UnsupportedOsException() 96 | -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/AppInfo/AppInfo.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from IoTuring.Entity.Entity import Entity 3 | from IoTuring.Entity.EntityData import EntitySensor 4 | from IoTuring.MyApp.App import App 5 | 6 | KEY_NAME = 'name' 7 | KEY_VERSION = 'version' 8 | KEY_UPDATE = 'update' 9 | PYPI_URL = 'https://pypi.org/pypi/ioturing/json' 10 | 11 | GET_UPDATE_ERROR_MESSAGE = "Error while checking, try to update to solve this problem. Alert the developers if the problem persists." 12 | 13 | EXTRA_ATTRIBUTE_UPDATE_LATEST = 'Latest version' 14 | EXTRA_ATTRIBUTE_UPDATE_ERROR = 'Check error' 15 | 16 | class AppInfo(Entity): 17 | NAME = "AppInfo" 18 | 19 | def Initialize(self): 20 | self.RegisterEntitySensor(EntitySensor(self, KEY_NAME)) 21 | self.RegisterEntitySensor(EntitySensor(self, KEY_VERSION)) 22 | self.RegisterEntitySensor(EntitySensor(self, KEY_UPDATE, supportsExtraAttributes=True)) 23 | 24 | self.SetEntitySensorValue(KEY_NAME, App.getName()) 25 | self.SetEntitySensorValue(KEY_VERSION, App.getVersion()) 26 | self.SetUpdateTimeout(600) 27 | 28 | def Update(self): 29 | # VERSION UPDATE CHECK 30 | try: 31 | new_version = self.GetUpdateInformation() 32 | 33 | if not new_version: # signal no update and current version (as its the latest) 34 | self.SetEntitySensorValue( 35 | KEY_UPDATE, "False") 36 | self.SetEntitySensorExtraAttribute(KEY_UPDATE, EXTRA_ATTRIBUTE_UPDATE_LATEST, App.getVersion()) 37 | else: # signal update and latest version 38 | self.SetEntitySensorValue( 39 | KEY_UPDATE, "True") 40 | self.SetEntitySensorExtraAttribute(KEY_UPDATE, EXTRA_ATTRIBUTE_UPDATE_LATEST, new_version) 41 | except Exception as e: 42 | # connection error or pypi name changed or something else 43 | self.SetEntitySensorValue( 44 | KEY_UPDATE, False) 45 | # add extra attribute to show error message 46 | self.SetEntitySensorExtraAttribute(KEY_UPDATE, EXTRA_ATTRIBUTE_UPDATE_ERROR, GET_UPDATE_ERROR_MESSAGE) 47 | 48 | 49 | def GetUpdateInformation(self): 50 | """ 51 | Get the update information of IoTuring 52 | Returns False if no update is available 53 | Otherwise returns the latest version (could be set as extra attribute) 54 | """ 55 | latest = "" 56 | res = requests.get(PYPI_URL) 57 | if res.status_code == 200: 58 | info = res.json().get("info", "") 59 | if len(info) >= 1: 60 | latest = info.get("version", "") 61 | else: 62 | raise UpdateCheckException() 63 | else: 64 | raise UpdateCheckException() 65 | if len(latest) >= 1: 66 | if versionToInt(latest) > versionToInt(App.getVersion()): 67 | return latest 68 | else: 69 | return False 70 | else: 71 | raise UpdateCheckException() 72 | 73 | def versionToInt(version: str): 74 | return int(''.join([i for i in version if i.isdigit()])) 75 | 76 | class UpdateCheckException(Exception): 77 | pass -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/Battery/Battery.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import psutil 3 | from IoTuring.Entity.Entity import Entity 4 | from IoTuring.Entity.EntityData import EntitySensor 5 | from IoTuring.Entity.ValueFormat import ValueFormatter, ValueFormatterOptions 6 | 7 | KEY_PERCENTAGE = 'percentage' 8 | KEY_CHARGING_STATUS = 'charging' 9 | 10 | class Battery(Entity): 11 | NAME = "Battery" 12 | supports_charge = False 13 | 14 | def Initialize(self): 15 | 16 | # This should raise error if no battery: 17 | batteryInfo = self.GetBatteryInformation() 18 | 19 | self.RegisterEntitySensor(EntitySensor( 20 | self, KEY_PERCENTAGE, valueFormatterOptions=ValueFormatterOptions(ValueFormatterOptions.TYPE_PERCENTAGE))) 21 | 22 | # Check if charging state working: 23 | if isinstance(batteryInfo['charging'], bool): 24 | self.supports_charge = True 25 | self.RegisterEntitySensor(EntitySensor(self, KEY_CHARGING_STATUS)) 26 | 27 | def Update(self): 28 | batteryInfo = self.GetBatteryInformation() 29 | 30 | self.SetEntitySensorValue( 31 | KEY_PERCENTAGE, int(batteryInfo['level'])) 32 | 33 | if self.supports_charge: 34 | self.SetEntitySensorValue( 35 | KEY_CHARGING_STATUS, str(batteryInfo['charging'])) 36 | 37 | def GetBatteryInformation(self) -> dict: 38 | battery = psutil.sensors_battery() 39 | if not battery: 40 | raise Exception("No battery sensor for this host") 41 | 42 | return {'level': battery.percent, 'charging': battery.power_plugged} 43 | -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/BootTime/BootTime.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import psutil 3 | from IoTuring.Entity.Entity import Entity 4 | from IoTuring.Entity.EntityData import EntitySensor 5 | 6 | KEY_BOOT_TIME = 'boot_time' 7 | 8 | 9 | class BootTime(Entity): 10 | NAME = "BootTime" 11 | 12 | def Initialize(self): 13 | self.RegisterEntitySensor(EntitySensor(self, KEY_BOOT_TIME)) 14 | 15 | def Update(self): 16 | self.SetEntitySensorValue(KEY_BOOT_TIME, 17 | str(datetime.datetime.fromtimestamp(psutil.boot_time()))) 18 | -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/Cpu/Cpu.py: -------------------------------------------------------------------------------- 1 | import psutil 2 | from IoTuring.Entity.ValueFormat import ValueFormatter, ValueFormatterOptions 3 | from IoTuring.Entity.Entity import Entity 4 | from IoTuring.Entity.EntityData import EntitySensor 5 | 6 | FREQUENCY_DECIMALS = 0 7 | 8 | MHZ = 1000000 9 | 10 | # Sensor: CPU 11 | KEY_PERCENTAGE = 'used_percentage' 12 | # Extra data keys 13 | EXTRA_KEY_COUNT = 'CPU Count' 14 | # CPU times 15 | EXTRA_KEY_TIMES_USER = 'User CPU time' 16 | EXTRA_KEY_TIMES_SYSTEM = 'System CPU time' 17 | EXTRA_KEY_TIMES_IDLE = 'Idle CPU time' 18 | # CPU stats 19 | EXTRA_KEY_STATS_CTX = 'Context switches since boot' 20 | EXTRA_KEY_STATS_INTERR = 'Number of interrupts since boot' 21 | # CPU avg load 22 | EXTRA_KEY_AVERAGE_LOAD_LAST_1 = 'Average load last minute' 23 | EXTRA_KEY_AVERAGE_LOAD_LAST_5 = 'Average load last 5 minutes' 24 | EXTRA_KEY_AVERAGE_LOAD_LAST_15 = 'Average load last 15 minutes' 25 | 26 | # Sensor: CPU frequency 27 | KEY_FREQ_CURRENT = 'current_frequency' 28 | # Extra data keys 29 | EXTRA_KEY_FREQ_MIN = 'Minimum CPU frequency' 30 | EXTRA_KEY_FREQ_MAX = 'Maximum CPU frequency' 31 | 32 | VALUEFORMATOPTIONS_CPU_PERCENTAGE = ValueFormatterOptions( 33 | ValueFormatterOptions.TYPE_PERCENTAGE, 1) 34 | VALUEFORMATOPTIONS_CPU_TIME = ValueFormatterOptions( 35 | ValueFormatterOptions.TYPE_MILLISECONDS) 36 | VALUEFORMATOPTIONS_ROUND2 = ValueFormatterOptions( 37 | ValueFormatterOptions.TYPE_NONE, 2) 38 | VALUEFORMATOPTIONS_CPU_FREQUENCY_MHZ = ValueFormatterOptions( 39 | ValueFormatterOptions.TYPE_FREQUENCY, FREQUENCY_DECIMALS, "MHz") 40 | 41 | 42 | class Cpu(Entity): 43 | NAME = "Cpu" 44 | 45 | def Initialize(self): 46 | self.RegisterEntitySensor( 47 | EntitySensor( 48 | self, 49 | KEY_PERCENTAGE, 50 | valueFormatterOptions=VALUEFORMATOPTIONS_CPU_PERCENTAGE, 51 | supportsExtraAttributes=True)) 52 | self.RegisterEntitySensor( 53 | EntitySensor( 54 | self, 55 | KEY_FREQ_CURRENT, 56 | valueFormatterOptions=VALUEFORMATOPTIONS_CPU_FREQUENCY_MHZ, 57 | supportsExtraAttributes=True)) 58 | 59 | def Update(self): 60 | # CPU Percentage 61 | self.SetEntitySensorValue(KEY_PERCENTAGE, psutil.cpu_percent()) 62 | # Extra data 63 | self.SetEntitySensorExtraAttribute( 64 | KEY_PERCENTAGE, EXTRA_KEY_COUNT, psutil.cpu_count()) 65 | # CPU times 66 | self.SetEntitySensorExtraAttribute(KEY_PERCENTAGE, EXTRA_KEY_TIMES_USER, psutil.cpu_times()[ 67 | 0], valueFormatterOptions=VALUEFORMATOPTIONS_CPU_TIME) 68 | self.SetEntitySensorExtraAttribute(KEY_PERCENTAGE, EXTRA_KEY_TIMES_SYSTEM, psutil.cpu_times()[ 69 | 1], valueFormatterOptions=VALUEFORMATOPTIONS_CPU_TIME) 70 | self.SetEntitySensorExtraAttribute(KEY_PERCENTAGE, EXTRA_KEY_TIMES_IDLE, psutil.cpu_times()[ 71 | 2], valueFormatterOptions=VALUEFORMATOPTIONS_CPU_TIME) 72 | # CPU stats 73 | self.SetEntitySensorExtraAttribute(KEY_PERCENTAGE, EXTRA_KEY_STATS_CTX, psutil.cpu_stats( 74 | )[0], valueFormatterOptions=VALUEFORMATOPTIONS_ROUND2) 75 | self.SetEntitySensorExtraAttribute(KEY_PERCENTAGE, EXTRA_KEY_STATS_INTERR, psutil.cpu_stats()[ 76 | 1], valueFormatterOptions=VALUEFORMATOPTIONS_ROUND2) 77 | 78 | # CPU avg load 79 | self.SetEntitySensorExtraAttribute(KEY_PERCENTAGE, EXTRA_KEY_AVERAGE_LOAD_LAST_1, 80 | psutil.getloadavg()[0], valueFormatterOptions=VALUEFORMATOPTIONS_ROUND2) 81 | self.SetEntitySensorExtraAttribute(KEY_PERCENTAGE, EXTRA_KEY_AVERAGE_LOAD_LAST_5, 82 | psutil.getloadavg()[1], valueFormatterOptions=VALUEFORMATOPTIONS_ROUND2) 83 | self.SetEntitySensorExtraAttribute(KEY_PERCENTAGE, EXTRA_KEY_AVERAGE_LOAD_LAST_15, 84 | psutil.getloadavg()[2], valueFormatterOptions=VALUEFORMATOPTIONS_ROUND2) 85 | 86 | # CPU freq 87 | self.SetEntitySensorValue( 88 | KEY_FREQ_CURRENT, 89 | value=MHZ * psutil.cpu_freq()[0]) 90 | # Extra data 91 | self.SetEntitySensorExtraAttribute( 92 | sensorDataKey=KEY_FREQ_CURRENT, 93 | attributeKey=EXTRA_KEY_FREQ_MIN, 94 | attributeValue=MHZ * psutil.cpu_freq()[1], 95 | valueFormatterOptions=VALUEFORMATOPTIONS_CPU_FREQUENCY_MHZ) 96 | self.SetEntitySensorExtraAttribute( 97 | sensorDataKey=KEY_FREQ_CURRENT, 98 | attributeKey=EXTRA_KEY_FREQ_MAX, 99 | attributeValue=MHZ * psutil.cpu_freq()[2], 100 | valueFormatterOptions=VALUEFORMATOPTIONS_CPU_FREQUENCY_MHZ) 101 | -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/DesktopEnvironment/DesktopEnvironment.py: -------------------------------------------------------------------------------- 1 | from IoTuring.Entity.Entity import Entity 2 | from IoTuring.Entity.EntityData import EntitySensor 3 | from IoTuring.MyApp.SystemConsts import DesktopEnvironmentDetection as De 4 | from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD 5 | 6 | KEY_DE = 'desktop_environment' 7 | EXTRA_KEY_WAYLAND = 'wayland' 8 | 9 | # TODO Here I need the possibility for fixed value -> a configuration 10 | 11 | 12 | class DesktopEnvironment(Entity): 13 | NAME = "DesktopEnvironment" 14 | 15 | def Initialize(self): 16 | 17 | # Attribute only on Linux 18 | self.RegisterEntitySensor(EntitySensor( 19 | self, KEY_DE, supportsExtraAttributes=OsD.IsLinux())) 20 | 21 | # The value for this sensor is static for the entire script run time 22 | self.SetEntitySensorValue(KEY_DE, De.GetDesktopEnvironment()) 23 | 24 | # Add an attribute on linux checking if it's a wayland session: 25 | if OsD.IsLinux(): 26 | self.SetEntitySensorExtraAttribute( 27 | KEY_DE, EXTRA_KEY_WAYLAND, str(De.IsWayland())) 28 | -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/Disk/Disk.py: -------------------------------------------------------------------------------- 1 | import psutil 2 | from IoTuring.Entity.Entity import Entity 3 | from IoTuring.Entity.EntityData import EntitySensor 4 | from IoTuring.Configurator.MenuPreset import MenuPreset 5 | from IoTuring.Entity.ValueFormat import ValueFormatterOptions 6 | from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD 7 | 8 | KEY_USED_PERCENTAGE = "space_used_percentage" 9 | CONFIG_KEY_DU_PATH = "path" 10 | 11 | EXTRA_KEY_DISK_MOUNTPOINT = "Mountpoint" 12 | EXTRA_KEY_DISK_FSTYPE = "Filesystem" 13 | EXTRA_KEY_DISK_DEVICE = "Device" 14 | EXTRA_KEY_DISK_TOTAL = "Total" 15 | EXTRA_KEY_DISK_USED = "Used" 16 | EXTRA_KEY_DISK_FREE = "Free" 17 | 18 | VALUEFORMATOPTIONS_DISK_GB = ValueFormatterOptions( 19 | ValueFormatterOptions.TYPE_BYTE, 0, "GB") 20 | 21 | 22 | DEFAULT_PATH = { 23 | OsD.WINDOWS: "C:\\", 24 | OsD.MACOS: "/", 25 | OsD.LINUX: "/" 26 | } 27 | 28 | DISK_CHOICE_STRING = { 29 | OsD.WINDOWS: "Drive with Driveletter {}", 30 | OsD.MACOS: "{}, mounted in {}", 31 | OsD.LINUX: "{}, mounted in {}" 32 | } 33 | 34 | 35 | class Disk(Entity): 36 | NAME = "Disk" 37 | ALLOW_MULTI_INSTANCE = True 38 | 39 | def Initialize(self) -> None: 40 | """Initialise the DiskUsage Entity and Register it 41 | """ 42 | 43 | self.configuredPath = self.GetFromConfigurations(CONFIG_KEY_DU_PATH) 44 | 45 | try: 46 | # Get partision info for extra attributes: 47 | self.disk_partition = next( 48 | (d for d in psutil.disk_partitions() if d.mountpoint == self.configuredPath)) 49 | except StopIteration: 50 | raise Exception(f"Device not found: {self.configuredPath}") 51 | 52 | self.RegisterEntitySensor( 53 | EntitySensor( 54 | self, 55 | KEY_USED_PERCENTAGE, 56 | supportsExtraAttributes=True, 57 | valueFormatterOptions=ValueFormatterOptions( 58 | ValueFormatterOptions.TYPE_PERCENTAGE 59 | ) 60 | ) 61 | ) 62 | 63 | self.SetEntitySensorExtraAttribute( 64 | sensorDataKey=KEY_USED_PERCENTAGE, 65 | attributeKey=EXTRA_KEY_DISK_MOUNTPOINT, 66 | attributeValue=self.disk_partition.mountpoint) 67 | 68 | self.SetEntitySensorExtraAttribute( 69 | sensorDataKey=KEY_USED_PERCENTAGE, 70 | attributeKey=EXTRA_KEY_DISK_FSTYPE, 71 | attributeValue=self.disk_partition.fstype) 72 | 73 | if not OsD.IsWindows(): 74 | self.SetEntitySensorExtraAttribute( 75 | sensorDataKey=KEY_USED_PERCENTAGE, 76 | attributeKey=EXTRA_KEY_DISK_DEVICE, 77 | attributeValue=self.disk_partition.device) 78 | 79 | def Update(self) -> None: 80 | """UpdateMethod, psutil does not need separate behaviour on any os 81 | """ 82 | 83 | usage = psutil.disk_usage(self.configuredPath) 84 | 85 | self.SetEntitySensorValue( 86 | key=KEY_USED_PERCENTAGE, 87 | value=usage.percent) 88 | 89 | self.SetEntitySensorExtraAttribute( 90 | sensorDataKey=KEY_USED_PERCENTAGE, 91 | attributeKey=EXTRA_KEY_DISK_TOTAL, 92 | attributeValue=usage.total, 93 | valueFormatterOptions=VALUEFORMATOPTIONS_DISK_GB) 94 | 95 | self.SetEntitySensorExtraAttribute( 96 | sensorDataKey=KEY_USED_PERCENTAGE, 97 | attributeKey=EXTRA_KEY_DISK_USED, 98 | attributeValue=usage.used, 99 | valueFormatterOptions=VALUEFORMATOPTIONS_DISK_GB) 100 | 101 | self.SetEntitySensorExtraAttribute( 102 | sensorDataKey=KEY_USED_PERCENTAGE, 103 | attributeKey=EXTRA_KEY_DISK_FREE, 104 | attributeValue=usage.free, 105 | valueFormatterOptions=VALUEFORMATOPTIONS_DISK_GB) 106 | 107 | @classmethod 108 | def ConfigurationPreset(cls) -> MenuPreset: 109 | 110 | # Get the choices for menu: 111 | DISK_CHOICES = [] 112 | 113 | for disk in psutil.disk_partitions(): 114 | DISK_CHOICES.append( 115 | {"name": 116 | DISK_CHOICE_STRING[OsD.GetOs()].format( 117 | disk.device, disk.mountpoint), 118 | "value": disk.mountpoint} 119 | ) 120 | 121 | preset = MenuPreset() 122 | preset.AddEntry(name="Drive to check", 123 | key=CONFIG_KEY_DU_PATH, mandatory=False, 124 | question_type="select", choices=DISK_CHOICES) 125 | return preset 126 | -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/DisplayMode/DisplayMode.py: -------------------------------------------------------------------------------- 1 | import os as sys_os 2 | from IoTuring.Entity.Entity import Entity 3 | from ctypes import * 4 | from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD 5 | 6 | from IoTuring.Entity.EntityData import EntityCommand 7 | 8 | SELECT_INTERNAL_MONITOR = "Only internal display" 9 | SELECT_EXTERNAL_MONITOR = "Only external display" 10 | SELECT_EXTEND_MONITOR = "Extend displays" 11 | SELECT_CLONE_MONITOR = "Clone displays" 12 | KEY_MODE = "mode" 13 | 14 | 15 | class DisplayMode(Entity): 16 | NAME = "DisplayMode" 17 | 18 | def Initialize(self): 19 | 20 | callback = self.Callback_Win 21 | 22 | self.RegisterEntityCommand(EntityCommand( 23 | self, KEY_MODE, callback)) 24 | 25 | def Callback_Win(self, message): 26 | parse_select_command = {SELECT_INTERNAL_MONITOR: "internal", 27 | SELECT_EXTERNAL_MONITOR: "external", 28 | SELECT_CLONE_MONITOR: "clone", 29 | SELECT_EXTEND_MONITOR: "extend"} 30 | 31 | if message.payload.decode('utf-8') not in parse_select_command: 32 | self.Log(self.LOG_WARNING, 33 | f"Invalid command: {message.payload.decode('utf-8')}") 34 | else: 35 | sr = OsD.GetEnv('SystemRoot') 36 | command = r'{}\System32\DisplaySwitch.exe /{}'.format( 37 | sr, parse_select_command[message.payload.decode('utf-8')]) 38 | self.RunCommand(command=command) 39 | 40 | @classmethod 41 | def CheckSystemSupport(cls): 42 | if OsD.IsWindows(): 43 | sr = OsD.GetEnv('SystemRoot') 44 | if not sys_os.path.exists(r'{}\System32\DisplaySwitch.exe'.format(sr)): 45 | raise Exception("DisplaySwitch.exe not found!") 46 | else: 47 | raise cls.UnsupportedOsException() 48 | -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/Fanspeed/Fanspeed.py: -------------------------------------------------------------------------------- 1 | import psutil 2 | from IoTuring.Entity.Entity import Entity 3 | from IoTuring.Entity.EntityData import EntitySensor 4 | from IoTuring.Entity.ValueFormat import ValueFormatterOptions 5 | from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD 6 | 7 | 8 | VALUEFORMATTEROPTIONS_FANSPEED_RPM = ValueFormatterOptions( 9 | value_type=ValueFormatterOptions.TYPE_ROTATION) 10 | 11 | 12 | FALLBACK_CONTROLLER_LABEL = "controller" 13 | FALLBACK_FAN_LABEL = "fan" 14 | 15 | 16 | class Fanspeed(Entity): 17 | """Entity to read fanspeed""" 18 | NAME = "Fanspeed" 19 | 20 | def Initialize(self) -> None: 21 | """Initialize the Class, setup Formatter, determin specificInitialize and specificUpdate depending on OS""" 22 | 23 | InitFunction = { 24 | OsD.LINUX: self.InitLinux 25 | } 26 | 27 | UpdateFunction = { 28 | OsD.LINUX: self.UpdateLinux 29 | } 30 | 31 | self.specificInitialize = InitFunction[OsD.GetOs()] 32 | self.specificUpdate = UpdateFunction[OsD.GetOs()] 33 | 34 | self.specificInitialize() 35 | 36 | def InitLinux(self) -> None: 37 | """OS dependant Init for Linux""" 38 | sensors = psutil.sensors_fans() 39 | self.Log(self.LOG_DEBUG, f"fancontrollers found:{sensors}") 40 | 41 | for i, controller in enumerate(sensors): 42 | # use FALLBACK for blank controllernames 43 | controllerName = controller or FALLBACK_CONTROLLER_LABEL + str(i) 44 | 45 | # Add extra attributes only if there are multiple fans: 46 | hasMultipleFans = bool(len(sensors[controller]) > 1) 47 | 48 | # register an entity for each controller 49 | self.RegisterEntitySensor( 50 | EntitySensor( 51 | self, 52 | controllerName, 53 | supportsExtraAttributes=hasMultipleFans, 54 | valueFormatterOptions=VALUEFORMATTEROPTIONS_FANSPEED_RPM, 55 | ) 56 | ) 57 | 58 | def Update(self) -> None: 59 | """placeholder for OS specificUpdate""" 60 | if self.specificUpdate: 61 | self.specificUpdate() 62 | else: 63 | raise NotImplementedError 64 | 65 | def UpdateLinux(self) -> None: 66 | """Updatemethod for Linux""" 67 | for controller, fans in psutil.sensors_fans().items(): 68 | # get all fanspeed in a list and find max 69 | highest_fan = max([fan.current for fan in fans]) 70 | # find higest fanspeed and assign the entity state 71 | self.SetEntitySensorValue( 72 | key=controller, 73 | value=highest_fan) 74 | # Set extra attributes {fan name : fanspeed in rpm} 75 | self.Log(self.LOG_DEBUG, 76 | f"updating controller:{controller} with {fans}") 77 | 78 | # Add fans as extra attributes, if there are more than one: 79 | if len(fans) > 1: 80 | for i, fan in enumerate(fans): 81 | # appy FALLBACK if label is blank 82 | fanlabel = fan.label or FALLBACK_FAN_LABEL + str(i) 83 | 84 | # set extra attributes for each fan 85 | self.SetEntitySensorExtraAttribute( 86 | controller, 87 | fanlabel, 88 | fan.current, 89 | valueFormatterOptions=VALUEFORMATTEROPTIONS_FANSPEED_RPM, 90 | ) 91 | 92 | @classmethod 93 | def CheckSystemSupport(cls): 94 | if OsD.IsLinux(): 95 | # psutil docs: no attribute -> system not supported 96 | if not hasattr(psutil, "sensors_fans"): 97 | raise Exception("System not supported by psutil") 98 | # psutil docs: empty dict -> no fancontrollers reporting 99 | if not bool(psutil.sensors_fans()): 100 | raise Exception("No fan found in system") 101 | 102 | else: 103 | raise cls.UnsupportedOsException() 104 | -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/FileSwitch/FileSwitch.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from IoTuring.Entity.Entity import Entity 4 | from IoTuring.Entity.EntityData import EntityCommand, EntitySensor 5 | from IoTuring.Configurator.MenuPreset import MenuPreset 6 | 7 | KEY_STATE = 'fileswitch_state' 8 | KEY_CMD = 'fileswitch' 9 | 10 | CONFIG_KEY_PATH = 'path' 11 | 12 | 13 | class FileSwitch(Entity): 14 | NAME = "FileSwitch" 15 | ALLOW_MULTI_INSTANCE = True 16 | 17 | def Initialize(self): 18 | 19 | try: 20 | self.config_path = self.GetFromConfigurations(CONFIG_KEY_PATH) 21 | except Exception as e: 22 | raise Exception("Configuration error: " + str(e)) 23 | 24 | self.RegisterEntitySensor(EntitySensor(self, KEY_STATE, supportsExtraAttributes=True)) 25 | self.RegisterEntityCommand(EntityCommand( 26 | self, KEY_CMD, self.Callback, KEY_STATE)) 27 | 28 | def Callback(self, message): 29 | payloadString = message.payload.decode('utf-8') 30 | 31 | if payloadString == "True": 32 | Path(self.config_path).touch() 33 | 34 | elif payloadString == "False": 35 | Path(self.config_path).unlink(missing_ok=True) 36 | 37 | else: 38 | raise Exception('Incorrect payload!') 39 | 40 | def Update(self): 41 | self.SetEntitySensorValue(KEY_STATE, 42 | str(Path(self.config_path).exists())) 43 | 44 | self.SetEntitySensorExtraAttribute(KEY_STATE, "Path", str(self.config_path)) 45 | 46 | @classmethod 47 | def ConfigurationPreset(cls) -> MenuPreset: 48 | preset = MenuPreset() 49 | preset.AddEntry("Path to file", CONFIG_KEY_PATH, mandatory=True, question_type="filepath") 50 | return preset 51 | -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/Hostname/Hostname.py: -------------------------------------------------------------------------------- 1 | import socket 2 | from IoTuring.Entity.Entity import Entity 3 | from IoTuring.Entity.EntityData import EntitySensor 4 | 5 | KEY_HOSTNAME = 'hostname' 6 | 7 | 8 | class Hostname(Entity): 9 | NAME = "Hostname" 10 | 11 | def Initialize(self): 12 | self.RegisterEntitySensor(EntitySensor(self, KEY_HOSTNAME)) 13 | # The value for this sensor is static for the entire script run time 14 | self.SetEntitySensorValue(KEY_HOSTNAME, self.GetHostname()) 15 | 16 | def GetHostname(self): 17 | return socket.gethostname() 18 | -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/Lock/Lock.py: -------------------------------------------------------------------------------- 1 | from IoTuring.Entity.Entity import Entity 2 | from IoTuring.Entity.EntityData import EntityCommand 3 | from IoTuring.MyApp.SystemConsts import DesktopEnvironmentDetection as De 4 | from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD 5 | 6 | KEY_LOCK = 'lock' 7 | 8 | commands = { 9 | OsD.WINDOWS: { 10 | 'base': 'rundll32.exe user32.dll,LockWorkStation' 11 | }, 12 | OsD.MACOS: { 13 | 'base': 'pmset displaysleepnow' 14 | }, 15 | OsD.LINUX: { 16 | 'gnome': 'gnome-screensaver-command -l', # gnome-screensaver is deprecated 17 | 'cinnamon': 'cinnamon-screensaver-command -a', 18 | 'i3': 'i3lock', 19 | 'base': 'loginctl lock-session', 20 | 'xfce': 'xflock4', 21 | 'mate': 'mate-screensaver-command -l', 22 | 'plasmax11': 'qdbus org.freedesktop.ScreenSaver /ScreenSaver Lock', # loginctl lock-session does also work on plasma 23 | 'plasma': 'qdbus org.freedesktop.ScreenSaver /ScreenSaver Lock', # same as plasma x11 24 | 'hyprland': 'hyprlock', 25 | 'sway': 'swaylock', # sway does not have a lock command, but swaylock is a popular lockscreen for sway 26 | } 27 | } 28 | 29 | 30 | class Lock(Entity): 31 | NAME = "Lock" 32 | 33 | def Initialize(self): 34 | 35 | if OsD.IsLinux(): 36 | self.command = self.GetLinuxCommand() 37 | 38 | else: 39 | self.command = commands[OsD.GetOs()][De.GetDesktopEnvironment()] 40 | 41 | self.Log(self.LOG_DEBUG, f"Found lock command: {self.command}") 42 | 43 | self.RegisterEntityCommand(EntityCommand( 44 | self, KEY_LOCK, self.Callback_Lock)) 45 | 46 | def Callback_Lock(self, message): 47 | self.RunCommand(command=self.command) 48 | 49 | @classmethod 50 | def GetLinuxCommand(cls) -> str: 51 | """ Get lock command for this DesktopEnvironment. Raises Exception if not found """ 52 | try: 53 | cmd = next((commands[OsD.GetOs()][de] for de in [De.GetDesktopEnvironment(), "base"] 54 | if OsD.CommandExists(commands[OsD.GetOs()][de].split()[0]))) 55 | return cmd 56 | except StopIteration: 57 | raise Exception(f"No lock command found for this system") 58 | 59 | @classmethod 60 | def CheckSystemSupport(cls): 61 | if OsD.GetOs() not in commands: 62 | raise cls.UnsupportedOsException() 63 | 64 | if OsD.IsLinux(): 65 | try: 66 | cls.GetLinuxCommand() 67 | except Exception as e: 68 | raise Exception("Lock command error: " + str(e)) 69 | -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/Monitor/Monitor.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import re 3 | 4 | from IoTuring.Entity.Entity import Entity 5 | from IoTuring.Entity.EntityData import EntityCommand, EntitySensor 6 | from IoTuring.Logger.consts import STATE_OFF, STATE_ON 7 | from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD 8 | from IoTuring.MyApp.SystemConsts import DesktopEnvironmentDetection as De 9 | 10 | 11 | KEY_STATE = 'monitor_state' 12 | KEY_CMD = 'monitor' 13 | 14 | 15 | class Monitor(Entity): 16 | NAME = "Monitor" 17 | 18 | def Initialize(self): 19 | 20 | if OsD.IsLinux(): 21 | self.RegisterEntitySensor(EntitySensor(self, KEY_STATE)) 22 | self.RegisterEntityCommand(EntityCommand( 23 | self, KEY_CMD, self.Callback, KEY_STATE)) 24 | 25 | elif OsD.IsWindows(): 26 | self.RegisterEntityCommand(EntityCommand( 27 | self, KEY_CMD, self.Callback)) 28 | 29 | def Callback(self, message): 30 | payloadString = message.payload.decode('utf-8') 31 | 32 | if payloadString == STATE_ON: 33 | if OsD.IsWindows(): 34 | ctypes.windll.user32.\ 35 | SendMessageA(0xFFFF, 0x0112, 0xF170, -1) # type:ignore 36 | elif OsD.IsLinux(): 37 | self.RunCommand(command='xset dpms force on') 38 | 39 | elif payloadString == STATE_OFF: 40 | if OsD.IsWindows(): 41 | ctypes.windll.user32\ 42 | .SendMessageA(0xFFFF, 0x0112, 0xF170, 2) # type:ignore 43 | elif OsD.IsLinux(): 44 | self.RunCommand(command='xset dpms force off') 45 | else: 46 | raise Exception('Incorrect payload!') 47 | 48 | def Update(self): 49 | if OsD.IsLinux(): 50 | p = self.RunCommand(command="xset q") 51 | monitorState = re.findall( 52 | 'Monitor is (.{2,3})', p.stdout)[0].upper() 53 | if monitorState in [STATE_OFF, STATE_ON]: 54 | self.SetEntitySensorValue(KEY_STATE, monitorState) 55 | else: 56 | raise Exception(f'Incorrect monitor state: {monitorState}') 57 | 58 | @classmethod 59 | def CheckSystemSupport(cls): 60 | if OsD.IsLinux(): 61 | if De.IsWayland(): 62 | raise Exception("Wayland is not supported") 63 | else: 64 | try: 65 | De.CheckXsetSupport() 66 | except Exception as e: 67 | raise Exception(f'Xset not supported: {str(e)}') 68 | 69 | elif not OsD.IsWindows(): 70 | raise cls.UnsupportedOsException() 71 | -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/Notify/Notify.py: -------------------------------------------------------------------------------- 1 | from IoTuring.Entity.Entity import Entity 2 | from IoTuring.Entity.EntityData import EntityCommand 3 | from IoTuring.MyApp.App import App 4 | from IoTuring.Configurator.MenuPreset import MenuPreset 5 | from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD 6 | 7 | import os 8 | import json 9 | 10 | supports_win = True 11 | try: 12 | import tinyWinToast.tinyWinToast as twt # type: ignore 13 | except: 14 | supports_win = False 15 | 16 | commands = { 17 | OsD.LINUX: 'notify-send "{}" "{}" --icon="{}" --app-name="{}"', # title, message, icon path, app name 18 | OsD.MACOS: 'osascript -e \'display notification "{}" with title "{}"\'' # message, title 19 | } 20 | 21 | 22 | KEY = 'notify' 23 | 24 | # To send notification data through message payload use these two 25 | PAYLOAD_KEY_TITLE = "title" 26 | PAYLOAD_KEY_MESSAGE = "message" 27 | PAYLOAD_SEPARATOR = "|" 28 | 29 | CONFIG_KEY_TITLE = "title" 30 | CONFIG_KEY_MESSAGE = "message" 31 | CONFIG_KEY_ICON_PATH = "icon_path" 32 | 33 | DEFAULT_ICON_PATH = os.path.join( 34 | os.path.dirname(os.path.abspath(__file__)), "icon.png") 35 | 36 | MODE_DATA_VIA_CONFIG = "data_via_config" 37 | MODE_DATA_VIA_PAYLOAD = "data_via_payload" 38 | 39 | 40 | class Notify(Entity): 41 | NAME = "Notify" 42 | ALLOW_MULTI_INSTANCE = True 43 | 44 | # Data is set from configurations if configurations contain both title and message 45 | # Otherwise, data is set from payload (even if only one of title or message is set) 46 | def Initialize(self): 47 | 48 | # Check if both config is defined or both is empty: 49 | if not bool(self.GetFromConfigurations(CONFIG_KEY_TITLE)) == bool(self.GetFromConfigurations(CONFIG_KEY_MESSAGE)): 50 | raise Exception( 51 | "Configuration error: Both title and message should be defined, or both should be empty!") 52 | 53 | try: 54 | self.config_title = self.GetFromConfigurations(CONFIG_KEY_TITLE) 55 | self.config_message = self.GetFromConfigurations(CONFIG_KEY_MESSAGE) 56 | self.data_mode = MODE_DATA_VIA_CONFIG 57 | except Exception as e: 58 | self.data_mode = MODE_DATA_VIA_PAYLOAD 59 | 60 | if self.data_mode == MODE_DATA_VIA_CONFIG: 61 | if not self.config_title or not self.config_message: 62 | self.data_mode = MODE_DATA_VIA_PAYLOAD 63 | 64 | if self.data_mode == MODE_DATA_VIA_CONFIG: 65 | self.Log(self.LOG_INFO, "Using data from configuration") 66 | else: 67 | self.Log(self.LOG_INFO, "Using data from payload") 68 | 69 | # Set and check icon path: 70 | self.config_icon_path = self.GetFromConfigurations(CONFIG_KEY_ICON_PATH) 71 | 72 | if not os.path.exists(self.config_icon_path): 73 | self.Log( 74 | self.LOG_WARNING, f"Using default icon, custom path not found: {self.config_icon_path}") 75 | self.config_icon_path = DEFAULT_ICON_PATH 76 | 77 | # In addition, if data is from payload, we add this info to entity name 78 | # ! Changing the name we recognize the difference in warehouses only using the name 79 | # e.g HomeAssistant warehouse can use the regex syntax with NotifyPaylaod to identify that the component needs the text message 80 | self.NAME = self.NAME + \ 81 | ("Payload" if self.data_mode == MODE_DATA_VIA_PAYLOAD else "") 82 | 83 | self.RegisterEntityCommand(EntityCommand(self, KEY, self.Callback)) 84 | 85 | def Callback(self, message): 86 | if self.data_mode == MODE_DATA_VIA_PAYLOAD: 87 | # Get data from payload: 88 | payloadString = message.payload.decode('utf-8') 89 | try: 90 | payloadMessage = json.loads(payloadString) 91 | self.notification_title = payloadMessage[PAYLOAD_KEY_TITLE] 92 | self.notification_message = payloadMessage[PAYLOAD_KEY_MESSAGE] 93 | except json.JSONDecodeError: 94 | payloadMessage = payloadString.split(PAYLOAD_SEPARATOR) 95 | self.notification_title = payloadMessage[0] 96 | self.notification_message = PAYLOAD_SEPARATOR.join( 97 | payloadMessage[1:]) 98 | else: # self.data_mode = MODE_DATA_VIA_CONFIG 99 | self.notification_title = self.config_title 100 | self.notification_message = self.config_message 101 | 102 | # Check only the os (if it's that os, it's supported because if it wasn't supported, 103 | # an exception would be thrown in post-inits) 104 | if OsD.IsWindows(): 105 | twt.getToast( 106 | title=self.notification_title, 107 | message=self.notification_message, 108 | icon=self.config_icon_path, 109 | appId=App.getName(), 110 | isMute=False).show() 111 | 112 | else: 113 | if OsD.IsMacos(): 114 | command = commands[OsD.GetOs()].format( 115 | self.notification_message, self.notification_title) 116 | 117 | else: # Linux: 118 | command = commands[OsD.GetOs()].format( 119 | self.notification_title, self.notification_message, self.config_icon_path, App.getName()) 120 | 121 | self.RunCommand(command=command, shell=True) 122 | 123 | @classmethod 124 | def ConfigurationPreset(cls) -> MenuPreset: 125 | preset = MenuPreset() 126 | preset.AddEntry(name="Notification title", key=CONFIG_KEY_TITLE, 127 | instruction="Leave empty to send this data via remote message", mandatory=False) 128 | # ask for the message only if the title is provided, otherwise don't ask (use display_if_key_value) 129 | preset.AddEntry(name="Notification message", key=CONFIG_KEY_MESSAGE, 130 | display_if_key_value={CONFIG_KEY_TITLE: True}, mandatory=True) 131 | # Icon for notification, mac is not supported :( 132 | preset.AddEntry(name="Path to icon", key=CONFIG_KEY_ICON_PATH, 133 | mandatory=False, default=DEFAULT_ICON_PATH, 134 | question_type="filepath") 135 | return preset 136 | 137 | @classmethod 138 | def CheckSystemSupport(cls): 139 | if OsD.IsWindows(): 140 | if not supports_win: 141 | raise Exception( 142 | 'Notify not available, have you installed \'tinyWinToast\' on pip ?') 143 | 144 | elif OsD.GetOs() in commands: 145 | if not OsD.CommandExists(commands[OsD.GetOs()].split(" ")[0]): 146 | raise Exception( 147 | f'Command not found {commands[OsD.GetOs()].split(" ")[0]}!' 148 | ) 149 | 150 | else: 151 | raise cls.UnsupportedOsException() 152 | -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/Notify/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richibrics/IoTuring/4253570b603a3ccfd3b90a24ed2d102cf683d18a/IoTuring/Entity/Deployments/Notify/icon.png -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/OperatingSystem/OperatingSystem.py: -------------------------------------------------------------------------------- 1 | import platform 2 | from IoTuring.Entity.Entity import Entity 3 | from IoTuring.Entity.EntityData import EntitySensor 4 | from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD 5 | 6 | KEY_OS = 'operating_system' 7 | 8 | EXTRA_KEY_RELEASE = 'release' 9 | EXTRA_KEY_BUILD = 'build' 10 | EXTRA_KEY_DISTRO = 'distro' 11 | 12 | 13 | class OperatingSystem(Entity): 14 | NAME = "OperatingSystem" 15 | 16 | def Initialize(self): 17 | self.RegisterEntitySensor(EntitySensor( 18 | self, KEY_OS, supportsExtraAttributes=True)) 19 | 20 | # The value for this sensor is static for the entire script run time 21 | self.SetEntitySensorValue(KEY_OS, OsD.GetOs()) 22 | 23 | extra_attrs = {} 24 | 25 | # win, default 26 | extra_attrs = { 27 | EXTRA_KEY_RELEASE: platform.release(), 28 | EXTRA_KEY_BUILD: platform.version() 29 | } 30 | 31 | if OsD.IsMacos(): 32 | extra_attrs.update({ 33 | EXTRA_KEY_BUILD: "".join(list(platform.mac_ver()[1])) 34 | }) 35 | 36 | if OsD.IsLinux(): 37 | if OsD.CommandExists("lsb_release"): 38 | 39 | extra_attrs.update({ 40 | EXTRA_KEY_RELEASE: self.RunCommand("lsb_release -rs").stdout, 41 | EXTRA_KEY_DISTRO: self.RunCommand("lsb_release -is").stdout, 42 | EXTRA_KEY_BUILD: platform.release() 43 | }) 44 | 45 | for key in extra_attrs: 46 | self.SetEntitySensorExtraAttribute( 47 | KEY_OS, key, extra_attrs[key]) 48 | -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/Power/Power.py: -------------------------------------------------------------------------------- 1 | from IoTuring.Entity.Entity import Entity 2 | from IoTuring.Entity.EntityData import EntityCommand 3 | from IoTuring.Exceptions.Exceptions import UnknownConfigKeyException 4 | from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD 5 | from IoTuring.MyApp.SystemConsts import DesktopEnvironmentDetection as De 6 | from IoTuring.Configurator.MenuPreset import MenuPreset 7 | 8 | 9 | CONFIG_KEY_ENABLE_HIBERNATE = 'enable_hibernate' 10 | 11 | KEY_SHUTDOWN = 'shutdown' 12 | KEY_REBOOT = 'reboot' 13 | KEY_SLEEP = 'sleep' 14 | KEY_HIBERNATE = 'hibernate' 15 | 16 | commands = { 17 | OsD.WINDOWS: { 18 | KEY_SHUTDOWN: 'shutdown /s /t 0', 19 | KEY_REBOOT: 'shutdown /r', 20 | KEY_SLEEP: 'rundll32.exe powrprof.dll,SetSuspendState 0,1,0', 21 | KEY_HIBERNATE: 'rundll32.exe powrprof.dll,SetSuspendState Hibernate' 22 | 23 | }, 24 | OsD.MACOS: { 25 | KEY_SHUTDOWN: 'sudo shutdown -h now', 26 | KEY_REBOOT: 'sudo reboot' 27 | }, 28 | OsD.LINUX: { 29 | KEY_SHUTDOWN: 'poweroff', 30 | KEY_REBOOT: 'reboot', 31 | KEY_SLEEP: 'systemctl suspend', 32 | KEY_HIBERNATE: 'systemctl hibernate' 33 | } 34 | } 35 | 36 | linux_commands = { 37 | "gnome": { 38 | KEY_SHUTDOWN: 'gnome-session-quit --power-off', 39 | KEY_REBOOT: 'gnome-session-quit --reboot' 40 | }, 41 | "X11": { 42 | KEY_SLEEP: 'xset dpms force standby' 43 | } 44 | } 45 | 46 | 47 | class Power(Entity): 48 | NAME = "Power" 49 | 50 | def Initialize(self): 51 | self.commands = {} 52 | 53 | command_keys = [KEY_SHUTDOWN, KEY_REBOOT, KEY_SLEEP] 54 | 55 | try: 56 | if self.GetTrueOrFalseFromConfigurations(CONFIG_KEY_ENABLE_HIBERNATE): 57 | command_keys.append(KEY_HIBERNATE) 58 | except UnknownConfigKeyException: 59 | pass 60 | 61 | for command_key in command_keys: 62 | if command_key in commands[OsD.GetOs()]: 63 | self.commands[command_key] = self.GetCommand(command_key) 64 | self.RegisterEntityCommand(EntityCommand( 65 | self, command_key, self.Callback)) 66 | 67 | def Callback(self, message): 68 | # From the topic we can find the command: 69 | key = message.topic.split("/")[-1] 70 | self.RunCommand( 71 | command=self.commands[key], 72 | command_name=key 73 | ) 74 | 75 | def GetCommand(self, command_key: str) -> str: 76 | """Get the command for this command_key 77 | 78 | Args: 79 | command_key (str): KEY_SHUTDOWN, KEY_REBOOT or KEY_SLEEP 80 | 81 | Returns: 82 | str: The command string 83 | """ 84 | 85 | command = commands[OsD.GetOs()][command_key] 86 | 87 | if OsD.IsLinux(): 88 | 89 | if De.IsXsetSupported() and command_key in linux_commands['X11']: 90 | command = linux_commands['X11'][command_key] 91 | elif De.GetDesktopEnvironment() in linux_commands \ 92 | and command_key in linux_commands[De.GetDesktopEnvironment()]: 93 | command = linux_commands[De.GetDesktopEnvironment( 94 | )][command_key] 95 | 96 | elif not command.startswith("systemctl"): 97 | # Try if command works without sudo, add if it's not working: 98 | testcommand = command + " --wtmp-only" 99 | 100 | if not self.RunCommand(testcommand).returncode == 0: 101 | command = "sudo " + command 102 | 103 | self.Log(self.LOG_DEBUG, 104 | f'Found {command_key} command: {command}') 105 | 106 | return command 107 | 108 | @classmethod 109 | def ConfigurationPreset(cls) -> MenuPreset: 110 | preset = MenuPreset() 111 | 112 | if KEY_HIBERNATE in commands[OsD.GetOs()]: 113 | preset.AddEntry("Enable hibernation", 114 | CONFIG_KEY_ENABLE_HIBERNATE, default="N", 115 | question_type="yesno") 116 | return preset 117 | 118 | @classmethod 119 | def CheckSystemSupport(cls): 120 | if OsD.GetOs() not in commands: 121 | raise cls.UnsupportedOsException() 122 | -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/Ram/Ram.py: -------------------------------------------------------------------------------- 1 | import psutil 2 | from IoTuring.Entity.Entity import Entity 3 | from IoTuring.Entity.EntityData import EntitySensor 4 | from IoTuring.Entity.ValueFormat import ValueFormatter, ValueFormatterOptions 5 | 6 | # Sensor: Virtual memory 7 | KEY_MEMORY_PERCENTAGE = 'physical_memory_percentage' 8 | # Extra data keys 9 | EXTRA_KEY_MEMORY_TOTAL = 'Physical memory: total' 10 | EXTRA_KEY_MEMORY_USED = 'Physical memory: used' 11 | EXTRA_KEY_MEMORY_FREE = 'Physical memory: free' 12 | EXTRA_KEY_MEMORY_AVAILABLE = 'Physical memory: available' 13 | 14 | # Sensor: Swap memory 15 | KEY_SWAP_PERCENTAGE = 'swap_memory_percentage' 16 | # Extra data keys 17 | EXTRA_KEY_SWAP_TOTAL = 'Swap memory: total' 18 | EXTRA_KEY_SWAP_USED = 'Swap memory: used' 19 | EXTRA_KEY_SWAP_FREE = 'Swap memory: free' 20 | 21 | VALUEFORMATOPTIONS_MEMORY_PERCENTAGE = ValueFormatterOptions(ValueFormatterOptions.TYPE_PERCENTAGE, 1) 22 | VALUEFORMATOPTIONS_MEMORY_MB = ValueFormatterOptions(ValueFormatterOptions.TYPE_BYTE, 0, "MB") 23 | 24 | class Ram(Entity): 25 | NAME = "Ram" 26 | 27 | def Initialize(self): 28 | self.RegisterEntitySensor(EntitySensor(self, KEY_MEMORY_PERCENTAGE, valueFormatterOptions=VALUEFORMATOPTIONS_MEMORY_PERCENTAGE, supportsExtraAttributes=True)) 29 | self.RegisterEntitySensor(EntitySensor(self, KEY_SWAP_PERCENTAGE, valueFormatterOptions=VALUEFORMATOPTIONS_MEMORY_PERCENTAGE, supportsExtraAttributes=True)) 30 | 31 | def Update(self): 32 | # Virtual memory 33 | self.SetEntitySensorValue(KEY_MEMORY_PERCENTAGE, psutil.virtual_memory()[2]) 34 | 35 | # Extra 36 | self.SetEntitySensorExtraAttribute(KEY_MEMORY_PERCENTAGE, EXTRA_KEY_MEMORY_TOTAL, psutil.virtual_memory()[0], valueFormatterOptions=VALUEFORMATOPTIONS_MEMORY_MB) 37 | self.SetEntitySensorExtraAttribute(KEY_MEMORY_PERCENTAGE, EXTRA_KEY_MEMORY_USED, psutil.virtual_memory()[3], valueFormatterOptions=VALUEFORMATOPTIONS_MEMORY_MB) 38 | self.SetEntitySensorExtraAttribute(KEY_MEMORY_PERCENTAGE, EXTRA_KEY_MEMORY_AVAILABLE, psutil.virtual_memory()[1], valueFormatterOptions=VALUEFORMATOPTIONS_MEMORY_MB) 39 | self.SetEntitySensorExtraAttribute(KEY_MEMORY_PERCENTAGE, EXTRA_KEY_MEMORY_FREE, psutil.virtual_memory()[4], valueFormatterOptions=VALUEFORMATOPTIONS_MEMORY_MB) 40 | 41 | # Swap memory 42 | self.SetEntitySensorValue(KEY_SWAP_PERCENTAGE, psutil.swap_memory()[3]) 43 | 44 | # Extra 45 | self.SetEntitySensorExtraAttribute(KEY_SWAP_PERCENTAGE, EXTRA_KEY_SWAP_TOTAL, psutil.swap_memory()[0], valueFormatterOptions=VALUEFORMATOPTIONS_MEMORY_MB) 46 | self.SetEntitySensorExtraAttribute(KEY_SWAP_PERCENTAGE, EXTRA_KEY_SWAP_USED, psutil.swap_memory()[1], valueFormatterOptions=VALUEFORMATOPTIONS_MEMORY_MB) 47 | self.SetEntitySensorExtraAttribute(KEY_SWAP_PERCENTAGE, EXTRA_KEY_SWAP_FREE, psutil.swap_memory()[2], valueFormatterOptions=VALUEFORMATOPTIONS_MEMORY_MB) 48 | -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/Temperature/Temperature.py: -------------------------------------------------------------------------------- 1 | import psutil 2 | from IoTuring.Entity.Entity import Entity 3 | from IoTuring.Entity.EntityData import EntitySensor 4 | from IoTuring.Entity.ValueFormat import ValueFormatter, ValueFormatterOptions 5 | from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD # don't name Os as could be a problem with old configurations that used the Os entity 6 | 7 | KEY_SENSOR_FORMAT = "{}" 8 | FALLBACK_PACKAGE_LABEL = "package_{}" 9 | FALLBACK_SENSOR_LABEL = "sensor_{}" 10 | TEMPERATURE_DECIMALS = 1 11 | 12 | INVALID_SMC_VALUE_HIGH = 120 # skip sensors if when initialized they return a value higher than this 13 | MACOS_SMC_TEMPERATURE_KEYS = { 14 | "CPU core A": "TC0c", 15 | "CPU core B": "TC1c", 16 | "CPU core C": "TC2c", 17 | "CPU core D": "TC3c", 18 | "CPU core E": "TC4c", 19 | "CPU core F": "TC5c", 20 | "CPU core G": "TC6c", 21 | "CPU core H": "TC7c", 22 | "CPU core I": "TC8c", 23 | "CPU core J": "TC9c", 24 | "CPU performance core 1 temperature": "Tp01", # ARM 25 | "CPU performance core 2 temperature": "Tp05", # ARM 26 | "CPU performance core 3 temperature": "Tp0D", # ARM 27 | "CPU performance core 4 temperature": "Tp0H", # ARM 28 | "CPU performance core 5 temperature": "Tp0L", # ARM 29 | "CPU performance core 6 temperature": "Tp0P", # ARM 30 | "CPU performance core 7 temperature": "Tp0X", # ARM 31 | "CPU performance core 8 temperature": "Tp0b", # ARM 32 | "CPU efficient core 1 temperature": "Tp09", # ARM 33 | "CPU efficient core 2 temperature": "Tp0T" # ARM 34 | } 35 | 36 | class Temperature(Entity): 37 | NAME = "Temperature" 38 | 39 | def Initialize(self): 40 | self.temperatureFormatOptions = ValueFormatterOptions(value_type=ValueFormatterOptions.TYPE_TEMPERATURE, decimals=TEMPERATURE_DECIMALS) 41 | 42 | self.specificInitialize = None 43 | self.specificUpdate = None 44 | 45 | if OsD.IsLinux(): 46 | self.specificInitialize = self.InitLinux 47 | self.specificUpdate = self.UpdateLinux 48 | elif OsD.IsMacos(): 49 | self.specificInitialize = self.InitmacOS 50 | self.specificUpdate = self.UpdatemacOS 51 | 52 | if self.specificInitialize: 53 | self.specificInitialize() 54 | 55 | def Update(self): 56 | if self.specificUpdate: 57 | self.specificUpdate() 58 | 59 | def InitmacOS(self): 60 | import ioturing_applesmc # type:ignore 61 | self.RegisterEntitySensor(EntitySensor(self, "cpu", supportsExtraAttributes=True, valueFormatterOptions=self.temperatureFormatOptions)) 62 | self.valid_keys = [] 63 | # get smc values for all the keys I have in the dictionary and remember 64 | # the keys that do not return a 0.0 value 65 | ioturing_applesmc.open_smc() 66 | for key, value in MACOS_SMC_TEMPERATURE_KEYS.items(): 67 | smc_value = ioturing_applesmc.get_temperature(value) 68 | if smc_value != 0.0 and smc_value < INVALID_SMC_VALUE_HIGH: 69 | self.valid_keys.append(value) 70 | if len(self.valid_keys) == 0: 71 | raise Exception("No valid sensor found for cpu temperature.") 72 | 73 | # get smc values for all valid keys previously found, then set the sensor value 74 | # to the highest value found. Save also the other values in the extra attributes. 75 | def UpdatemacOS(self): 76 | import ioturing_applesmc # type:ignore 77 | values = {} 78 | # key = description, value = smc key 79 | for key, value in MACOS_SMC_TEMPERATURE_KEYS.items(): 80 | if value in self.valid_keys: # in valid_keys I have only the smc keys that returned a value != 0.0 81 | values[key] = ioturing_applesmc.get_temperature(value) 82 | 83 | # Set main value to the highest value found 84 | self.SetEntitySensorValue("cpu", max(values.values())) 85 | # Set extra attributes 86 | for key, value in values.items(): 87 | self.SetEntitySensorExtraAttribute("cpu", key, values[key], valueFormatterOptions=self.temperatureFormatOptions) 88 | 89 | # I don't register packages that do not have Current temperature, so I store the registered in a list which is then checked during update 90 | def InitLinux(self): 91 | self.registeredPackages = [] 92 | sensors = psutil.sensors_temperatures() 93 | index = 1 94 | for pkgName, data in sensors.items(): 95 | if pkgName == None or pkgName == "": 96 | pkgName = FALLBACK_PACKAGE_LABEL.format(index) 97 | package = psutilTemperaturePackage(pkgName, data) 98 | if package.hasCurrent(): 99 | self.registeredPackages.append(package.getLabel()) 100 | self.RegisterEntitySensor(EntitySensor( 101 | self, self.packageNameToEntitySensorKey(package.getLabel()), supportsExtraAttributes=True, valueFormatterOptions=self.temperatureFormatOptions)) 102 | index += 1 103 | 104 | def UpdateLinux(self): 105 | sensors = psutil.sensors_temperatures() 106 | index = 1 107 | for pkgName, data in sensors.items(): 108 | if pkgName == None or pkgName == "": 109 | pkgName = FALLBACK_PACKAGE_LABEL.format(index) 110 | package = psutilTemperaturePackage(pkgName, data) 111 | if package.getLabel() in self.registeredPackages: 112 | # Set main value = current of the package 113 | self.SetEntitySensorValue(self.packageNameToEntitySensorKey( 114 | package.getLabel()), package.getCurrent()) 115 | 116 | # Set extra attributes 117 | for key, value in package.getAttributesDict().items(): 118 | self.SetEntitySensorExtraAttribute(self.packageNameToEntitySensorKey( 119 | package.getLabel()), key, value, valueFormatterOptions=self.temperatureFormatOptions) 120 | 121 | index += 1 122 | 123 | def packageNameToEntitySensorKey(self, packageName): 124 | return KEY_SENSOR_FORMAT.format(packageName) 125 | 126 | 127 | @classmethod 128 | def CheckSystemSupport(cls): 129 | if not OsD.IsLinux() and not OsD.IsMacos(): 130 | raise cls.UnsupportedOsException() 131 | 132 | class psutilTemperaturePackage(): 133 | def __init__(self, packageName, packageData) -> None: 134 | """ packageData is the value of the the dict returned by psutil.sensors_temperatures() """ 135 | self.packageName = packageName 136 | self.sensors = [] 137 | for sensor in packageData: 138 | self.sensors.append(psutilTemperatureSensor(sensor)) 139 | 140 | def getLabel(self) -> str: 141 | return self.packageName 142 | 143 | def getSensors(self) -> list: 144 | return self.sensors.copy() 145 | 146 | # Package stats strategies here: my choice is to return always the highest among the temperatures, critical: here will return the lowest 147 | def getCurrent(self): 148 | """ Returns highest current temperature among this package sensors. None if any sensor has that data. """ 149 | highest = None 150 | for sensor in self.getSensors(): 151 | if sensor.hasCurrent() and (highest == None or highest < sensor.getCurrent()): 152 | highest = sensor.getCurrent() 153 | return highest 154 | 155 | def getHighest(self): 156 | """ Returns highest highest temperature among this package sensors. None if any sensor has that data. """ 157 | highest = None 158 | for sensor in self.getSensors(): 159 | if sensor.hasHighest() and (highest == None or highest < sensor.getHighest()): 160 | highest = sensor.getHighest() 161 | return highest 162 | 163 | def getCritical(self): 164 | """ Returns lower critical temperature among this package sensors. None if any sensor has that data. """ 165 | lowest = None 166 | for sensor in self.getSensors(): 167 | if sensor.hasCritical() and (lowest == None or lowest > sensor.getCritical()): 168 | lowest = sensor.getCritical() 169 | return lowest 170 | 171 | def hasCurrent(self): 172 | """ True if at least a sensor of the package has the current property """ 173 | for sensor in self.sensors: 174 | if sensor.hasCurrent(): 175 | return True 176 | return False 177 | 178 | def hasHighest(self): 179 | """ True if at least a sensor of the package has the highest property """ 180 | for sensor in self.sensors: 181 | if sensor.hasHighest(): 182 | return True 183 | return False 184 | 185 | def hasCritical(self): 186 | """ True if at least a sensor of the package has the critical property """ 187 | for sensor in self.sensors: 188 | if sensor.hasCritical(): 189 | return True 190 | return False 191 | 192 | def getAttributesDict(self): 193 | attributes = {} 194 | for index, sensor in enumerate(self.getSensors()): 195 | if sensor.hasLabel(): 196 | label = sensor.getLabel() 197 | else: 198 | label = FALLBACK_SENSOR_LABEL.format(index) 199 | if sensor.hasCurrent(): 200 | attributes[f"{label} - Current"] = sensor.getCurrent() 201 | if sensor.hasHighest(): 202 | attributes[f"{label} - Highest"] = sensor.getHighest() 203 | if sensor.hasCritical(): 204 | attributes[ f"{label} - Critical"] = sensor.getCritical() 205 | return attributes 206 | 207 | 208 | class psutilTemperatureSensor(): 209 | def __init__(self, sensorData) -> None: 210 | """ sensorData is an element from the list which is the value of the the dict returned by psutil.sensors_temperatures() """ 211 | self.label = sensorData[0] 212 | self.current = sensorData[1] 213 | self.highest = sensorData[2] 214 | self.critical = sensorData[3] 215 | 216 | def getCurrent(self): 217 | return self.current 218 | 219 | def getLabel(self) -> str: 220 | return self.label 221 | 222 | def getHighest(self): 223 | return self.highest 224 | 225 | def getCritical(self): 226 | return self.critical 227 | 228 | def hasLabel(self): 229 | """ True if a label is set for this sensor """ 230 | return not self.label == None and not self.label.strip() == "" 231 | 232 | def hasCurrent(self): 233 | """ True if a current is set for this sensor """ 234 | return not self.current == None 235 | 236 | def hasHighest(self): 237 | """ True if a highest is set for this sensor """ 238 | return not self.highest == None 239 | 240 | def hasCritical(self): 241 | """ True if a critical is set for this sensor """ 242 | return not self.critical == None 243 | -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/Time/Time.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from IoTuring.Entity.Entity import Entity 3 | from IoTuring.Entity.EntityData import EntitySensor 4 | 5 | KEY_NOW = 'now' 6 | 7 | 8 | class Time(Entity): 9 | NAME = "Time" 10 | 11 | def Initialize(self): 12 | self.RegisterEntitySensor(EntitySensor(self, KEY_NOW)) 13 | 14 | def Update(self): 15 | self.SetEntitySensorValue(KEY_NOW, self.GetCurrentTime()) 16 | 17 | def GetCurrentTime(self): 18 | return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") 19 | -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/UpTime/UpTime.py: -------------------------------------------------------------------------------- 1 | import psutil 2 | import time 3 | from IoTuring.Entity.Entity import Entity 4 | from IoTuring.Entity.EntityData import EntitySensor 5 | from IoTuring.Entity.ValueFormat import ValueFormatter, ValueFormatterOptions 6 | 7 | KEY = 'uptime' 8 | 9 | 10 | class UpTime(Entity): 11 | NAME = "UpTime" 12 | 13 | def Initialize(self): 14 | self.RegisterEntitySensor(EntitySensor(self, KEY, valueFormatterOptions=ValueFormatterOptions(ValueFormatterOptions.TYPE_TIME, 0, "m"))) 15 | 16 | def Update(self): 17 | self.SetEntitySensorValue(KEY, time.time() - psutil.boot_time()) 18 | -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/Username/Username.py: -------------------------------------------------------------------------------- 1 | import os 2 | from IoTuring.Entity.Entity import Entity 3 | from IoTuring.Entity.EntityData import EntitySensor 4 | 5 | KEY_USERNAME = "username" 6 | 7 | 8 | class Username(Entity): 9 | NAME = "Username" 10 | 11 | def Initialize(self): 12 | self.RegisterEntitySensor(EntitySensor(self, KEY_USERNAME)) 13 | self.SetEntitySensorValue(KEY_USERNAME, self.GetUsername()) 14 | 15 | 16 | def GetUsername(self): 17 | # Gives user's home directory 18 | userhome = os.path.expanduser('~') 19 | 20 | # Gives username by splitting path based on OS 21 | return os.path.split(userhome)[-1] 22 | -------------------------------------------------------------------------------- /IoTuring/Entity/Deployments/Volume/Volume.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from IoTuring.Entity.Entity import Entity 4 | from IoTuring.Entity.EntityData import EntityCommand, EntitySensor 5 | from IoTuring.Entity.ValueFormat import ValueFormatterOptions 6 | from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD 7 | 8 | KEY_STATE = 'volume_state' 9 | KEY_CMD = 'volume' 10 | 11 | EXTRA_KEY_MUTED_OUTPUT = 'Muted output' 12 | EXTRA_KEY_OUTPUT_VOLUME = 'Output volume' 13 | EXTRA_KEY_INPUT_VOLUME = 'Input volume' 14 | EXTRA_KEY_ALERT_VOLUME = 'Alert volume' 15 | VALUEFORMATTEROPTIONS_PERCENTAGE_ROUND0 = ValueFormatterOptions( 16 | value_type=ValueFormatterOptions.TYPE_PERCENTAGE, decimals=0) 17 | 18 | commands = { 19 | OsD.LINUX: 'pactl set-sink-volume @DEFAULT_SINK@ {}%', 20 | OsD.MACOS: 'osascript -e "set volume output volume {}"' 21 | } 22 | 23 | UNMUTE_PREFIX_LINUX = 'pactl set-sink-mute @DEFAULT_SINK@ 0' 24 | 25 | 26 | class Volume(Entity): 27 | NAME = "Volume" 28 | 29 | def Initialize(self): 30 | 31 | # Register: 32 | self.RegisterEntitySensor(EntitySensor( 33 | self, KEY_STATE, 34 | supportsExtraAttributes=True, 35 | valueFormatterOptions=VALUEFORMATTEROPTIONS_PERCENTAGE_ROUND0)) 36 | self.RegisterEntityCommand(EntityCommand( 37 | self, KEY_CMD, self.Callback, KEY_STATE)) 38 | 39 | def Update(self): 40 | if OsD.IsMacos(): 41 | self.UpdateMac() 42 | elif OsD.IsLinux(): 43 | # Check if it's muted: 44 | p = self.RunCommand(command="pactl get-sink-mute @DEFAULT_SINK@", 45 | shell=True) 46 | output_muted = bool(re.search("Mute: yes", p.stdout)) 47 | 48 | if output_muted: 49 | output_volume = 0 50 | 51 | else: 52 | # Example: 'Volume: front-left: 39745 / 61% / -13,03 dB, ... 53 | # Only care about the first percent. 54 | p = self.RunCommand(command="pactl get-sink-volume @DEFAULT_SINK@", 55 | shell=True) 56 | m = re.search(r"/ +(\d{1,3})% /", p.stdout) 57 | 58 | if not m: 59 | raise Exception(f"Error getting volume from {p.stdout}") 60 | 61 | output_volume = m.group(1) 62 | 63 | self.SetEntitySensorValue(KEY_STATE, output_volume) 64 | self.SetEntitySensorExtraAttribute( 65 | KEY_STATE, EXTRA_KEY_MUTED_OUTPUT, output_muted) 66 | 67 | def Callback(self, message): 68 | payloadString = message.payload.decode('utf-8') 69 | 70 | # parse the payload and get the volume number which is between 0 and 100 71 | volume = int(payloadString) 72 | if not 0 <= volume <= 100: 73 | raise Exception('Incorrect payload!') 74 | 75 | cmd = commands[OsD.GetOs()] 76 | 77 | # Unmute on Linux: 78 | if 0 < volume and OsD.IsLinux(): 79 | cmd = UNMUTE_PREFIX_LINUX + " && " + cmd 80 | 81 | self.RunCommand(command=cmd.format(volume), 82 | shell=True) 83 | 84 | def UpdateMac(self): 85 | # result like: output volume:44, input volume:89, alert volume:100, output muted:false 86 | command = self.RunCommand( 87 | command=['osascript', '-e', 'get volume settings']) 88 | result = command.stdout.strip().split(',') 89 | 90 | output_volume = result[0].split(':')[1] 91 | input_volume = result[1].split(':')[1] 92 | alert_volume = result[2].split(':')[1] 93 | output_muted = False if result[3].split(':')[1] == 'false' else True 94 | 95 | self.SetEntitySensorValue(KEY_STATE, output_volume) 96 | self.SetEntitySensorExtraAttribute( 97 | KEY_STATE, EXTRA_KEY_OUTPUT_VOLUME, output_volume, valueFormatterOptions=VALUEFORMATTEROPTIONS_PERCENTAGE_ROUND0) 98 | self.SetEntitySensorExtraAttribute( 99 | KEY_STATE, EXTRA_KEY_INPUT_VOLUME, input_volume, valueFormatterOptions=VALUEFORMATTEROPTIONS_PERCENTAGE_ROUND0) 100 | self.SetEntitySensorExtraAttribute( 101 | KEY_STATE, EXTRA_KEY_ALERT_VOLUME, alert_volume, valueFormatterOptions=VALUEFORMATTEROPTIONS_PERCENTAGE_ROUND0) 102 | self.SetEntitySensorExtraAttribute( 103 | KEY_STATE, EXTRA_KEY_MUTED_OUTPUT, output_muted) 104 | 105 | @classmethod 106 | def CheckSystemSupport(cls): 107 | if OsD.IsLinux(): 108 | if not OsD.CommandExists("pactl"): 109 | raise Exception( 110 | "Only PulseAudio is supported on Linux! Please open an issue on Github!") 111 | elif not OsD.IsMacos(): 112 | raise cls.UnsupportedOsException() 113 | -------------------------------------------------------------------------------- /IoTuring/Entity/Entity.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | if TYPE_CHECKING: 4 | from IoTuring.Configurator.Configuration import SingleConfiguration 5 | from IoTuring.Entity.EntityData import EntityData, EntitySensor, EntityCommand, ExtraAttribute 6 | 7 | 8 | import time 9 | import subprocess 10 | 11 | from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject 12 | from IoTuring.Logger.LogObject import LogObject 13 | from IoTuring.Exceptions.Exceptions import UnknownEntityKeyException 14 | 15 | from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD 16 | 17 | from IoTuring.Settings.Deployments.AppSettings.AppSettings import AppSettings, CONFIG_KEY_UPDATE_INTERVAL 18 | 19 | 20 | class Entity(ConfiguratorObject, LogObject): 21 | 22 | entitySensors: list[EntitySensor] 23 | entityCommands: list[EntityCommand] 24 | 25 | def __init__(self, single_configuration: SingleConfiguration) -> None: 26 | super().__init__(single_configuration) 27 | 28 | # Prepare the entity 29 | self.entitySensors = [] 30 | self.entityCommands = [] 31 | 32 | self.tag = self.GetConfigurations().GetTag() 33 | 34 | # When I update the values this number changes (randomly) so each warehouse knows I have updated 35 | self.valuesID = 0 36 | 37 | self.updateTimeout = int( 38 | AppSettings.GetFromSettingsConfigurations(CONFIG_KEY_UPDATE_INTERVAL)) 39 | 40 | def Initialize(self): 41 | """ Must be implemented in sub-classes, may be useful here to use the configuration """ 42 | pass 43 | 44 | def CallInitialize(self) -> bool: 45 | """ Safe method to run the Initialize function. Returns True if no error occcured. """ 46 | try: 47 | self.CheckSystemSupport() 48 | self.Initialize() 49 | self.Log(self.LOG_INFO, "Initialization successfully completed") 50 | except Exception as e: 51 | self.Log(self.LOG_ERROR, 52 | "Initialization interrupted due to an error:") 53 | self.Log(self.LOG_ERROR, e) 54 | return False 55 | return True 56 | 57 | def CallUpdate(self): # Call the Update method safely 58 | """ Safe method to run the Update function """ 59 | try: 60 | self.Update() 61 | except Exception as exc: 62 | # TODO I need an exception manager 63 | self.Log(self.LOG_ERROR, 'Error occured during update: ' + str(exc)) 64 | #  self.entityManager.UnloadEntity(self) # TODO Think how to improve this 65 | 66 | def Update(self): 67 | """ Must be implemented in sub-classes """ 68 | # Can't be called directly, cause stops everything in exception, call only using CallUpdate 69 | pass 70 | 71 | def SetEntitySensorValue(self, key, value) -> None: 72 | """ Set the value for an entity sensor """ 73 | self.GetEntitySensorByKey(key).SetValue(value) 74 | 75 | def GetEntitySensorValue(self, key) -> str | int | float: 76 | """ Get value using its entity sensor key if the value is present (else raise an exception) """ 77 | if not self.GetEntitySensorByKey(key).HasValue(): 78 | raise Exception( 79 | "The Entity sensor you asked for hasn't got a value") 80 | return self.GetEntitySensorByKey(key).GetValue() 81 | 82 | def HasEntitySensorExtraAttributes(self, key) -> bool: 83 | """ Check if EntitySensor has an extra attributes dict """ 84 | return self.GetEntitySensorByKey(key).HasExtraAttributes() 85 | 86 | def GetEntitySensorExtraAttributes(self, key) -> list[ExtraAttribute]: 87 | """ Get attributes using its entity sensor key if the extra attributes are present (else raise an exception) """ 88 | if not self.GetEntitySensorByKey(key).HasExtraAttributes(): 89 | raise Exception( 90 | "The Entity sensor you asked for hasn't got extra attributes") 91 | return self.GetEntitySensorByKey(key).GetExtraAttributes() 92 | 93 | def SetEntitySensorExtraAttribute(self, sensorDataKey, attributeKey, attributeValue, valueFormatterOptions=None) -> None: 94 | """ Set the extra attribute for an entity sensor """ 95 | self.GetEntitySensorByKey(sensorDataKey).SetExtraAttribute( 96 | attributeKey, attributeValue, valueFormatterOptions) 97 | 98 | def SetUpdateTimeout(self, timeout) -> None: 99 | """ Set how much time to wait between 2 updates """ 100 | self.updateTimeout = timeout 101 | 102 | def ShouldUpdate(self) -> bool: 103 | """ Wait the correct timeout time and then the update will run """ 104 | time.sleep(self.updateTimeout) 105 | return True 106 | 107 | def LoopThread(self) -> None: 108 | """ Entry point of Entity thread, will run the Update function periodically """ 109 | self.CallUpdate() # first call 110 | while (True): 111 | if self.ShouldUpdate(): 112 | self.CallUpdate() 113 | 114 | def RegisterEntitySensor(self, entitySensor: EntitySensor): 115 | """ Add EntitySensor to the Entity. This action must be in Initialize """ 116 | self.entitySensors.append(entitySensor) 117 | 118 | def RegisterEntityCommand(self, entityCommand: EntityCommand): 119 | """ Add EntityCommand to the Entity. This action must be in Initialize, so the Warehouses can subscribe to them at initializing time""" 120 | self.entityCommands.append(entityCommand) 121 | 122 | def GetEntitySensors(self) -> list[EntitySensor]: 123 | """ safe - Return list of registered entity sensors """ 124 | return self.entitySensors.copy() # Safe return: nobody outside can change the value ! 125 | 126 | def GetEntityCommands(self) -> list[EntityCommand]: 127 | """ safe - Return list of registered entity commands """ 128 | return self.entityCommands.copy() # Safe return: nobody outside can change the callback ! 129 | 130 | def GetAllEntityData(self) -> list: 131 | """ safe - Return list of entity sensors and commands """ 132 | return self.entityCommands.copy() + self.entitySensors.copy() # Safe return: nobody outside can change the callback ! 133 | 134 | def GetAllUnconnectedEntityData(self) -> list[EntityCommand|EntitySensor]: 135 | """ safe - Return All EntityCommands and EntitySensors without connected sensors """ 136 | connected_sensors = [] 137 | for command in self.entityCommands: 138 | connected_sensors.extend(command.GetConnectedEntitySensors()) 139 | 140 | unconnected_sensors = [sensor for sensor in self.entitySensors 141 | if sensor not in connected_sensors] 142 | return self.entityCommands.copy() + unconnected_sensors.copy() 143 | 144 | def GetEntitySensorByKey(self, key) -> EntitySensor: 145 | try: 146 | sensor = next((s for s in self.entitySensors if s.GetKey() == key)) 147 | return sensor 148 | except StopIteration: 149 | raise UnknownEntityKeyException(key) 150 | 151 | def GetEntityName(self) -> str: 152 | """ Return entity name """ 153 | return self.NAME 154 | 155 | def GetEntityTag(self) -> str: 156 | """ Return entity identifier tag """ 157 | return self.tag 158 | 159 | def GetEntityNameWithTag(self) -> str: 160 | """ Return entity name and tag combined (or name alone if no tag is present) """ 161 | if self.tag: 162 | return self.GetEntityName()+" @" + self.GetEntityTag() 163 | else: 164 | return self.GetEntityName() 165 | 166 | def GetEntityId(self) -> str: 167 | if self.tag: 168 | return "Entity." + self.GetEntityName() + "." + self.tag 169 | else: 170 | return "Entity." + self.GetEntityName() 171 | 172 | def LogSource(self): 173 | return self.GetEntityId() 174 | 175 | def RunCommand(self, 176 | command: str | list, 177 | command_name: str = "", 178 | log_errors: bool = True, 179 | shell: bool = False, 180 | **kwargs) -> subprocess.CompletedProcess: 181 | """Safely call a subprocess. Kwargs are other Subprocess options 182 | 183 | Args: 184 | command (str | list): The command to call 185 | command_name (str, optional): For logging, if empty entity name will be used. 186 | log_errors (bool, optional): Log stderr of command. Use False when failure is expected. Defaults to True. 187 | shell (bool, optional): Run in shell. Defaults to False. 188 | **kwargs: subprocess args 189 | 190 | Returns: 191 | subprocess.CompletedProcess: See subprocess docs 192 | """ 193 | 194 | try: 195 | 196 | if command_name: 197 | command_name = self.NAME + "-" + command_name 198 | else: 199 | command_name = self.NAME 200 | 201 | p = OsD.RunCommand(command, shell=shell, **kwargs) 202 | 203 | self.Log(self.LOG_DEBUG, f"Called {command_name} command: {p}") 204 | 205 | # Do not log errors: 206 | error_loglevel = self.LOG_ERROR if log_errors else self.LOG_DEBUG 207 | if p.stderr: 208 | self.Log(error_loglevel, 209 | f"Error during {command_name} command: {p.stderr}") 210 | 211 | return p 212 | 213 | except Exception as e: 214 | raise Exception(f"Error during {command_name} command: {str(e)}") 215 | 216 | 217 | @classmethod 218 | def CheckSystemSupport(cls): 219 | """Must be implemented in subclasses. Raise an exception if system not supported.""" 220 | return 221 | 222 | @classmethod 223 | def SystemSupported(cls) -> bool: 224 | """Check if the sysytem supported by this entity. 225 | 226 | Returns: 227 | bool: True if supported 228 | """ 229 | try: 230 | cls.CheckSystemSupport() 231 | return True 232 | except: 233 | return False 234 | 235 | class UnsupportedOsException(Exception): 236 | def __init__(self) -> None: 237 | super().__init__(f"Unsupported operating system: {OsD.GetOs()}") 238 | -------------------------------------------------------------------------------- /IoTuring/Entity/EntityData.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING, Callable 3 | if TYPE_CHECKING: 4 | from IoTuring.Entity.Entity import Entity 5 | 6 | from IoTuring.Logger.LogObject import LogObject 7 | from IoTuring.Entity.ValueFormat import ValueFormatter 8 | 9 | # EntitySensor extra attribute aren't read from all the warehouses 10 | 11 | 12 | class EntityData(LogObject): 13 | 14 | def __init__(self, entity: Entity, key, customPayload={}) -> None: 15 | self.entityId = entity.GetEntityId() 16 | self.id = self.entityId + "." + key 17 | self.key = key 18 | self.entity = entity 19 | self.customPayload = customPayload 20 | 21 | def GetEntity(self) -> Entity: 22 | return self.entity 23 | 24 | def GetId(self): 25 | return self.id 26 | 27 | def GetKey(self): 28 | return self.key 29 | 30 | def LogSource(self): 31 | return self.GetId() 32 | 33 | def GetCustomPayload(self): 34 | return self.customPayload 35 | 36 | 37 | class EntitySensor(EntityData): 38 | 39 | value: str | int | float 40 | extraAttributes: list[ExtraAttribute] 41 | 42 | def __init__(self, entity, key, 43 | valueFormatterOptions=None, 44 | supportsExtraAttributes=False, 45 | customPayload={}): 46 | """ 47 | If supportsExtraAttributes is True, the entity sensor can have extra attributes. 48 | valueFormatterOptions is a IoTuring.Entity.ValueFormat.ValueFormatterOptions object. 49 | CustomPayload overrides HomeAssistant discovery configuration 50 | """ 51 | EntityData.__init__(self, entity, key, customPayload) 52 | self.supportsExtraAttributes = supportsExtraAttributes 53 | self.valueFormatterOptions = valueFormatterOptions 54 | 55 | def DoesSupportExtraAttributes(self) -> bool: 56 | return self.supportsExtraAttributes 57 | 58 | def GetValueFormatterOptions(self): 59 | return self.valueFormatterOptions 60 | 61 | def GetValue(self) -> str | int | float: 62 | if self.HasValue(): 63 | return self.value 64 | else: 65 | raise Exception("No value for this sensor!") 66 | 67 | def SetValue(self, value) -> None: 68 | self.Log(self.LOG_DEBUG, "Set to " + str(value)) 69 | self.value = value 70 | 71 | def HasValue(self) -> bool: 72 | """ True if self.value isn't empty """ 73 | return hasattr(self, "value") 74 | 75 | def GetExtraAttributes(self) -> list[ExtraAttribute]: 76 | """ Get extra attribute objects as a list """ 77 | if not self.supportsExtraAttributes: 78 | raise Exception( 79 | "This entity sensor does not support extra attributes. Please specify it when initializing the sensor.") 80 | elif not self.HasExtraAttributes(): 81 | raise Exception( 82 | "No extra attribute set yet!" 83 | ) 84 | return self.extraAttributes 85 | 86 | def GetFormattedExtraAtributes(self, includeUnit: bool) -> dict[str, str]: 87 | """ Get extra attributes names and formatted values as a dict """ 88 | formattedExtraAttributes = {} 89 | for extraAttr in self.GetExtraAttributes(): 90 | formatted_value = ValueFormatter.FormatValue( 91 | extraAttr.GetValue(), 92 | extraAttr.GetValueFormatterOptions(), 93 | includeUnit) 94 | formattedExtraAttributes[extraAttr.GetName()] = formatted_value 95 | return formattedExtraAttributes 96 | 97 | def HasExtraAttributes(self): 98 | """ True if self.extraAttributes isn't empty """ 99 | return hasattr(self, "extraAttributes") 100 | 101 | def SetExtraAttribute(self, attribute_name, attribute_value, valueFormatterOptions=None): 102 | if not self.supportsExtraAttributes: 103 | raise Exception( 104 | "This entity sensor does not support extra attributes. Please specify it when initializing the sensor.") 105 | if not self.HasExtraAttributes(): 106 | self.extraAttributes = [] 107 | 108 | # If the Attribute does not already exists, create it, otherwise update it 109 | extraAttributeObj = next( 110 | (attr for attr in self.extraAttributes if attr.GetName() == attribute_name), None) 111 | if not extraAttributeObj: 112 | self.extraAttributes.append(ExtraAttribute( 113 | attribute_name, attribute_value, valueFormatterOptions)) 114 | else: 115 | extraAttributeObj.SetValue(attribute_value) 116 | 117 | 118 | class EntityCommand(EntityData): 119 | 120 | def __init__(self, entity: Entity, key: str, callbackFunction: Callable, 121 | connectedEntitySensorKeys: str | list = [], 122 | customPayload={}): 123 | """Create a new EntityCommand. 124 | 125 | If key or keys for the entity sensor is passed, warehouses that support it can use this command as a switch with state. 126 | Order of sensors matter, first sensors state topic will be used. 127 | Better to register the sensors before this command to avoid unexpected behaviours. 128 | 129 | Args: 130 | entity (Entity): The entity this command belongs to. 131 | key (str): The KEY of this command 132 | callbackFunction (Callable): Function to be called 133 | connectedEntitySensorKeys (str | list, optional): A key to a sensor or a list of keys. Defaults to []. 134 | customPayload (dict, optional): Overrides HomeAssistant discovery configuration. Defaults to {}. 135 | """ 136 | 137 | EntityData.__init__(self, entity, key, customPayload) 138 | self.callbackFunction = callbackFunction 139 | self.connectedEntitySensorKeys = connectedEntitySensorKeys if isinstance( 140 | connectedEntitySensorKeys, list) else [connectedEntitySensorKeys] 141 | 142 | def SupportsState(self) -> bool: 143 | """ True if this command supports state (has a connected sensors) """ 144 | return bool(self.connectedEntitySensorKeys) 145 | 146 | def GetConnectedEntitySensors(self) -> list[EntitySensor]: 147 | """ Returns the entity sensors connected to this command. Returns empty list if none found. """ 148 | return [self.GetEntity().GetEntitySensorByKey(key) for key in self.connectedEntitySensorKeys] 149 | 150 | def CallCallback(self, message): 151 | """ Safely run callback for this command, passing the message (a paho.mqtt.client.MQTTMessage). 152 | Reutrns True if callback was run correctly, False if an error occurred.""" 153 | self.Log(self.LOG_DEBUG, "Callback") 154 | try: 155 | self.RunCallback(message) 156 | return True 157 | except Exception as e: 158 | self.Log(self.LOG_ERROR, "Error while running callback: " + str(e)) 159 | return False 160 | 161 | def RunCallback(self, message): 162 | """ Called only by CallCallback. 163 | Run callback for this command, passing the message (a paho.mqtt.client.MQTTMessage) """ 164 | self.callbackFunction(message) 165 | 166 | 167 | class ExtraAttribute(): 168 | def __init__(self, name, value, valueFormatterOptions=None): 169 | self.name = name 170 | self.value = value 171 | self.valueFormatterOptions = valueFormatterOptions 172 | 173 | def GetName(self): 174 | return self.name 175 | 176 | def GetValue(self): 177 | return self.value 178 | 179 | def GetValueFormatterOptions(self): 180 | return self.valueFormatterOptions 181 | 182 | def HasValueFormatterOptions(self): 183 | return self.valueFormatterOptions is not None 184 | 185 | def SetValue(self, value): 186 | self.value = value 187 | -------------------------------------------------------------------------------- /IoTuring/Entity/EntityManager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | if TYPE_CHECKING: 4 | from IoTuring.Entity.Entity import Entity 5 | 6 | from threading import Thread 7 | from IoTuring.Logger.LogObject import LogObject 8 | from IoTuring.Logger.Logger import Singleton 9 | 10 | 11 | class EntityManager(LogObject, metaclass=Singleton): 12 | 13 | def __init__(self) -> None: 14 | 15 | # Where I store the entities that update periodically that have an active behaviour: can send and receive data 16 | self.activeEntities = [] 17 | 18 | @staticmethod 19 | def EntityNameToClass(name): # TODO Implement 20 | """ Get entity name and return its class """ 21 | raise NotImplementedError() 22 | 23 | def AddActiveEntity(self, entity): 24 | """ Pass an entity instance, add to list of active entities """ 25 | self.activeEntities.append(entity) 26 | 27 | def Start(self): 28 | self.InitializeEntities() 29 | self.ManageUpdates() 30 | 31 | def GetEntities(self) -> list[Entity]: 32 | """ 33 | Return a list with entities. 34 | """ 35 | return self.activeEntities 36 | 37 | def UnloadEntity(self, entity): 38 | """ Unloads the passed entity """ 39 | if entity in self.activeEntities: 40 | self.activeEntities.remove(entity) 41 | else: 42 | raise Exception("Can't unload the requested entity: not found among loaded entities.") 43 | self.Log(self.LOG_INFO, entity.GetEntityId() + " unloaded") 44 | 45 | def InitializeEntities(self): 46 | for entity in self.GetEntities().copy(): 47 | if not entity.CallInitialize(): 48 | self.UnloadEntity(entity) # if errors, unload 49 | 50 | def ManageUpdates(self): 51 | """ Start a thread for each entity in which it will update periodically """ 52 | for entity in self.GetEntities(): 53 | 54 | # Only start threads for entities with Update() method: 55 | if not entity.Update.__qualname__ == "Entity.Update": 56 | thread = Thread(target=entity.LoopThread) 57 | thread.daemon = True 58 | thread.start() 59 | 60 | -------------------------------------------------------------------------------- /IoTuring/Entity/ToImplement/Brightness/Brightness.py: -------------------------------------------------------------------------------- 1 | from IoTuring.Entity.Entity import Entity 2 | import subprocess 3 | 4 | supports_win_brightness = True 5 | try: 6 | import wmi # Only to get windows brightness 7 | import pythoncom 8 | except: 9 | supports_win_brightness = False 10 | 11 | 12 | IN_TOPIC = 'brightness/set' # Receive a set message 13 | OUT_TOPIC = 'brightness/get' # Send a message with the value 14 | 15 | 16 | class Brightness(Entity): 17 | def Initialize(self): 18 | self.SubscribeToTopic(IN_TOPIC) 19 | self.AddTopic(OUT_TOPIC) 20 | self.stopCommand = False 21 | self.stopSensor = False 22 | self.stateOff = False 23 | 24 | def PostInitialize(self): 25 | os = self.GetOS() 26 | 27 | # Sensor function settings 28 | if(os == self.consts.FIXED_VALUE_OS_WINDOWS): 29 | self.GetBrightness_OS = self.GetBrightness_Win 30 | elif(os == self.consts.FIXED_VALUE_OS_MACOS): 31 | self.GetBrightness_OS = self.GetBrightness_macOS 32 | elif(os == self.consts.FIXED_VALUE_OS_LINUX): 33 | self.GetBrightness_OS = self.GetBrightness_Linux 34 | else: 35 | self.Log(self.Logger.LOG_WARNING, 36 | 'No brightness sensor available for this operating system') 37 | self.stopSensor = True 38 | 39 | # Command function settings 40 | if(os == self.consts.FIXED_VALUE_OS_WINDOWS): 41 | self.SetBrightness_OS = self.SetBrightness_Win 42 | elif(os == self.consts.FIXED_VALUE_OS_MACOS): 43 | self.SetBrightness_OS = self.SetBrightness_macOS 44 | elif(os == self.consts.FIXED_VALUE_OS_LINUX): 45 | self.SetBrightness_OS = self.SetBrightness_Linux 46 | else: 47 | self.Log(self.Logger.LOG_WARNING, 48 | 'No brightness command available for this operating system') 49 | self.stopCommand = True 50 | 51 | 52 | def Callback(self, message): 53 | state = message.payload.decode("utf-8") 54 | if not self.stopCommand: 55 | 56 | if state == self.consts.ON_STATE and self.stateOff is not False: 57 | state = self.stateOff if self.stateOff is not None else 100 58 | 59 | if state == self.consts.OFF_STATE: 60 | self.stateOff = self.GetTopicValue(OUT_TOPIC) 61 | state = 1 62 | elif self.stateOff is not False: 63 | self.stateOff = False 64 | 65 | try: 66 | # Value from 0 and 100 67 | self.SetBrightness_OS(int(state)) 68 | except ValueError: # Not int -> not a message for that function 69 | return 70 | except Exception as e: 71 | raise Exception("Error during brightness set: " + str(e)) 72 | 73 | # Finally, tell the sensor to update and to send 74 | self.CallUpdate() 75 | self.SendOnlineState() 76 | self.lastSendingTime = None # Force sensor to send immediately 77 | 78 | def Update(self): 79 | if not self.stopSensor: 80 | self.SetTopicValue(OUT_TOPIC, self.GetBrightness_OS(), 81 | self.ValueFormatter.TYPE_PERCENTAGE) 82 | self.SendOnlineState() 83 | 84 | def SetBrightness_macOS(self, value): 85 | value = value/100 # cause I need it from 0 to 1 86 | command = 'brightness ' + str(value) 87 | subprocess.Popen(command.split(), stdout=subprocess.PIPE) 88 | 89 | def SetBrightness_Linux(self, value): 90 | command = 'xbacklight -set ' + str(value) 91 | subprocess.Popen(command.split(), stdout=subprocess.PIPE) 92 | 93 | def SetBrightness_Win(self, value): 94 | if supports_win_brightness: 95 | pythoncom.CoInitialize() 96 | return wmi.WMI(namespace='wmi').WmiMonitorBrightnessMethods()[0].WmiSetBrightness(value, 0) 97 | else: 98 | raise Exception( 99 | 'No WMI module installed') 100 | 101 | def GetBrightness_macOS(self): 102 | try: 103 | command = 'brightness -l' 104 | process = subprocess.Popen(command.split(), stdout=subprocess.PIPE) 105 | stdout = process.communicate()[0] 106 | brightness = re.findall( 107 | 'display 0: brightness.*$', str(stdout))[0][22:30] 108 | brightness = float(brightness)*100 # is between 0 and 1 109 | return brightness 110 | except: 111 | raise Exception( 112 | 'You sure you installed Brightness from Homebrew ? (else try checking you PATH)') 113 | 114 | def GetBrightness_Linux(self): 115 | try: 116 | command = 'xbacklight' 117 | process = subprocess.Popen(command.split(), stdout=subprocess.PIPE) 118 | stdout = process.communicate()[0] 119 | brightness = float(stdout) 120 | return brightness 121 | except: 122 | raise Exception( 123 | 'You sure you installed Brightness from Homebrew ? (else try checking you PATH)') 124 | 125 | def GetBrightness_Win(self): 126 | if supports_win_brightness: 127 | return int(wmi.WMI(namespace='wmi').WmiMonitorBrightness() 128 | [0].CurrentBrightness) 129 | else: 130 | raise Exception( 131 | 'No WMI module installed') 132 | 133 | def GetOS(self): 134 | # Get OS from OsSensor and get temperature based on the os 135 | os = self.FindEntity('Os') 136 | if os: 137 | if not os.postinitializeState: # I run this function in post initialize so the os sensor might not be ready 138 | os.CallPostInitialize() 139 | os.CallUpdate() 140 | return os.GetTopicValue() 141 | 142 | def ManageDiscoveryData(self, discovery_data): 143 | for data in discovery_data: 144 | data['expire_after']="" 145 | 146 | self.SendOnlineState() 147 | 148 | discovery_data[0]['payload']['brightness_state_topic'] = self.SelectTopic( 149 | OUT_TOPIC) 150 | discovery_data[0]['payload']['state_topic'] = self.SelectTopic( 151 | self.STATE_TOPIC) 152 | discovery_data[0]['payload']['brightness_command_topic'] = self.SelectTopic( 153 | IN_TOPIC) 154 | discovery_data[0]['payload']['command_topic'] = self.SelectTopic( 155 | IN_TOPIC) 156 | discovery_data[0]['payload']['payload_on'] = self.consts.ON_STATE 157 | discovery_data[0]['payload']['payload_off'] = self.consts.OFF_STATE 158 | discovery_data[0]['payload']['brightness_scale'] = 100 159 | 160 | return discovery_data 161 | 162 | STATE_TOPIC = 'brightness/state' 163 | 164 | def SendOnlineState(self): 165 | if self.GetTopicValue(OUT_TOPIC) and int(self.GetTopicValue(OUT_TOPIC)) > 1: 166 | self.mqtt_client.SendTopicData( 167 | self.SelectTopic(self.STATE_TOPIC), self.consts.ON_STATE) 168 | else: 169 | self.mqtt_client.SendTopicData( 170 | self.SelectTopic(self.STATE_TOPIC), self.consts.OFF_STATE) 171 | -------------------------------------------------------------------------------- /IoTuring/Entity/ToImplement/Brightness/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | sensors: 3 | - Os: 4 | dont_send: True 5 | 6 | discovery: 7 | homeassistant: # Must match discovery preset name 8 | - topic: "brightness/get" 9 | disable: True 10 | payload: 11 | name: "Brightness level" 12 | unit_of_measurement: "%" 13 | - topic: "brightness/set" 14 | type: "light" 15 | payload: 16 | name: "Brightness" -------------------------------------------------------------------------------- /IoTuring/Entity/ToImplement/CpuTemperature/CpuTemperatures.py: -------------------------------------------------------------------------------- 1 | from IoTuring.Entity.Entity import Entity 2 | import psutil 3 | import json 4 | from IoTuring.Logger import Logger, ExceptionTracker 5 | 6 | supports_win_temperature = True 7 | try: 8 | import wmi # Only to get windows temperature 9 | openhardwaremonitor = wmi.WMI(namespace="root\\OpenHardwareMonitor") 10 | except: 11 | supports_win_temperature = False 12 | 13 | 14 | TOPIC = 'cpu/temperatures' 15 | 16 | 17 | class CpuTemperatures(Entity): 18 | def Initialize(self): 19 | self.AddTopic(TOPIC) 20 | 21 | def PostInitialize(self): 22 | os = self.GetOS() 23 | 24 | self.UpdateSpecificFunction = None # Specific function for this os/de, set this here to avoid all if else except at each update 25 | 26 | if(os == self.consts.FIXED_VALUE_OS_WINDOWS): 27 | self.UpdateSpecificFunction = self.GetCpuTemperature_Win 28 | # elif(Get_Operating_System() == self.consts.FIXED_VALUE_OS_MACOS): 29 | # self.UpdateSpecificFunction = Get_Temperatures_macOS NOT SUPPORTED 30 | elif(os == self.consts.FIXED_VALUE_OS_LINUX): 31 | self.UpdateSpecificFunction = self.GetCpuTemperature_Unix 32 | else: 33 | raise Exception( 34 | 'No temperature sensor available for this operating system') 35 | 36 | def Update(self): 37 | self.SetTopicValue(TOPIC, self.UpdateSpecificFunction()) 38 | 39 | def GetCpuTemperature_Unix(self): 40 | temps = psutil.sensors_temperatures() 41 | if 'coretemp' in temps: 42 | for temp in temps['coretemp']: 43 | if 'Core' in temp.label: 44 | return temp.current 45 | elif 'cpu_thermal' in temps: 46 | for temp in temps['cpu_thermal']: 47 | return temp.current 48 | else: 49 | self.Log(Logger.LOG_ERROR, "Can't get temperature for your system.") 50 | self.Log(Logger.LOG_ERROR, 51 | "Open a Git Issue and show this: " + str(temps)) 52 | self.Log(Logger.LOG_ERROR, "Thank you") 53 | raise Exception("No dict data") 54 | # Send the list as json 55 | raise Exception("No temperature data found") 56 | 57 | def GetCpuTemperature_Win(self): 58 | if supports_win_temperature: 59 | # Needs OpenHardwareMonitor interface for WMI 60 | sensors = openhardwaremonitor.) 61 | for sensor in sensors: 62 | if sensor.SensorType == u'Temperature' and not 'GPU' in sensor.Name: 63 | return float(sensor.Value) 64 | raise Exception("No temperature data found") 65 | 66 | # def GetCpuTemperatures_macOS(): 67 | #command = commands['macOS']['temperature'] + ' | grep \'temperature\'' 68 | # print(command) 69 | # out = subprocess.Popen(command.split(), 70 | # stdout=subprocess.PIPE, 71 | # stderr=subprocess.STDOUT) 72 | #out, errors = out.communicate() 73 | # from out, I have to get the float with temperature 74 | #temperature = [re.findall("\d+\.\d+", str(out))] 75 | # print(temperature) 76 | # Send the list as json 77 | # return str(json.dumps(temperature)) 78 | 79 | 80 | def GetOS(self): 81 | # Get OS from OsSensor and get temperature based on the os 82 | os = self.FindEntity('Os') 83 | if os: 84 | if not os.postinitializeState: # I run this function in post initialize so the os sensor might not be ready 85 | os.CallPostInitialize() 86 | os.CallUpdate() 87 | return os.GetTopicValue() 88 | -------------------------------------------------------------------------------- /IoTuring/Entity/ToImplement/CpuTemperature/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | sensors: 3 | - Os: 4 | dont_send: True 5 | 6 | discovery: 7 | homeassistant: # Must match discovery preset name 8 | - topic: "*" 9 | payload: 10 | name: "Cpu temperature" 11 | unit_of_measurement: "\u00b0C" -------------------------------------------------------------------------------- /IoTuring/Entity/ToImplement/Network/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | sensors: 3 | - Os: 4 | dont_send: True 5 | 6 | discovery: 7 | homeassistant: # Must match discovery preset name 8 | - topic: "network/traffic/bytes_recv" 9 | payload: 10 | name: "Network received data" 11 | icon: "mdi:download" 12 | unit_of_measurement: "MB" 13 | - topic: "network/traffic/bytes_sent" 14 | payload: 15 | name: "Network sent data" 16 | icon: "mdi:upload" 17 | unit_of_measurement: "MB" 18 | # Other network interface specific settings are in the ManageDiscoveryData function of the entity -------------------------------------------------------------------------------- /IoTuring/Entity/ToImplement/Screenshot/Screenshot.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pyscreenshot as ImageGrab 3 | from PIL import Image 4 | from IoTuring.Entity.Entity import Entity 5 | 6 | 7 | TOPIC = 'screenshot' 8 | 9 | SCREENSHOT_FILENAME = 'screenshot.png' 10 | scriptFolder = str(os.path.dirname(os.path.realpath(__file__))) 11 | 12 | 13 | class Screenshot(Entity): 14 | def Initialize(self): 15 | self.AddTopic(TOPIC) 16 | 17 | def Update(self): 18 | self.SetTopicValue(TOPIC, self.TakeScreenshot()) 19 | 20 | def TakeScreenshot(self): 21 | filename = os.path.join(scriptFolder, SCREENSHOT_FILENAME) 22 | ImageGrab.grab().save(filename) 23 | f = open(filename, "rb") # 3.7kiB in same folder 24 | fileContent = f.read() 25 | image = bytearray(fileContent) 26 | f.close() 27 | os.remove(filename) 28 | return image 29 | 30 | def ManageDiscoveryData(self, discovery_data): 31 | # Camera must not have some information that sensors have so here they have to be removed ! (done with expire_after) 32 | 33 | discovery_data[0]['payload'].pop('state_topic', None) 34 | discovery_data[0]['payload']['topic']=self.SelectTopic({"topic":TOPIC}) 35 | 36 | if 'expire_after' in discovery_data[0]['payload']: # Camera must not have this information or will be discarded 37 | del(discovery_data[0]['payload']['expire_after']) 38 | 39 | ''' 40 | discovery_data[0]['payload']['availability']={} 41 | discovery_data[0]['payload']['availability']['topic']=self.SelectTopic("status") 42 | discovery_data[0]['payload']['availability']['payload_available']=ONLINE_STATE 43 | discovery_data[0]['payload']['availability']['payload_not_available']=OFFLINE_STATE 44 | ''' 45 | return discovery_data -------------------------------------------------------------------------------- /IoTuring/Entity/ToImplement/Screenshot/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | 3 | discovery: 4 | homeassistant: # Must match discovery preset name 5 | - topic: "*" 6 | type: camera 7 | payload: 8 | name: "Screen" 9 | 10 | 11 | -------------------------------------------------------------------------------- /IoTuring/Entity/ToImplement/State/State.py: -------------------------------------------------------------------------------- 1 | import psutil 2 | from IoTuring.Entity.Entity import Entity 3 | import signal, sys 4 | import time 5 | 6 | TOPIC_STATE = 'state' 7 | 8 | 9 | class State(Entity): 10 | def Initialize(self): 11 | self.AddTopic(TOPIC_STATE) 12 | signal.signal(signal.SIGINT, self.ExitSignal) 13 | 14 | 15 | def Update(self): 16 | self.SetTopicValue(TOPIC_STATE, self.consts.ONLINE_STATE) 17 | 18 | def SendOfflineState(self): 19 | self.mqtt_client.SendTopicData(self.SelectTopic(TOPIC_STATE),self.consts.OFFLINE_STATE) 20 | 21 | def ExitSignal(self,sig, frame): 22 | # Before exiting I send an offline message to the state_topic if prese 23 | print("\r", end="") # This removes the Control-C symbol (^C) 24 | self.Log(self.Logger.LOG_INFO,'Let me send the Offline state message') 25 | self.SendOfflineState() 26 | time.sleep(1) 27 | self.Log(self.Logger.LOG_INFO,"All done, goodbye !") 28 | sys.exit(0) 29 | -------------------------------------------------------------------------------- /IoTuring/Entity/ToImplement/State/settings.yaml: -------------------------------------------------------------------------------- 1 | requirements: 2 | 3 | discovery: 4 | homeassistant: # Must match discovery preset name 5 | - topic: "state" 6 | type: binary_sensor 7 | payload: 8 | name: "Online state" 9 | device_class: "connectivity" 10 | payload_off: "Offline" # Must match the fixed value in consts 11 | payload_on: "Online" # Must match the fixed value in consts 12 | -------------------------------------------------------------------------------- /IoTuring/Entity/ValueFormat/ValueFormatter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import math 3 | from IoTuring.Entity.ValueFormat import ValueFormatterOptions 4 | 5 | # Value formatter 6 | # If I have to return value not in byte but with MB/GB/KB; same for time 7 | 8 | # OPTIONS MEANING (example with byte values) 9 | # type: the type of the value (one of the ValueFormatter constants) 10 | # size: True "means that will be used the nearest unit 1024Byte->1KB" False "send the value without using the pow1024 mechianism" 11 | # size: MB "means that will be used the specified size 2014Byte->0.001MB" 12 | # decimals: integer "the number of decimal for the value": -1 for untouched 13 | 14 | # Lists of measure units 15 | BYTE_SIZES = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] 16 | BYTE_PER_SECOND_SIZES = ['Bps' ,'KBps', 'MBps', 'GBps', 'TBps', 'PBps'] 17 | BIT_PER_SECOND_SIZES = ['bps' ,'Kbps', 'Mbps', 'Gbps', 'Tbps', 'Pbps'] 18 | TIME_SIZES = ['s', 'm', 'h', 'd'] 19 | FREQUENCY_SIZES = ['Hz', 'kHz', 'MHz', 'GHz'] 20 | TIME_SIZES_DIVIDERS = [1, 60, 60, 24] 21 | CELSIUS_UNIT = '°C' 22 | ROTATION = ['rpm'] 23 | RADIOPOWER =['dBm'] 24 | 25 | SPACE_BEFORE_UNIT = ' ' 26 | 27 | class ValueFormatter(): 28 | 29 | # includeUnit: isn't in the option as it's chosen by each warehouse and not by the entity itself 30 | @staticmethod 31 | def FormatValue(value, options: ValueFormatterOptions | None, includeUnit: bool): 32 | """ 33 | Format the value according to the options. Returns value as string. 34 | IncludeUnit: True if the unit has to be included in the value 35 | """ 36 | return str(ValueFormatter._ParseValue(value, options, includeUnit)) 37 | 38 | @staticmethod 39 | def _ParseValue(value, options: ValueFormatterOptions | None, includeUnit: bool): 40 | if options is None: 41 | return value 42 | valueType = options.get_value_type() 43 | 44 | # specific type formatting 45 | if valueType == ValueFormatterOptions.TYPE_NONE: # edit needed only if decimals 46 | return ValueFormatter.roundValue(value, options) 47 | elif valueType == ValueFormatterOptions.TYPE_BYTE: 48 | return ValueFormatter.ByteFormatter(value, options, includeUnit) 49 | elif valueType == ValueFormatterOptions.TYPE_MILLISECONDS: 50 | return ValueFormatter.MillisecondsFormatter(value, options, includeUnit) 51 | elif valueType == ValueFormatterOptions.TYPE_TIME: 52 | return ValueFormatter.TimeFormatter(value, options, includeUnit) 53 | elif valueType == ValueFormatterOptions.TYPE_FREQUENCY: 54 | return ValueFormatter.FrequencyFormatter(value, options, includeUnit) 55 | elif valueType == ValueFormatterOptions.TYPE_TEMPERATURE: 56 | return ValueFormatter.TemperatureCelsiusFormatter(value, options, includeUnit) 57 | elif valueType == ValueFormatterOptions.TYPE_ROTATION: 58 | return ValueFormatter.RoundsPerMinuteFormatter(value, options, includeUnit) 59 | elif valueType ==ValueFormatterOptions.TYPE_RADIOPOWER: 60 | return ValueFormatter.RadioPowerFormatter(value, options, includeUnit) 61 | elif valueType == ValueFormatterOptions.TYPE_PERCENTAGE: 62 | if includeUnit: 63 | return str(value) + SPACE_BEFORE_UNIT + '%' 64 | else: 65 | return str(value) 66 | elif valueType == ValueFormatterOptions.TYPE_BIT_PER_SECOND: 67 | return ValueFormatter.BitPerSecondFormatter(value, options, includeUnit) 68 | elif valueType == ValueFormatterOptions.TYPE_BYTE_PER_SECOND: 69 | return ValueFormatter.BytePerSecondFormatter(value, options, includeUnit) 70 | else: 71 | return str(value) 72 | 73 | @staticmethod 74 | def TimeFormatter(value, options: ValueFormatterOptions, includeUnit: bool): 75 | # Get value in seconds, and adjustable 76 | asked_size = options.get_adjust_size() 77 | 78 | if asked_size and asked_size in TIME_SIZES: 79 | index = TIME_SIZES.index(asked_size) 80 | divider = 1 81 | for i in range(0, index+1): 82 | divider = divider*TIME_SIZES_DIVIDERS[i] 83 | 84 | value = value/divider 85 | else: 86 | index = 0 87 | 88 | value = ValueFormatter.roundValue(value, options) 89 | result = str(value) 90 | 91 | if includeUnit: 92 | result = result + SPACE_BEFORE_UNIT + TIME_SIZES[index] 93 | 94 | return result 95 | 96 | @staticmethod 97 | def MillisecondsFormatter(value, options: ValueFormatterOptions, includeUnit: bool): 98 | # Get value in milliseconds: adjust not implemented 99 | 100 | value = ValueFormatter.roundValue(value, options) 101 | 102 | if includeUnit: 103 | return str(value) + SPACE_BEFORE_UNIT + 'ms' 104 | else: 105 | return str(value) 106 | 107 | # Get from number of bytes the correct byte size: 1045B is 1KB. If size_wanted passed and is SIZE_MEGABYTE, if I have 10^9B, I won't diplay 1GB but c.a. 1000MB 108 | @staticmethod 109 | def ByteFormatter(value, options: ValueFormatterOptions, includeUnit: bool): 110 | # Get value in bytes 111 | asked_size = options.get_adjust_size() 112 | decimals = options.get_decimals() 113 | 114 | if asked_size and asked_size in BYTE_SIZES: 115 | powOf1024 = BYTE_SIZES.index(asked_size) 116 | # If value == 0 math.log failes, so simply send 0: 117 | elif float(value) == 0: 118 | powOf1024 = 0 119 | else: 120 | powOf1024 = math.floor(math.log(value, 1024)) 121 | 122 | value = value/(math.pow(1024, powOf1024)) 123 | 124 | value = ValueFormatter.roundValue(value, options) 125 | 126 | result = str(value) 127 | 128 | if includeUnit: 129 | result = result + SPACE_BEFORE_UNIT + BYTE_SIZES[powOf1024] 130 | 131 | return result 132 | 133 | @staticmethod 134 | def FrequencyFormatter(value, options: ValueFormatterOptions, includeUnit: bool): 135 | # Get value in hertz, and adjustable 136 | asked_size = options.get_adjust_size() 137 | 138 | if asked_size and asked_size in FREQUENCY_SIZES: 139 | index = FREQUENCY_SIZES.index(asked_size) 140 | value = value/pow(1000,index) 141 | else: 142 | index = 0 143 | 144 | # decimals 145 | value = ValueFormatter.roundValue(value, options) 146 | 147 | result = str(value) 148 | 149 | if includeUnit: 150 | result = result + SPACE_BEFORE_UNIT + FREQUENCY_SIZES[index] 151 | return result 152 | 153 | 154 | @staticmethod 155 | def TemperatureCelsiusFormatter(value, options: ValueFormatterOptions, includeUnit: bool): 156 | # asked_size not implemented 157 | 158 | # decimals 159 | value = ValueFormatter.roundValue(value, options) 160 | 161 | result = str(value) 162 | 163 | if includeUnit: 164 | result = result + SPACE_BEFORE_UNIT + CELSIUS_UNIT 165 | return result 166 | 167 | @staticmethod 168 | def RoundsPerMinuteFormatter(value, options: ValueFormatterOptions, includeUnit: bool): 169 | # asked_size not implemented 170 | 171 | # decimals 172 | value = ValueFormatter.roundValue(value, options) 173 | 174 | result = str(value) 175 | 176 | if includeUnit: 177 | result = result + SPACE_BEFORE_UNIT + ROTATION[0] 178 | return result 179 | 180 | @staticmethod 181 | def RadioPowerFormatter(value, options: ValueFormatterOptions, includeUnit: bool): 182 | 183 | value = ValueFormatter.roundValue(value, options) 184 | 185 | if includeUnit: 186 | return str(value) + SPACE_BEFORE_UNIT + 'dBm' 187 | else: 188 | return str(value) 189 | 190 | @staticmethod 191 | def BytePerSecondFormatter(value, options: ValueFormatterOptions, includeUnit: bool): 192 | # Get value in hertz, and adjustable 193 | asked_size = options.get_adjust_size() 194 | 195 | if asked_size and asked_size in BYTE_PER_SECOND_SIZES: 196 | index = BYTE_PER_SECOND_SIZES.index(asked_size) 197 | value = value/pow(1000,index) 198 | else: 199 | index = 0 200 | 201 | value = ValueFormatter.roundValue(value, options) 202 | 203 | if includeUnit: 204 | return str(value) + SPACE_BEFORE_UNIT + BYTE_PER_SECOND_SIZES[index] 205 | else: 206 | return str(value) 207 | 208 | def BitPerSecondFormatter(value, options: ValueFormatterOptions, includeUnit: bool): 209 | # Get value in hertz, and adjustable 210 | asked_size = options.get_adjust_size() 211 | 212 | if asked_size and asked_size in BYTE_PER_SECOND_SIZES: 213 | index = BIT_PER_SECOND_SIZES.index(asked_size) 214 | value = value/pow(1000,index) 215 | else: 216 | index = 0 217 | 218 | value = ValueFormatter.roundValue(value, options) 219 | 220 | if includeUnit: 221 | return str(value) + SPACE_BEFORE_UNIT + BIT_PER_SECOND_SIZES[index] 222 | else: 223 | return str(value) 224 | 225 | @staticmethod 226 | def roundValue(value, options: ValueFormatterOptions): 227 | if options.get_decimals() != ValueFormatterOptions.DO_NOT_TOUCH_DECIMALS: 228 | return round(value, options.get_decimals()) 229 | return value 230 | 231 | -------------------------------------------------------------------------------- /IoTuring/Entity/ValueFormat/ValueFormatterOptions.py: -------------------------------------------------------------------------------- 1 | class ValueFormatterOptions(): 2 | TYPE_NONE = 0 3 | TYPE_BYTE = 1 4 | TYPE_TIME = 2 5 | TYPE_PERCENTAGE = 3 6 | TYPE_FREQUENCY = 4 7 | TYPE_MILLISECONDS = 5 8 | TYPE_TEMPERATURE = 6 9 | TYPE_ROTATION = 7 10 | TYPE_RADIOPOWER = 8 11 | TYPE_BYTE_PER_SECOND = 9 12 | TYPE_BIT_PER_SECOND = 10 13 | 14 | DO_NOT_TOUCH_DECIMALS = -1 15 | 16 | def __init__(self, value_type=TYPE_NONE, decimals=DO_NOT_TOUCH_DECIMALS, adjust_size=None): 17 | self.value_type = value_type 18 | self.decimals = decimals 19 | self.adjust_size = adjust_size 20 | 21 | def set_value_type(self, value_type): 22 | self.value_type = value_type 23 | 24 | def set_decimals(self, decimals): 25 | self.decimals = decimals 26 | 27 | def set_adjust_size(self, adjust_size): 28 | self.adjust_size = adjust_size 29 | 30 | def get_value_type(self): 31 | return self.value_type 32 | 33 | def get_decimals(self): 34 | return self.decimals 35 | 36 | def get_adjust_size(self): 37 | return self.adjust_size 38 | -------------------------------------------------------------------------------- /IoTuring/Entity/ValueFormat/__init__.py: -------------------------------------------------------------------------------- 1 | from .ValueFormatterOptions import ValueFormatterOptions 2 | from .ValueFormatter import ValueFormatter -------------------------------------------------------------------------------- /IoTuring/Exceptions/Exceptions.py: -------------------------------------------------------------------------------- 1 | class UnknownEntityKeyException(Exception): 2 | def __init__(self, key: str) -> None: 3 | super().__init__(f"Unknown entity key: {key}") 4 | 5 | 6 | class UnknownConfigKeyException(Exception): 7 | def __init__(self, key: str) -> None: 8 | super().__init__(f"Configuration key not found: {key}") 9 | 10 | 11 | class UserCancelledException(Exception): 12 | def __init__(self, *args: object) -> None: 13 | super().__init__(*args) 14 | self.message = "User cancelled the ongoing process" 15 | 16 | 17 | class UnknownLoglevelException(Exception): 18 | def __init__(self, loglevel: str) -> None: 19 | super().__init__(f"Unknown log level: {loglevel}") 20 | self.loglevel = loglevel 21 | -------------------------------------------------------------------------------- /IoTuring/Logger/Colors.py: -------------------------------------------------------------------------------- 1 | class Colors: 2 | reset = '\033[0m' 3 | black = '\033[30m' 4 | red = '\033[31m' 5 | green = '\033[32m' 6 | orange = '\033[33m' 7 | blue = '\033[34m' 8 | purple = '\033[35m' 9 | cyan = '\033[36m' 10 | lightgrey = '\033[37m' 11 | darkgrey = '\033[90m' 12 | lightred = '\033[91m' 13 | lightgreen = '\033[92m' 14 | yellow = '\033[93m' 15 | lightblue = '\033[94m' 16 | pink = '\033[95m' 17 | lightcyan = '\033[96m' -------------------------------------------------------------------------------- /IoTuring/Logger/LogLevel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import logging 3 | from IoTuring.Logger.Colors import Colors 4 | from IoTuring.Exceptions.Exceptions import UnknownLoglevelException 5 | 6 | 7 | class LogLevel: 8 | """ A loglevel with numeric and string values""" 9 | 10 | def __init__(self, level_const: str) -> None: 11 | 12 | self.string = level_const.upper() 13 | if self.string.startswith("LOG_"): 14 | self.string = self.string[4:] 15 | 16 | try: 17 | self.number = getattr(logging, self.string) 18 | except AttributeError: 19 | raise UnknownLoglevelException(level_const) 20 | 21 | # WARNING is yellow: 22 | if self.number == 30: 23 | self.color = Colors.yellow 24 | # ERROR and CRITICAL red: 25 | elif self.number > 30: 26 | self.color = Colors.red 27 | else: 28 | self.color = "" 29 | 30 | def __str__(self) -> str: 31 | return self.string 32 | 33 | def __int__(self) -> int: 34 | return self.number 35 | 36 | 37 | class LogLevelObject: 38 | """ Base class for loglevel properties """ 39 | 40 | LOG_DEBUG = LogLevel("DEBUG") 41 | LOG_INFO = LogLevel("INFO") 42 | LOG_WARNING = LogLevel("WARNING") 43 | LOG_ERROR = LogLevel("ERROR") 44 | LOG_CRITICAL = LogLevel("CRITICAL") 45 | 46 | LOGTARGET_FILE = "file" 47 | LOGTARGET_CONSOLE = "console" 48 | 49 | @classmethod 50 | def GetLoglevels(cls) -> list[LogLevel]: 51 | """Get all available log levels 52 | 53 | Returns: 54 | list[LogLevel]: List of LogLevel objects 55 | """ 56 | return [getattr(cls, l) for l in dir(cls) if isinstance(getattr(cls, l), LogLevel)] 57 | -------------------------------------------------------------------------------- /IoTuring/Logger/LogObject.py: -------------------------------------------------------------------------------- 1 | from IoTuring.Logger.Logger import Logger 2 | from IoTuring.Logger.LogLevel import LogLevelObject, LogLevel 3 | 4 | class LogObject(LogLevelObject): 5 | 6 | def Log(self, loglevel: LogLevel, message, **kwargs): 7 | logger = Logger() 8 | logger.Log( 9 | source=self.LogSource(), 10 | message=message, 11 | loglevel=loglevel, 12 | **kwargs 13 | ) 14 | 15 | # to override in classes where I want a source different from class name 16 | def LogSource(self): 17 | return type(self).__name__ 18 | -------------------------------------------------------------------------------- /IoTuring/Logger/consts.py: -------------------------------------------------------------------------------- 1 | LOGS_FOLDER = "Logs" 2 | DEFAULT_LOG_LEVEL = "INFO" 3 | MIN_CONSOLE_WIDTH = 95 4 | 5 | LOG_PREFIX_LENGTHS = { 6 | "asctime": 19, 7 | "levelname:^8s": 8, 8 | "source:^30s": 30 9 | } 10 | 11 | LOG_PREFIX_ENDS = [ 12 | "[ ", " ] " 13 | ] 14 | 15 | 16 | LOG_PREFIX_SEPARATOR = " | " 17 | 18 | 19 | # On/off states as strings: 20 | STATE_ON = "ON" 21 | STATE_OFF = "OFF" 22 | -------------------------------------------------------------------------------- /IoTuring/MyApp/App.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import metadata 2 | from pathlib import Path 3 | 4 | class App(): 5 | METADATA = metadata('IoTuring') 6 | 7 | NAME = METADATA['Name'] 8 | DESCRIPTION = METADATA['Summary'] 9 | VENDOR = METADATA['Maintainer-email'].split(' <')[0] 10 | VERSION = METADATA['Version'] 11 | 12 | # "Project-URL": "homepage, https://github.com/richibrics/IoTuring", 13 | # "Project-URL": "documentation, https://github.com/richibrics/IoTuring", 14 | # "Project-URL": "repository, https://github.com/richibrics/IoTuring", 15 | # "Project-URL": "changelog, https://github.com/richibrics/IoTuring/releases", 16 | URLS = METADATA.get_all("Project-URL") or "" 17 | URL_HOMEPAGE = URLS[0].split(', ')[1].strip() 18 | URL_RELEASES = URLS[-1].split(', ')[1].strip() 19 | 20 | @staticmethod 21 | def getName() -> str: 22 | return App.NAME 23 | 24 | @staticmethod 25 | def getVendor() -> str: 26 | return App.VENDOR 27 | 28 | @staticmethod 29 | def getDescription() -> str: 30 | return App.DESCRIPTION 31 | 32 | @staticmethod 33 | def getVersion() -> str: 34 | return App.VERSION 35 | 36 | @staticmethod 37 | def getUrlHomepage() -> str: 38 | return App.URL_HOMEPAGE 39 | 40 | @staticmethod 41 | def getUrlReleases() -> str: 42 | return App.URL_RELEASES 43 | 44 | @staticmethod 45 | def getRootPath() -> Path: 46 | """Get the project root path 47 | 48 | Returns: 49 | Path: The path to th project root as a pathlib.Path 50 | """ 51 | return Path(__file__).parents[1] 52 | 53 | def __str__(self) -> str: 54 | msg = "" 55 | msg += "Name: " + App.getName() + "\n" 56 | msg += "Version: " + App.getVersion() + "\n" 57 | msg += "Description: " + App.getDescription() + "\n" 58 | return msg 59 | -------------------------------------------------------------------------------- /IoTuring/MyApp/SystemConsts/DesktopEnvironmentDetection.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from IoTuring.MyApp.SystemConsts.OperatingSystemDetection import OperatingSystemDetection as OsD 3 | 4 | 5 | class DesktopEnvironmentDetection(): 6 | @staticmethod 7 | def GetDesktopEnvironment() -> str: 8 | 9 | de = OsD.GetEnv('DESKTOP_SESSION') 10 | if not de: 11 | de = "base" 12 | return de 13 | 14 | @staticmethod 15 | def IsWayland() -> bool: 16 | return bool( 17 | OsD.GetEnv('WAYLAND_DISPLAY') or 18 | OsD.GetEnv('XDG_SESSION_TYPE') == 'wayland' 19 | ) 20 | 21 | @staticmethod 22 | def CheckXsetSupport() -> None: 23 | """ Check if system supports xset. Raises exception if not supported """ 24 | if not OsD.CommandExists("xset"): 25 | raise Exception("xset command not found!") 26 | else: 27 | # Check if xset is working: 28 | p = subprocess.run(['xset', 'dpms'], capture_output=True, shell=False) 29 | if p.stderr: 30 | raise Exception(f"Xset dpms error: {p.stderr.decode()}") 31 | elif not OsD.GetEnv('DISPLAY'): 32 | raise Exception('No $DISPLAY environment variable!') 33 | 34 | @staticmethod 35 | def IsXsetSupported() -> bool: 36 | if DesktopEnvironmentDetection.IsWayland(): 37 | return False 38 | try: 39 | DesktopEnvironmentDetection.CheckXsetSupport() 40 | return True 41 | except: 42 | return False 43 | -------------------------------------------------------------------------------- /IoTuring/MyApp/SystemConsts/OperatingSystemDetection.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import platform 4 | import os 5 | import psutil 6 | import shutil 7 | import subprocess 8 | 9 | class OperatingSystemDetection(): 10 | OS_NAME = platform.system() 11 | 12 | # Values to return as OS: 13 | MACOS = "macOS" 14 | WINDOWS = "Windows" 15 | LINUX = "Linux" 16 | 17 | # Values to use to detect the OS: 18 | MACOS_DETECTION_NAMES = ["macOS", "Darwin"] 19 | WINDOWS_DETECTION_NAMES = ["Windows"] 20 | LINUX_DETECTION_NAMES = ["Linux"] 21 | 22 | 23 | @classmethod 24 | def GetOs(cls) -> str: 25 | if cls.IsMacos(): 26 | return cls.MACOS 27 | elif cls.IsLinux(): 28 | return cls.LINUX 29 | elif cls.IsWindows(): 30 | return cls.WINDOWS 31 | else: 32 | raise Exception( 33 | f"Operating system not in the fixed list. Please open a Git issue and warn about this: OS = {cls.OS_NAME}") 34 | 35 | @classmethod 36 | def IsLinux(cls) -> bool: 37 | return bool(cls.OS_NAME in cls.LINUX_DETECTION_NAMES) 38 | 39 | @classmethod 40 | def IsWindows(cls) -> bool: 41 | return bool(cls.OS_NAME in cls.WINDOWS_DETECTION_NAMES) 42 | 43 | @classmethod 44 | def IsMacos(cls) -> bool: 45 | return bool(cls.OS_NAME in cls.MACOS_DETECTION_NAMES) 46 | 47 | @classmethod 48 | def GetEnv(cls, envvar) -> str: 49 | """Get envvar, also from different tty on linux""" 50 | env_value = os.environ.get(envvar) or "" 51 | if cls.IsLinux() and not env_value: 52 | try: 53 | # Try if there is another tty with gui: 54 | session_pid = next((u.pid for u in psutil.users() if u.host and "tty" in u.host), None) 55 | if session_pid: 56 | p = psutil.Process(int(session_pid)) 57 | with p.oneshot(): 58 | env_value = p.environ()[envvar] 59 | except KeyError: 60 | env_value = "" 61 | return env_value 62 | 63 | @staticmethod 64 | def RunCommand(command: str | list, 65 | shell: bool = False, 66 | **kwargs) -> subprocess.CompletedProcess: 67 | """Safely call a subprocess. Kwargs are other Subprocess options 68 | 69 | Args: 70 | command (str | list): The command to call 71 | shell (bool, optional): Run in shell. Defaults to False. 72 | **kwargs: subprocess args 73 | 74 | Returns: 75 | subprocess.CompletedProcess: See subprocess docs 76 | """ 77 | 78 | # different defaults than in subprocess: 79 | defaults = { 80 | "capture_output": True, 81 | "text": True 82 | } 83 | 84 | for param, value in defaults.items(): 85 | if param not in kwargs: 86 | kwargs[param] = value 87 | 88 | if shell == False and isinstance(command, str): 89 | runcommand = command.split() 90 | else: 91 | runcommand = command 92 | 93 | p = subprocess.run( 94 | runcommand, shell=shell, **kwargs) 95 | 96 | return p 97 | 98 | 99 | @staticmethod 100 | def CommandExists(command) -> bool: 101 | """Check if a command exists""" 102 | return bool(shutil.which(command)) 103 | -------------------------------------------------------------------------------- /IoTuring/MyApp/SystemConsts/TerminalDetection.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import shutil 4 | 5 | 6 | class TerminalDetection: 7 | @staticmethod 8 | def CheckTerminalSupportsColors() -> bool: 9 | """ 10 | Returns True if the running system's terminal supports color, and False 11 | otherwise. 12 | """ 13 | plat = sys.platform 14 | supported_platform = plat != 'Pocket PC' and (plat != 'win32' or 15 | 'ANSICON' in os.environ) 16 | # isatty is not always implemented, #6223. 17 | is_a_tty = hasattr(sys.stdout, 'isatty') and sys.stdout.isatty() 18 | return supported_platform and is_a_tty 19 | 20 | @staticmethod 21 | def CheckTerminalSupportsSize() -> bool: 22 | return any(shutil.get_terminal_size(fallback=(0,0))) 23 | 24 | 25 | @staticmethod 26 | def GetTerminalLines() -> int: 27 | return shutil.get_terminal_size().lines 28 | 29 | @staticmethod 30 | def GetTerminalColumns() -> int: 31 | return shutil.get_terminal_size().columns 32 | 33 | 34 | @staticmethod 35 | def CalculateNumberOfLines(string_length: int) -> int: 36 | """Get the number of lines required to display a text with the given length 37 | 38 | Args: 39 | string_length (int): Length of the text 40 | 41 | Returns: 42 | int: Number of lines required 43 | """ 44 | return (string_length // shutil.get_terminal_size().columns) + 1 45 | -------------------------------------------------------------------------------- /IoTuring/MyApp/SystemConsts/__init__.py: -------------------------------------------------------------------------------- 1 | from .DesktopEnvironmentDetection import DesktopEnvironmentDetection 2 | from .OperatingSystemDetection import OperatingSystemDetection 3 | from .TerminalDetection import TerminalDetection -------------------------------------------------------------------------------- /IoTuring/Protocols/MQTTClient/MQTTClient.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from IoTuring.Logger.LogObject import LogObject 4 | from IoTuring.MyApp.App import App 5 | import paho.mqtt.client as MqttClient 6 | 7 | try: 8 | import paho.mqtt.enums as mqttEnums 9 | except ModuleNotFoundError: 10 | sys.exit("paho.mqtt.enums not found! Update paho-mqtt package to 2.0.0! Exiting...") 11 | 12 | 13 | from IoTuring.Protocols.MQTTClient.TopicCallback import TopicCallback 14 | 15 | """ 16 | 17 | MQTTClient Operations: 18 | - Init 19 | - AsyncConnect 20 | - SendTopicData 21 | - AddNewTopicToSubscribeTo 22 | 23 | """ 24 | 25 | 26 | class MQTTClient(LogObject): 27 | client = None 28 | connected = False 29 | 30 | # After the init, you have to connect with AsyncConnect ! 31 | def __init__(self, address, port=1883, name=None, username="", password=""): 32 | self.address = address 33 | self.port = int(port) 34 | self.name = name 35 | self.username = username 36 | self.password = password 37 | 38 | if self.name == None: 39 | self.name = App.getName() 40 | 41 | # List of TopicCallback objects, which I use to call callbacks, compare topics, keep subscribed state 42 | self.topicCallbacks = [] 43 | 44 | self.Log(self.LOG_INFO, 'Preparing MQTT client') 45 | self.SetupClient() 46 | 47 | def IsConnected(self): 48 | """ Return True if client is currently connected """ 49 | return self.connected 50 | 51 | def SetupClient(self) -> None: 52 | self.client = MqttClient.Client(callback_api_version=mqttEnums.CallbackAPIVersion.VERSION2, client_id=self.name) 53 | 54 | if self.username != "" and self.password != "": 55 | self.client.username_pw_set(self.username, self.password) 56 | 57 | # Assign event callbacks 58 | self.client.on_connect = self.Event_OnClientConnect 59 | self.client.on_disconnect = self.Event_OnClientDisconnect 60 | self.client.on_message = self.Event_OnMessageReceive 61 | 62 | def AsyncConnect(self) -> None: 63 | """ Connect async to the broker """ 64 | self.Log(self.LOG_INFO, 'MQTT Client ready to connect to the broker') 65 | # If broker is not reachable wait till he's reachable 66 | self.client.connect_async(self.address, port=self.port) 67 | self.client.loop_start() 68 | 69 | # EVENTS 70 | 71 | def Event_OnClientConnect(self, client, userdata, flags, reason_code, properties)-> None: 72 | if reason_code==0: # Connections is OK 73 | self.Log(self.LOG_INFO, "Connection established") 74 | self.connected = True 75 | self.SubscribeToAllTopics() 76 | else: 77 | self.Log(self.LOG_ERROR, "Connection error: code " + str(reason_code)) 78 | 79 | def Event_OnClientDisconnect(self, client, userdata, flags, reason_code, properties)-> None: 80 | self.Log(self.LOG_ERROR, "Connection lost") 81 | self.connected = False 82 | 83 | for topicCallback in self.topicCallbacks: 84 | topicCallback.SetAsNotSubscribed() 85 | 86 | def Event_OnMessageReceive(self, client, userdata, message) -> None: 87 | # TODO QoS also here 88 | try: 89 | topicCallback = self.GetTopicCallback(message.topic) 90 | topicCallback.Call_Callback(message) 91 | except Exception as e: 92 | self.Log(self.LOG_WARNING, "Error in message receive: " + str(e)) 93 | 94 | # OUTCOMING MESSAGES PART 95 | 96 | def SendTopicData(self, topic, data) -> None: 97 | self.client.publish(topic, data) 98 | 99 | def LwtSet(self, topic, payload) -> None: 100 | # Sets Lwt message data 101 | self.client.will_set(topic, payload=payload, retain=False) 102 | 103 | # INCOMING MESSAGES PART / SUBSCRIBE 104 | 105 | def AddNewTopicToSubscribeTo(self, topic, callbackFunction) -> TopicCallback: 106 | topicCallback = TopicCallback(topic, callbackFunction) 107 | self.topicCallbacks.append(topicCallback) 108 | if self.connected: 109 | topicCallback.SubscribeTopic(self.client) 110 | return TopicCallback 111 | 112 | def SubscribeToAllTopics(self) -> None: 113 | """ Subscribe all TopicCallback using the MQTT client, if client is connected """ 114 | if self.connected: 115 | for topicCallback in self.topicCallbacks: 116 | topicCallback.SubscribeTopic(self.client) 117 | 118 | def UnsubscribeFromTopic(self, topic) -> None: 119 | try: 120 | topicCallback = self.GetTopicCallback(topic) 121 | topicCallback.UnsubscribeTopic(self.client) 122 | self.topicCallbacks.remove(topicCallback) 123 | except Exception as e: 124 | self.Log(self.LOG_ERROR, "Error in topic unsubscription: " + str(e)) 125 | 126 | def GetTopicCallbacks(self) -> list: 127 | """ Return (safely) a list with topics to which the client should be subscribed when everything is working correctly""" 128 | return self.topicCallbacks.copy() 129 | 130 | def GetTopicCallback(self, topic) -> TopicCallback: 131 | for topicCallback in self.topicCallbacks: 132 | if topicCallback.CompareTopic(topic): 133 | return topicCallback 134 | raise Exception("Can't find any matching TopicCallback for " + topic) 135 | 136 | # LOG 137 | def LogSource(self) -> str: 138 | return "MQTT" 139 | 140 | # NORMALIZE 141 | @staticmethod 142 | def NormalizeTopic(entityDataId) -> str: 143 | return entityDataId.replace(".", "/") 144 | -------------------------------------------------------------------------------- /IoTuring/Protocols/MQTTClient/TopicCallback.py: -------------------------------------------------------------------------------- 1 | from IoTuring.Logger.LogObject import LogObject 2 | 3 | import paho.mqtt.client as mqtt 4 | 5 | 6 | class TopicCallback(LogObject): 7 | 8 | def __init__(self, topic, callback) -> None: 9 | super().__init__() 10 | if topic is None or callback is None: 11 | self.Log(self.LOG_ERROR, "Topic/Callback can't be null\nTopic: " + 12 | topic + "\nCallback: " + callback) 13 | return 14 | self.topic = topic 15 | self.callback = callback 16 | self.imSubscribed = False 17 | 18 | def CompareTopic(self, wantedTopic): 19 | """ Return true if the passed topic equals to my topic """ 20 | return self.topic == wantedTopic # TODO Wildcard check maybe 21 | 22 | def Call_Callback(self, message): 23 | """ Call callback, need also the topic because may be used a wildcard in self.topic, so I want the complete topic ALWAYS """ 24 | try: 25 | self.callback(message) 26 | except Exception as e: 27 | self.Log(self.LOG_ERROR, "Error in callback call\n --> Topic: " + message.topic + 28 | "\n --> Payload: " + str(message.payload) + "\nError: " + str(e)) 29 | 30 | def SetAsSubscribed(self): 31 | """ Set subscribed status to False """ 32 | self.imSubscribed = True 33 | 34 | def SetAsNotSubscribed(self): 35 | """ Reset subscribed status to False """ 36 | self.imSubscribed = False 37 | 38 | def GetSubscriptionState(self): 39 | """ Return True if I'm subscribed currently """ 40 | return self.imSubscribed 41 | 42 | def SubscribeTopic(self, mqttClient: mqtt.Client): 43 | """ Subscribe to the topic using the passed MQTT client (if not already subscribed to) """ 44 | if not self.GetSubscriptionState(): 45 | mqttClient.subscribe(self.topic, 0) # TODO QoS settings 46 | self.SetAsSubscribed() 47 | 48 | def UnsubscribeTopic(self, mqttClient: mqtt.Client): 49 | """ Unubscribe from the topic using the passed MQTT client """ 50 | mqttClient.unsubscribe(self.topic) 51 | self.SetAsNotSubscribed() 52 | -------------------------------------------------------------------------------- /IoTuring/Settings/Deployments/AppSettings/AppSettings.py: -------------------------------------------------------------------------------- 1 | from IoTuring.Configurator.MenuPreset import MenuPreset 2 | from IoTuring.Settings.Settings import Settings 3 | 4 | 5 | CONFIG_KEY_UPDATE_INTERVAL = "update_interval" 6 | CONFIG_KEY_RETRY_INTERVAL = "retry_interval" 7 | # CONFIG_KEY_SLOW_INTERVAL = "slow_interval" 8 | 9 | 10 | class AppSettings(Settings): 11 | """Class that stores AppSettings, not related to a specifuc Entity or Warehouse """ 12 | NAME = "App" 13 | 14 | @classmethod 15 | def ConfigurationPreset(cls): 16 | preset = MenuPreset() 17 | 18 | preset.AddEntry(name="Main update interval in seconds", 19 | key=CONFIG_KEY_UPDATE_INTERVAL, mandatory=True, 20 | question_type="integer", default=10) 21 | 22 | preset.AddEntry(name="Connection retry interval in seconds", 23 | instruction="If broker is not available retry after this amount of time passed", 24 | key=CONFIG_KEY_RETRY_INTERVAL, mandatory=True, 25 | question_type="integer", default=1) 26 | 27 | 28 | # preset.AddEntry(name="Secondary update interval in minutes", 29 | # key=CONFIG_KEY_SLOW_INTERVAL, mandatory=True, 30 | # question_type="integer", default=10) 31 | 32 | return preset 33 | -------------------------------------------------------------------------------- /IoTuring/Settings/Deployments/LogSettings/LogSettings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from IoTuring.Configurator.MenuPreset import MenuPreset 4 | from IoTuring.Settings.Settings import Settings 5 | from IoTuring.Logger.Logger import Logger 6 | from IoTuring.Configurator.Configuration import SingleConfiguration 7 | from IoTuring.MyApp.App import App 8 | from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD 9 | from IoTuring.Logger.LogLevel import LogLevel 10 | from IoTuring.Logger import consts 11 | 12 | 13 | # macOS dep (in PyObjC) 14 | try: 15 | from AppKit import * # type:ignore 16 | from Foundation import * # type:ignore 17 | macos_support = True 18 | except: 19 | macos_support = False 20 | 21 | 22 | CONFIG_KEY_CONSOLE_LOG_LEVEL = "console_log_level" 23 | CONFIG_KEY_CONSOLE_LOG_TIME = "console_log_time" 24 | CONFIG_KEY_FILE_LOG_LEVEL = "file_log_level" 25 | CONFIG_KEY_FILE_LOG_ENABLED = "file_log_enabled" 26 | CONFIG_KEY_FILE_LOG_PATH = "file_log_path" 27 | 28 | 29 | all_loglevels = sorted(Logger.GetLoglevels(), key=lambda l: int(l)) 30 | 31 | loglevel_choices = [{"name": str(l).capitalize(), "value": str(l)} 32 | for l in all_loglevels] 33 | 34 | 35 | class LogSettings(Settings): 36 | NAME = "Log" 37 | 38 | def __init__(self, single_configuration: SingleConfiguration, early_init: bool) -> None: 39 | super().__init__(single_configuration, early_init) 40 | 41 | # Load settings to logger: 42 | logger = Logger() 43 | 44 | logger.SetupConsoleLogging( 45 | loglevel=LogLevel( 46 | self.GetFromConfigurations(CONFIG_KEY_CONSOLE_LOG_LEVEL)), 47 | include_time=self.GetTrueOrFalseFromConfigurations( 48 | CONFIG_KEY_CONSOLE_LOG_TIME) 49 | ) 50 | 51 | logger.SetupFileLogging( 52 | enabled=self.GetTrueOrFalseFromConfigurations( 53 | CONFIG_KEY_FILE_LOG_ENABLED), 54 | loglevel=LogLevel(self.GetFromConfigurations( 55 | CONFIG_KEY_FILE_LOG_LEVEL)), 56 | log_dir_path=Path(self.GetFromConfigurations( 57 | CONFIG_KEY_FILE_LOG_PATH)), 58 | early_init=early_init 59 | ) 60 | 61 | @classmethod 62 | def ConfigurationPreset(cls): 63 | preset = MenuPreset() 64 | 65 | preset.AddEntry(name="Console log level", key=CONFIG_KEY_CONSOLE_LOG_LEVEL, 66 | question_type="select", mandatory=True, default=str(LogLevel(consts.DEFAULT_LOG_LEVEL)), 67 | instruction="IOTURING_LOG_LEVEL envvar overwrites this setting!", 68 | choices=loglevel_choices) 69 | 70 | preset.AddEntry(name="Display time in console log", key=CONFIG_KEY_CONSOLE_LOG_TIME, 71 | question_type="yesno", default="Y") 72 | 73 | preset.AddEntry(name="Enable file logging", key=CONFIG_KEY_FILE_LOG_ENABLED, 74 | question_type="yesno", default="N") 75 | 76 | preset.AddEntry(name="File log level", key=CONFIG_KEY_FILE_LOG_LEVEL, 77 | question_type="select", mandatory=True, default=str(LogLevel(consts.DEFAULT_LOG_LEVEL)), 78 | choices=loglevel_choices, display_if_key_value={CONFIG_KEY_FILE_LOG_ENABLED: "Y"}) 79 | 80 | preset.AddEntry(name="File log path", key=CONFIG_KEY_FILE_LOG_PATH, 81 | question_type="filepath", mandatory=True, default=cls.GetDefaultLogPath(), 82 | instruction="Directory where log files will be saved", 83 | display_if_key_value={CONFIG_KEY_FILE_LOG_ENABLED: "Y"}) 84 | 85 | return preset 86 | 87 | @staticmethod 88 | def GetDefaultLogPath() -> str: 89 | 90 | default_path = App.getRootPath().joinpath( 91 | "Logger").joinpath(consts.LOGS_FOLDER) 92 | base_path = None 93 | 94 | if OsD.IsMacos() and macos_support: 95 | base_path = \ 96 | Path(NSSearchPathForDirectoriesInDomains( # type: ignore 97 | NSLibraryDirectory, # type: ignore 98 | NSUserDomainMask, True)[0]) # type: ignore 99 | elif OsD.IsWindows(): 100 | base_path = Path(OsD.GetEnv("LOCALAPPDATA")) 101 | elif OsD.IsLinux(): 102 | if OsD.GetEnv("XDG_CACHE_HOME"): 103 | base_path = Path(OsD.GetEnv("XDG_CACHE_HOME")) 104 | elif OsD.GetEnv("HOME"): 105 | base_path = Path(OsD.GetEnv("HOME")).joinpath(".cache") 106 | 107 | if base_path: 108 | default_path = base_path.joinpath(App.getName()) 109 | 110 | return str(default_path) 111 | -------------------------------------------------------------------------------- /IoTuring/Settings/Settings.py: -------------------------------------------------------------------------------- 1 | from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject 2 | from IoTuring.Settings.SettingsManager import SettingsManager 3 | from IoTuring.Configurator.MenuPreset import BooleanAnswers 4 | from IoTuring.Configurator.Configuration import SingleConfiguration 5 | 6 | 7 | class Settings(ConfiguratorObject): 8 | """Base class for settings""" 9 | NAME = "Settings" 10 | 11 | def __init__(self, single_configuration: SingleConfiguration, early_init: bool) -> None: 12 | """Initialize a settings class 13 | 14 | Args: 15 | single_configuration (SingleConfiguration): The configuration 16 | early_init (bool): True when loaded before configurator menu, False when added to SettingsManager 17 | """ 18 | super().__init__(single_configuration) 19 | 20 | @classmethod 21 | def GetFromSettingsConfigurations(cls, key: str): 22 | """Get value from settings' saved configurations from SettingsManager 23 | 24 | Args: 25 | key (str): The CONFIG_KEY of the configuration 26 | 27 | Raises: 28 | Exception: If the key not found 29 | 30 | Returns: 31 | Any: The config value 32 | """ 33 | 34 | sM = SettingsManager() 35 | saved_config = sM.GetConfigOfType(cls) 36 | 37 | if saved_config.HasConfigKey(key): 38 | return saved_config.GetConfigValue(key) 39 | else: 40 | raise Exception( 41 | f"Can't find key {key} in SettingsManager configurations") 42 | 43 | @classmethod 44 | def GetTrueOrFalseFromSettingsConfigurations(cls, key: str) -> bool: 45 | """Get boolean value from settings' saved configurations from SettingsManager 46 | 47 | Args: 48 | key (str): The CONFIG_KEY of the configuration 49 | 50 | Returns: 51 | bool: The config value 52 | """ 53 | 54 | value = cls.GetFromSettingsConfigurations(key).lower() 55 | return bool(value in BooleanAnswers.TRUE_ANSWERS) 56 | -------------------------------------------------------------------------------- /IoTuring/Settings/SettingsManager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | if TYPE_CHECKING: 4 | from IoTuring.Settings.Settings import Settings 5 | from IoTuring.Configurator.Configuration import SingleConfiguration 6 | 7 | from IoTuring.Logger.Logger import Singleton 8 | from IoTuring.Logger.LogObject import LogObject 9 | 10 | 11 | class SettingsManager(LogObject, metaclass=Singleton): 12 | """Singleton for storing configurations of Settings""" 13 | 14 | def __init__(self) -> None: 15 | self.setting_configs = {} 16 | 17 | def AddSettings(self, setting_entities: list[Settings]) -> None: 18 | """Add settings configuration 19 | 20 | Args: 21 | setting_entities (list[Settings]): The loaded settings classes 22 | """ 23 | for setting_entity in setting_entities: 24 | self.setting_configs[setting_entity.NAME] = setting_entity.configurations 25 | 26 | def GetConfigOfType(self, setting_class) -> SingleConfiguration: 27 | """Get the configuration of a saved class. Raises exception if not found""" 28 | 29 | if setting_class.NAME in self.setting_configs: 30 | return self.setting_configs[setting_class.NAME] 31 | else: 32 | raise Exception(f"No settings config for {setting_class.NAME}") 33 | -------------------------------------------------------------------------------- /IoTuring/Warehouse/Deployments/ConsoleWarehouse/ConsoleWarehouse.py: -------------------------------------------------------------------------------- 1 | from IoTuring.Logger.Logger import Logger 2 | from IoTuring.Warehouse.Warehouse import Warehouse 3 | from IoTuring.Entity.ValueFormat import ValueFormatter 4 | from IoTuring.Entity.EntityData import EntitySensor 5 | 6 | class ConsoleWarehouse(Warehouse): 7 | NAME = "Console" 8 | 9 | def Loop(self): 10 | for entity in self.GetEntities(): 11 | for entitySensor in entity.GetEntitySensors(): 12 | if(entitySensor.HasValue()): 13 | self.Log(self.LOG_INFO, entitySensor.GetId() + 14 | ": " + self.FormatValue(entitySensor)) 15 | 16 | def FormatValue(self, entitySensor: EntitySensor): 17 | return ValueFormatter.FormatValue(entitySensor.GetValue(), entitySensor.GetValueFormatterOptions(), True) -------------------------------------------------------------------------------- /IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml: -------------------------------------------------------------------------------- 1 | Connectivity: #LWT 2 | custom_type: binary_sensor 3 | device_class: connectivity 4 | Battery - percentage: 5 | name: Battery Level 6 | unit_of_measurement: "%" 7 | device_class: battery 8 | Battery - charging: 9 | name: Battery State 10 | custom_type: binary_sensor 11 | device_class: battery_charging 12 | payload_on: "True" 13 | payload_off: "False" 14 | OperatingSystem: 15 | name: Operating System 16 | icon: mdi:apple-keyboard-command 17 | ActiveWindow: 18 | name: Active Window 19 | icon: mdi:window-restore 20 | BootTime: 21 | name: Boot Time 22 | icon: mdi:clock 23 | UpTime: 24 | name: Uptime 25 | unit_of_measurement: min 26 | icon: mdi:clock 27 | DesktopEnvironment: 28 | name: Desktop Environment 29 | icon: mdi:window-maximize 30 | Username: 31 | icon: mdi:account-supervisor-circle 32 | Lock: 33 | icon: mdi:lock 34 | NotifyPayload: 35 | icon: mdi:forum 36 | custom_type: text 37 | Notify: 38 | icon: mdi:forum 39 | Hostname: 40 | icon: mdi:form-textbox 41 | Power - shutdown: 42 | icon: mdi:power 43 | Power - reboot: 44 | icon: mdi:restart 45 | Power - sleep: 46 | icon: mdi:power-sleep 47 | Disk: 48 | unit_of_measurement: "%" 49 | icon: mdi:harddisk 50 | Ram .*(free|total|available|used): 51 | unit_of_measurement: MB 52 | icon: mdi:memory 53 | Ram .*percentage: 54 | unit_of_measurement: "%" 55 | icon: mdi:memory 56 | Cpu .*percentage: 57 | unit_of_measurement: "%" 58 | icon: mdi:calculator-variant 59 | Cpu .*freq.*: 60 | unit_of_measurement: MHz 61 | icon: mdi:calculator-variant 62 | Cpu: # if no matches with above CPU patterns, set this 63 | icon: mdi:calculator-variant 64 | Time: 65 | icon: mdi:clock 66 | Monitor: 67 | icon: mdi:monitor-shimmer 68 | custom_type: switch 69 | AppInfo - update: 70 | custom_type: binary_sensor 71 | icon: mdi:package-up 72 | device_class: update 73 | payload_on: "True" 74 | payload_off: "False" 75 | AppInfo: 76 | icon: mdi:information-outline 77 | Temperature: 78 | icon: mdi:thermometer-lines 79 | unit_of_measurement: °C 80 | Fanspeed: 81 | icon: mdi:fan 82 | unit_of_measurement: rpm 83 | DisplayMode: 84 | name: Display Mode 85 | icon: mdi:monitor-multiple 86 | custom_type: select 87 | options: 88 | - Only internal display 89 | - Only external display 90 | - Clone displays 91 | - Extend displays 92 | FileSwitch: 93 | icon: mdi:file-star 94 | payload_on: "True" 95 | payload_off: "False" 96 | Volume: 97 | icon: mdi:volume-high 98 | unit_of_measurement: "%" 99 | custom_type: number 100 | Wifi: 101 | icon: mdi:wifi 102 | TerminalPayloadCommand: 103 | name: Terminal Command 104 | icon: mdi:console-line 105 | custom_type: text 106 | TerminalSwitch: 107 | name: Terminal Switch 108 | icon: mdi:console 109 | custom_type: switch 110 | TerminalSensor: 111 | name: Terminal Sensor 112 | icon: mdi:console 113 | custom_type: sensor 114 | TerminalBinarySensor: 115 | name: Terminal Binary Sensor 116 | icon: mdi:console 117 | custom_type: binary_sensor 118 | TerminalCover: 119 | name: Terminal Cover 120 | icon: mdi:window-shutter 121 | custom_type: cover 122 | state_closing: CLOSE 123 | state_opening: OPEN 124 | state_stopped: STOP 125 | TerminalButton: 126 | name: Terminal Button 127 | icon: mdi:console 128 | custom_type: button 129 | -------------------------------------------------------------------------------- /IoTuring/Warehouse/Deployments/MQTTWarehouse/MQTTWarehouse.py: -------------------------------------------------------------------------------- 1 | from IoTuring.Configurator.MenuPreset import MenuPreset 2 | from IoTuring.Protocols.MQTTClient.MQTTClient import MQTTClient 3 | from IoTuring.Warehouse.Warehouse import Warehouse 4 | from IoTuring.MyApp.App import App 5 | from IoTuring.Entity.ValueFormat import ValueFormatter 6 | 7 | import inspect # To get this folder path 8 | import os # To get this folder path 9 | import time 10 | 11 | 12 | 13 | TOPIC_FORMAT = "{}/{}/{}" # That stands for: App name, Client name, EntityData Id 14 | 15 | OUTPUT_TOPICS_FILENAME = "commands_topic.txt" 16 | 17 | CONFIG_KEY_ADDRESS = "address" 18 | CONFIG_KEY_PORT = "port" 19 | CONFIG_KEY_NAME = "name" 20 | CONFIG_KEY_USERNAME = "username" 21 | CONFIG_KEY_PASSWORD = "password" 22 | CONFIG_KEY_ADD_UNITS = "add_units" 23 | 24 | 25 | class MQTTWarehouse(Warehouse): 26 | NAME = "MQTT" 27 | 28 | def Start(self): 29 | # I configure my Warehouse with configurations 30 | self.clientName = self.GetFromConfigurations(CONFIG_KEY_NAME) 31 | self.client = MQTTClient(self.GetFromConfigurations(CONFIG_KEY_ADDRESS), 32 | self.GetFromConfigurations(CONFIG_KEY_PORT), 33 | self.GetFromConfigurations(CONFIG_KEY_NAME), 34 | self.GetFromConfigurations( 35 | CONFIG_KEY_USERNAME), 36 | self.GetFromConfigurations(CONFIG_KEY_PASSWORD)) 37 | self.addUnitsToValues = self.GetFromConfigurations(CONFIG_KEY_ADD_UNITS) # is a boolean 38 | self.client.AsyncConnect() 39 | self.RegisterEntityCommands() 40 | 41 | super().Start() # Then run other inits (start the loop for example) 42 | 43 | def RegisterEntityCommands(self): 44 | """ Add EntityCommands to the MQTT client (subscribe to them) """ 45 | for entity in self.GetEntities(): 46 | for entityCommand in entity.GetEntityCommands(): 47 | self.client.AddNewTopicToSubscribeTo( 48 | self.MakeTopic(entityCommand), entityCommand.CallCallback) 49 | self.Log(self.LOG_DEBUG, entityCommand.GetId() + 50 | " subscribed to " + self.MakeTopic(entityCommand)) 51 | self.ExportCommandsTopics() 52 | 53 | def Loop(self): 54 | while(not self.client.IsConnected()): 55 | time.sleep(self.retry_interval) 56 | 57 | # Here in Loop I send sensor's data (command callbacks are not managed here) 58 | for entity in self.GetEntities(): 59 | for entitySensor in entity.GetEntitySensors(): 60 | if(entitySensor.HasValue()): 61 | value = ValueFormatter.FormatValue(entitySensor.GetValue(), entitySensor.GetValueFormatterOptions(), self.addUnitsToValues) 62 | self.client.SendTopicData(self.MakeTopic( 63 | entitySensor), value) 64 | 65 | def MakeTopic(self, entityData): 66 | return MQTTClient.NormalizeTopic(TOPIC_FORMAT.format(App.getName(), self.clientName, entityData.GetId())) 67 | 68 | def ExportCommandsTopics(self): 69 | """ Create a file on which I write the Entity command Id and the topic """ 70 | thisFolder = os.path.dirname(inspect.getfile(MQTTWarehouse)) 71 | path = os.path.join(thisFolder, OUTPUT_TOPICS_FILENAME) 72 | with open(path, "w") as f: 73 | for entity in self.GetEntities(): 74 | for entityCommand in entity.GetEntityCommands(): 75 | f.write(entityCommand.GetId() + " registered on " + 76 | self.MakeTopic(entityCommand)+"\n") 77 | 78 | # CONFIGURATION 79 | @classmethod 80 | def ConfigurationPreset(cls) -> MenuPreset: 81 | preset = MenuPreset() 82 | preset.AddEntry("Address", CONFIG_KEY_ADDRESS, mandatory=True) 83 | preset.AddEntry("Port", CONFIG_KEY_PORT, default=1883, question_type="integer") 84 | preset.AddEntry("Client name", CONFIG_KEY_NAME, default=App.getName()) 85 | preset.AddEntry("Username", CONFIG_KEY_USERNAME) 86 | preset.AddEntry("Password", CONFIG_KEY_PASSWORD, question_type="secret") 87 | preset.AddEntry("Add units to values", CONFIG_KEY_ADD_UNITS, default="Y", question_type="yesno") 88 | return preset 89 | -------------------------------------------------------------------------------- /IoTuring/Warehouse/README.md: -------------------------------------------------------------------------------- 1 | # Warehouse 2 | 3 | ## New Warehouse 4 | 5 | Subclasses of "Warehouse" must have this functions NOT SURE NOW 6 | 7 | ``` 8 | 9 | @staticmethod 10 | def InstantiateWithConfiguration(configuration) -> None: 11 | """ Receive a configuration and instantiate the warehouse with the correct ordered parameters """ 12 | return 13 | 14 | @staticmethod 15 | def ConfigurationPreset() -> MenuPreset: 16 | """ Prepare a preset to manage settings insert/edit for the warehouse """ 17 | return None 18 | 19 | ``` 20 | 21 | These aren't in "Warehouse" because are static so would be unique in all Warehouses 22 | 23 | Example present in HomeAssistantWarehouse 24 | 25 | If not implmented: 26 | 27 | - **InstantiateWithConfiguration** is replaced by the initializer of the class so the class will be initialized without a configuration 28 | - **ConfigurationPreset** is not replaced: the menu will be blank and no configuration will be available for this warehouse 29 | -------------------------------------------------------------------------------- /IoTuring/Warehouse/Warehouse.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | if TYPE_CHECKING: 4 | from IoTuring.Entity.Entity import Entity 5 | from IoTuring.Configurator.Configuration import SingleConfiguration 6 | 7 | from threading import Thread 8 | import time 9 | 10 | from IoTuring.Logger.LogObject import LogObject 11 | from IoTuring.Configurator.ConfiguratorObject import ConfiguratorObject 12 | from IoTuring.Entity.EntityManager import EntityManager 13 | 14 | from IoTuring.Settings.Deployments.AppSettings.AppSettings import AppSettings, CONFIG_KEY_UPDATE_INTERVAL, CONFIG_KEY_RETRY_INTERVAL 15 | 16 | 17 | class Warehouse(ConfiguratorObject, LogObject): 18 | 19 | def __init__(self, single_configuration: SingleConfiguration) -> None: 20 | super().__init__(single_configuration) 21 | 22 | self.loopTimeout = int( 23 | AppSettings.GetFromSettingsConfigurations(CONFIG_KEY_UPDATE_INTERVAL)) 24 | self.retry_interval = int(AppSettings 25 | .GetFromSettingsConfigurations(CONFIG_KEY_RETRY_INTERVAL)) 26 | 27 | def Start(self) -> None: 28 | """ Initial configuration and start the thread that will loop the Warehouse.Loop() function""" 29 | thread = Thread(target=self.LoopThread) 30 | thread.daemon = True 31 | thread.start() 32 | 33 | def SetLoopTimeout(self, timeout) -> None: 34 | """ Set a timeout between 2 loops """ 35 | self.loopTimeout = timeout 36 | 37 | def ShouldCallLoop(self) -> bool: 38 | """ Wait the timeout time and then tell it can run the Loop function """ 39 | time.sleep(self.loopTimeout) 40 | return True 41 | 42 | def LoopThread(self) -> None: 43 | """ Entry point of the warehouse thread, will run Loop() periodically """ 44 | self.Loop() # First call without sleep before 45 | while (True): 46 | if self.ShouldCallLoop(): 47 | self.Loop() 48 | 49 | def GetEntities(self) -> list[Entity]: 50 | return EntityManager().GetEntities() 51 | 52 | # Called by "LoopThread", with time constant defined in "ShouldCallLoop" 53 | def Loop(self) -> None: 54 | """ Must be implemented in subclasses, autorun at Warehouse __init__ """ 55 | raise NotImplementedError( 56 | "Please implement Loop method for this Warehouse") 57 | 58 | def GetWarehouseName(self) -> str: 59 | return self.NAME 60 | 61 | def GetWarehouseId(self) -> str: 62 | return "Warehouse." + self.GetWarehouseName() 63 | 64 | def LogSource(self): 65 | return self.GetWarehouseId() 66 | -------------------------------------------------------------------------------- /IoTuring/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import signal 4 | import sys 5 | import time 6 | import argparse 7 | 8 | from IoTuring.MyApp.App import App 9 | from IoTuring.Configurator.Configurator import Configurator 10 | from IoTuring.Configurator.ConfiguratorLoader import ConfiguratorLoader 11 | from IoTuring.Entity.EntityManager import EntityManager 12 | from IoTuring.Settings.SettingsManager import SettingsManager 13 | from IoTuring.Logger.Logger import Logger 14 | from IoTuring.Logger.Colors import Colors 15 | 16 | warehouses = [] 17 | entities = [] 18 | 19 | 20 | def loop(): 21 | 22 | parser = argparse.ArgumentParser( 23 | prog=App.getName(), 24 | description=App.getDescription(), 25 | epilog="Start without argument for normal use" 26 | ) 27 | 28 | parser.add_argument("-v", "--version", 29 | action="version", 30 | version=f"{App.getName()} {App.getVersion()}" 31 | ) 32 | 33 | parser.add_argument("-c", "--configurator", 34 | help="enter configuration mode", 35 | action="store_true") 36 | 37 | parser.add_argument("-o", "--open-config", 38 | help="open config file", 39 | action="store_true") 40 | 41 | args = parser.parse_args() 42 | 43 | # Only one argument should be used: 44 | if all(vars(args).values()): 45 | parser.print_help() 46 | print() 47 | sys.exit("Error: Invalid arguments!") 48 | 49 | # Clear the terminal 50 | Configurator.ClearTerminal() 51 | 52 | # Start logger: 53 | logger = Logger() 54 | configurator = Configurator() 55 | 56 | # Early log settings: 57 | ConfiguratorLoader(configurator).LoadSettings(early_init=True) 58 | 59 | logger.Log(Logger.LOG_DEBUG, "App", f"Selected options: {vars(args)}") 60 | 61 | if args.configurator: 62 | try: 63 | # Check old location: 64 | configurator.CheckFile() 65 | 66 | configurator.Menu() 67 | except KeyboardInterrupt: 68 | logger.Log(Logger.LOG_WARNING, "Configurator", 69 | "Configuration NOT saved") 70 | Exit_SIGINT_handler() 71 | 72 | elif args.open_config: 73 | configurator.OpenConfigInEditor() 74 | sys.exit(0) 75 | 76 | # This have to start after configurator.Menu(), otherwise won't work starting from the menu 77 | signal.signal(signal.SIGINT, Exit_SIGINT_handler) 78 | 79 | # Load Settings: 80 | settings = ConfiguratorLoader(configurator).LoadSettings() 81 | sM = SettingsManager() 82 | sM.AddSettings(settings) 83 | 84 | logger.Log(Logger.LOG_INFO, "App", App()) # Print App info 85 | 86 | # Add help if not started from Configurator 87 | if not args.configurator: 88 | logger.Log(Logger.LOG_INFO, "Configurator", 89 | "Run the script with -c to enter configuration mode") 90 | 91 | eM = EntityManager() 92 | 93 | # These will be done from the configuration reader 94 | entities = ConfiguratorLoader(configurator).LoadEntities() 95 | warehouses = ConfiguratorLoader(configurator).LoadWarehouses() 96 | 97 | # Add entites to the EntityManager 98 | for entity in entities: 99 | eM.AddActiveEntity(entity) 100 | 101 | # Ready to start Entities loop 102 | eM.Start() 103 | 104 | # Prepare warehouses - # after entities, so entitites have already told to which EntityCommand I need to subscribe ! 105 | for warehouse in warehouses: 106 | warehouse.Start() 107 | 108 | logger.Log(Logger.LOG_DEBUG, "Main", "Main finished its work ;)") 109 | 110 | # Threads are in daemon mode (entities and warehouses) because 111 | # on Windows a SIGINT signal can't be catched otherwise. 112 | # Daemon mode involves thread exit when main ends. So 113 | # I need main to never end 114 | while (True): 115 | time.sleep(1) 116 | 117 | 118 | def Exit_SIGINT_handler(sig=None, frame=None): 119 | logger = Logger() 120 | logger.Log(Logger.LOG_INFO, "Main", "Application closed by SigInt", 121 | logtarget=Logger.LOGTARGET_FILE) # to file 122 | 123 | messages = ["Exiting...", 124 | "Thanks for using IoTuring !"] 125 | 126 | print() # New line 127 | logger.Log(Logger.LOG_INFO, "Main", messages, 128 | color=Colors.cyan, logtarget=Logger.LOGTARGET_CONSOLE) 129 | 130 | sys.exit(0) 131 | -------------------------------------------------------------------------------- /IoTuring/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from IoTuring import loop 4 | 5 | if __name__ == "__main__": 6 | loop() 7 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | ioturing: 3 | image: richibrics/ioturing:latest 4 | # Or from Github: 5 | # image: ghcr.io/richibrics/ioturing:latest 6 | container_name: ioturing 7 | volumes: 8 | - ~/.config/IoTuring/:/config 9 | environment: 10 | - TZ=Europe/Budapest 11 | restart: unless-stopped -------------------------------------------------------------------------------- /docs/Autostart tips.md: -------------------------------------------------------------------------------- 1 | # Autostart tips 2 | 3 | Built-in autostart functionality is planned, but until it's not implemented, here are some tips how to set it up manually. 4 | 5 | ## Windows 6 | 7 | ### Bat file called from a vbs script 8 | 9 | [inobrevi](https://github.com/inobrevi)'s solution from [here](https://github.com/richibrics/IoTuring/issues/94#issuecomment-2002548352): 10 | 11 | After configuring IoTuring, you can create a `IoTuring.bat` file: 12 | 13 | ``` 14 | @echo off 15 | python -m IoTuring 16 | ``` 17 | 18 | And then run it from `IoTuring_hidden.vbs` script, which would run it without showing console window: 19 | 20 | ``` 21 | CreateObject("Wscript.Shell").Run "IoTuring.bat",0,True 22 | ``` 23 | 24 | Save both files in the same folder, run `IoTuring_hidden.vbs` and it just works. 25 | 26 | You can also add this .vbs script to autorun 27 | 28 | ### NSSM 29 | 30 | With nssm it's possible to create a service for any command: https://nssm.cc 31 | 32 | ## Linux 33 | 34 | ### As a user service with Systemd (recommended) 35 | 36 | Example service file is here: [IoTuring.service](IoTuring.service) 37 | 38 | Install the service: 39 | 40 | 44 | ```shell 45 | mkdir -p ~/.local/share/systemd/user/ 46 | cp docs/IoTuring.service ~/.local/share/systemd/user/ 47 | 48 | systemctl --user daemon-reload 49 | systemctl --user edit IoTuring.service 50 | ``` 51 | 52 | Add to the override: 53 | 54 | ```ini 55 | [Service] 56 | # Select start command according to installation: 57 | # system wide installation: 58 | ExecStart=IoTuring 59 | # Pipx: 60 | ExecStart=pipx run IoTuring 61 | # Venv: 62 | ExecStart=/path/to/IoTuring/.venv/bin/IoTuring 63 | ``` 64 | Enable and start the service: 65 | 66 | ```shell 67 | systemctl --user enable IoTuring.service 68 | systemctl --user start IoTuring.service 69 | ``` 70 | 71 | #### Start user service before login 72 | 73 | This is optional. Without this, IoTuring starts after login. 74 | 75 | ```shell 76 | # Enable the automatic login of the user, so IoTuring can start right after boot: 77 | loginctl enable-linger $(whoami) 78 | 79 | # Copy the service which restarts IoTuring: 80 | cp docs/IoTuring-restart.service ~/.local/share/systemd/user/ 81 | 82 | systemctl --user daemon-reload 83 | systemctl --user enable IoTuring-restart.service 84 | systemctl --user start IoTuring-restart.service 85 | ``` 86 | 87 | ### As a system service with Systemd 88 | 89 | Some entities may not work if installed as a system service 90 | 91 | Example service file is here: [IoTuring.service](IoTuring.service) 92 | 93 | Install the service: 94 | 95 | ```shell 96 | sudo cp docs/IoTuring.service /usr/lib/systemd/system/ 97 | 98 | sudo systemctl daemon-reload 99 | sudo systemctl edit IoTuring.service 100 | ``` 101 | 102 | Add to the override: 103 | 104 | ```ini 105 | [Service] 106 | # User and group to run as: 107 | User=user 108 | Group=group 109 | 110 | # Select start command according to installation: 111 | # system wide installation: 112 | ExecStart=IoTuring 113 | # Pipx: 114 | ExecStart=pipx run IoTuring 115 | # Venv: 116 | ExecStart=/path/to/IoTuring/.venv/bin/IoTuring 117 | ``` 118 | 119 | Enable and start the service: 120 | 121 | ```shell 122 | sudo systemctl enable IoTuring.service 123 | sudo systemctl start IoTuring.service 124 | ``` -------------------------------------------------------------------------------- /docs/DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | ## Install 4 | 5 | ### Editable install 6 | 7 | [Pip documentation](https://pip.pypa.io/en/stable/topics/local-project-installs/) 8 | 9 | ```shell 10 | git clone https://github.com/richibrics/IoTuring 11 | cd IoTuring 12 | pip install -e . 13 | ``` 14 | 15 | Then run it like in the non-editable mode. 16 | 17 | Warning: sometimes to run the module in editable mode you need to cd into the upper IoTuring folder. 18 | 19 | 20 | ### Editable install with venv from git 21 | 22 | ```shell 23 | git clone https://github.com/richibrics/IoTuring 24 | cd IoTuring 25 | mkdir .venv 26 | python -m venv .venv 27 | . ./.venv/bin/activate 28 | pip install --upgrade pip 29 | pip install -e . 30 | IoTuring -c 31 | ``` 32 | 33 | ## Versioning 34 | 35 | The project uses [calendar versioning](https://calver.org/): 36 | 37 | `YYYY.M.n`: 38 | 39 | - `YYYY`: Full year: 2022, 2023 ... 40 | - `M`: Month: 1, 2 ... 11, 12 41 | - `n`: Build number in the month: 1, 2 ... 42 | 43 | ## Tests 44 | 45 | ### Run tests in docker: 46 | 47 | ```shell 48 | docker run --rm -it $(docker build -q -f tests.Dockerfile .) 49 | ``` 50 | 51 | ### Run tests in a venv 52 | 53 | ```shell 54 | pip install -e ".[test]" 55 | python -m pytest 56 | ``` 57 | 58 | ## Docker 59 | 60 | To build docker image: 61 | 62 | ``` 63 | docker build -t ioturing:latest . 64 | ``` 65 | 66 | ## Tips 67 | 68 | ### InquirerPy 69 | 70 | Current InquirerPy release on Pypi doesn't export classes properly, so type checking gives false positives. To fix this, remove the version installed from Pypi, and install it from Github: 71 | 72 | ```shell 73 | pip uninstall InquirerPy 74 | pip install git+https://github.com/kazhala/InquirerPy 75 | ``` 76 | 77 | ### Docstrings 78 | 79 | To generate nice docstrings quickly in VSCode, use the [autoDocstring extension](https://github.com/NilsJPWerner/autoDocstring) -------------------------------------------------------------------------------- /docs/IoTuring-restart.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Restart IoTuring after login 3 | 4 | [Service] 5 | Type=oneshot 6 | ExecStart=/usr/bin/systemctl --user restart IoTuring.service 7 | 8 | [Install] 9 | WantedBy=graphical-session.target -------------------------------------------------------------------------------- /docs/IoTuring.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | 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. 3 | 4 | [Service] 5 | Type=simple 6 | RemainAfterExit=yes 7 | TimeoutStartSec=0 8 | 9 | [Install] 10 | WantedBy=default.target -------------------------------------------------------------------------------- /docs/images/device_info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richibrics/IoTuring/4253570b603a3ccfd3b90a24ed2d102cf683d18a/docs/images/device_info.png -------------------------------------------------------------------------------- /docs/images/homeassistant-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richibrics/IoTuring/4253570b603a3ccfd3b90a24ed2d102cf683d18a/docs/images/homeassistant-demo.gif -------------------------------------------------------------------------------- /docs/images/linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richibrics/IoTuring/4253570b603a3ccfd3b90a24ed2d102cf683d18a/docs/images/linux.png -------------------------------------------------------------------------------- /docs/images/mac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richibrics/IoTuring/4253570b603a3ccfd3b90a24ed2d102cf683d18a/docs/images/mac.png -------------------------------------------------------------------------------- /docs/images/win.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richibrics/IoTuring/4253570b603a3ccfd3b90a24ed2d102cf683d18a/docs/images/win.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "IoTuring" 3 | version = "2024.6.1" 4 | description = "Your Windows, Linux, macOS computer as MQTT and HomeAssistant integration." 5 | readme = "README.md" 6 | requires-python = ">=3.8" 7 | license = {file = "COPYING"} 8 | keywords = ["iot","mqtt","monitor","homeassistant"] 9 | authors = [ 10 | {name = "richibrics", email = "riccardo.briccola.dev@gmail.com"}, 11 | {name = "infeeeee", email = "gyetpet@mailbox.org"} 12 | ] 13 | maintainers = [ 14 | {name = "richibrics", email = "riccardo.briccola.dev@gmail.com"}, 15 | {name = "infeeeee", email = "gyetpet@mailbox.org"} 16 | ] 17 | classifiers = [ 18 | "Development Status :: 3 - Alpha", 19 | "Environment :: Console", 20 | "Programming Language :: Python", 21 | "Topic :: System :: Monitoring" 22 | ] 23 | 24 | dependencies = [ 25 | "paho-mqtt>=2.0.0", 26 | "psutil", 27 | "PyYAML", 28 | "requests", 29 | "InquirerPy", 30 | "PyObjC; sys_platform == 'darwin'", 31 | "IoTuring-applesmc; sys_platform == 'darwin'", 32 | "tinyWinToast; sys_platform == 'win32'" 33 | ] 34 | 35 | [project.optional-dependencies] 36 | test = [ 37 | "pytest" 38 | ] 39 | 40 | [project.urls] 41 | homepage = "https://github.com/richibrics/IoTuring" 42 | documentation = "https://github.com/richibrics/IoTuring" 43 | repository = "https://github.com/richibrics/IoTuring" 44 | changelog = "https://github.com/richibrics/IoTuring/releases" 45 | 46 | [project.scripts] 47 | IoTuring = "IoTuring:loop" 48 | 49 | [build-system] 50 | requires = ["setuptools", "wheel"] 51 | 52 | [tool.setuptools.package-data] 53 | "*" = ["entities.yaml", "icon.png"] 54 | -------------------------------------------------------------------------------- /tests.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8.17-slim-bullseye 2 | 3 | WORKDIR /root/IoTuring 4 | 5 | COPY . . 6 | 7 | RUN pip install --no-cache-dir .[test] 8 | 9 | CMD python -m pytest 10 | 11 | # docker run --rm -it $(docker build -q -f tests.Dockerfile .) -------------------------------------------------------------------------------- /tests/ClassManager/test_ClassManager.py: -------------------------------------------------------------------------------- 1 | from IoTuring.ClassManager.ClassManager import ClassManager, KEY_ENTITY, KEY_WAREHOUSE 2 | 3 | 4 | class TestClassManager: 5 | def testClassCount(self): 6 | for class_key in [KEY_ENTITY, KEY_WAREHOUSE]: 7 | 8 | cm = ClassManager(class_key) 9 | assert bool(cm.loadedClasses) == False 10 | 11 | class_num = len(cm.ListAvailableClasses()) 12 | assert class_num == len(cm.GetModuleFilePaths()) 13 | assert class_num == len(cm.loadedClasses) 14 | -------------------------------------------------------------------------------- /tests/Entity/Deployments/Temperature/test_Temperature.py: -------------------------------------------------------------------------------- 1 | from IoTuring.Entity.Deployments.Temperature.Temperature import psutilTemperaturePackage, psutilTemperatureSensor 2 | 3 | class TestPsutilTemperatureSensor: 4 | def testGetters(self): 5 | sensorData = ["sensorLabel", 91, 101, True] 6 | sensor = psutilTemperatureSensor(sensorData) 7 | assert sensor.hasLabel() 8 | assert sensor.hasCurrent() 9 | assert sensor.hasHighest() 10 | assert sensor.hasCritical() 11 | assert sensor.getLabel() == "sensorLabel" 12 | assert sensor.getCurrent() == 91 13 | assert sensor.getHighest() == 101 14 | assert sensor.getCritical() == True 15 | 16 | sensorData = [None, 91, 101, True] 17 | sensor = psutilTemperatureSensor(sensorData) 18 | assert not sensor.hasLabel() 19 | assert sensor.hasCurrent() 20 | assert sensor.hasHighest() 21 | assert sensor.hasCritical() 22 | 23 | sensorData = ["sensorLabel", None, 101, True] 24 | sensor = psutilTemperatureSensor(sensorData) 25 | assert sensor.hasLabel() 26 | assert not sensor.hasCurrent() 27 | assert sensor.hasHighest() 28 | assert sensor.hasCritical() 29 | 30 | sensorData = ["sensorLabel", 91, None, True] 31 | sensor = psutilTemperatureSensor(sensorData) 32 | assert sensor.hasLabel() 33 | assert sensor.hasCurrent() 34 | assert not sensor.hasHighest() 35 | assert sensor.hasCritical() 36 | 37 | sensorData = ["sensorLabel", 91, 101, None] 38 | sensor = psutilTemperatureSensor(sensorData) 39 | assert sensor.hasLabel() 40 | assert sensor.hasCurrent() 41 | assert sensor.hasHighest() 42 | assert not sensor.hasCritical() 43 | 44 | 45 | class TestPsutilTemperaturePackage: 46 | def testHas(self): 47 | # package p has attribute (result = True) iff at least a sensor of p has that attribute 48 | packageData = [] 49 | packageData.append(["sensorLabel", 80, 101, True]) 50 | packageData.append(["sensorLabel", 90, 99, False]) 51 | package = psutilTemperaturePackage("pkg", packageData) 52 | assert package.hasCurrent() 53 | assert package.hasHighest() 54 | assert package.hasCritical() 55 | 56 | packageData = [] 57 | packageData.append(["sensorLabel", None, None, None]) 58 | packageData.append(["sensorLabel", 90, 99, False]) 59 | package = psutilTemperaturePackage("pkg", packageData) 60 | assert package.hasCurrent() 61 | assert package.hasHighest() 62 | assert package.hasCritical() 63 | 64 | packageData = [] 65 | packageData.append(["sensorLabel", 11, 99, True]) 66 | packageData.append(["sensorLabel", None, None, None]) 67 | package = psutilTemperaturePackage("pkg", packageData) 68 | assert package.hasCurrent() 69 | assert package.hasHighest() 70 | assert package.hasCritical() 71 | 72 | packageData = [] 73 | packageData.append(["sensorLabel", 11, None, True]) 74 | packageData.append(["sensorLabel", None, 156, None]) 75 | package = psutilTemperaturePackage("pkg", packageData) 76 | assert package.hasCurrent() 77 | assert package.hasHighest() 78 | assert package.hasCritical() 79 | 80 | packageData = [] 81 | packageData.append(["sensorLabel", None, None, None]) 82 | packageData.append(["sensorLabel", None, None, None]) 83 | package = psutilTemperaturePackage("pkg", packageData) 84 | assert not package.hasCurrent() 85 | assert not package.hasHighest() 86 | assert not package.hasCritical() 87 | 88 | packageData = [] 89 | packageData.append(["sensorLabel", 12, None, None]) 90 | packageData.append(["sensorLabel", None, None, None]) 91 | package = psutilTemperaturePackage("pkg", packageData) 92 | assert package.hasCurrent() 93 | assert not package.hasHighest() 94 | assert not package.hasCritical() 95 | 96 | packageData = [] 97 | packageData.append(["sensorLabel", None, None, None]) 98 | packageData.append(["sensorLabel", None, 35, None]) 99 | package = psutilTemperaturePackage("pkg", packageData) 100 | assert not package.hasCurrent() 101 | assert package.hasHighest() 102 | assert not package.hasCritical() 103 | 104 | packageData = [] 105 | packageData.append(["sensorLabel", None, None, False]) 106 | packageData.append(["sensorLabel", None, None, None]) 107 | package = psutilTemperaturePackage("pkg", packageData) 108 | assert not package.hasCurrent() 109 | assert not package.hasHighest() 110 | assert package.hasCritical() 111 | 112 | 113 | def testGetters(self): 114 | packageName = "pkg" 115 | 116 | packageData = [] 117 | packageData.append(["sensorLabel1", 80, 101, 11]) 118 | packageData.append(["sensorLabel2", 90, 99, 12]) 119 | package = psutilTemperaturePackage(packageName, packageData) 120 | assert package.getSensors()[0].getLabel() == "sensorLabel1" 121 | assert package.getSensors()[0].getCurrent() == 80 122 | assert package.getSensors()[0].getHighest() == 101 123 | assert package.getSensors()[0].getCritical() == 11 124 | assert package.getSensors()[1].getLabel() == "sensorLabel2" 125 | assert package.getSensors()[1].getCurrent() == 90 126 | assert package.getSensors()[1].getHighest() == 99 127 | assert package.getSensors()[1].getCritical() == 12 128 | assert package.getLabel() == packageName 129 | assert package.getCurrent() == 90 # highest of the two sensors 130 | assert package.getHighest() == 101 # highest of the two sensors 131 | assert package.getCritical() == 11 # lowest of the two sensors 132 | 133 | packageData = [] 134 | packageData.append(["sensorLabel", 29, 101, 22]) 135 | packageData.append(["sensorLabel", 10, 102, 10]) 136 | package = psutilTemperaturePackage(packageName, packageData) 137 | assert package.getCurrent() == 29 # highest of the two sensors 138 | assert package.getHighest() == 102 # highest of the two sensors 139 | assert package.getCritical() == 10 # lowest of the two sensors 140 | 141 | packageData = [] 142 | packageData.append(["sensorLabel", None, 101, None]) 143 | packageData.append(["sensorLabel", 10, None, 33]) 144 | package = psutilTemperaturePackage(packageName, packageData) 145 | assert package.getCurrent() == 10 # highest of the two sensors 146 | assert package.getHighest() == 101 # highest of the two sensors 147 | assert package.getCritical() == 33 # lowest of the two sensors --------------------------------------------------------------------------------