diff --git a/IoTuring/Configurator/MenuPreset.py b/IoTuring/Configurator/MenuPreset.py index f9bc5122c..590942e20 100644 --- a/IoTuring/Configurator/MenuPreset.py +++ b/IoTuring/Configurator/MenuPreset.py @@ -3,9 +3,21 @@ class MenuPreset(): def __init__(self) -> None: self.preset = [] - def AddEntry(self, name, key, default=None, mandatory=False): + def AddEntry(self, name, key, default=None, mandatory=False, display_if_value_for_following_key_provided=None): + """ + Add an entry to the preset with: + - key: the key to use in the dict + - name: the name to display to the user + - default: the default value to use if the user doesn't provide one (works only if mandatory=False) + - mandatory: if the user must provide a value for this entry + - display_if_value_for_following_key_provided: key of an entry (that must preceed this) that will enable this one, if the user has provided a value for that. + * If it's None, the entry will always be displayed + * If it has a key, the entry won't be displayed if menu[provided_key] has value. + * In case this won't be displayed, a default value will be used if provided; otherwise won't set this key in the dict) + ! Caution: if the entry is not displayed, the mandatory property will be ignored ! + """ self.preset.append( - {"name": name, "key": key, "default": default, "mandatory": mandatory, "value": None}) + {"name": name, "key": key, "default": default, "mandatory": mandatory, "dependsOn": display_if_value_for_following_key_provided, "value": None}) def ListEntries(self): return self.preset @@ -14,35 +26,52 @@ def Question(self, id): try: question = "" if id < len(self.preset): - question = "Add value for \""+self.preset[id]["name"]+"\"" - if self.preset[id]['mandatory']: - question = question + " {!}" - if self.preset[id]["default"] is not None: - question = question + \ - " [" + str(self.preset[id]["default"])+"]" + # if the display of this does not depend on a previous entry, or if the previous entry (this depends on) has a value: ask for a value + if self.preset[id]["dependsOn"] is None or self.RetrievePresetAnswerByKey(self.preset[id]["dependsOn"]): + question = "Add value for \""+self.preset[id]["name"]+"\"" + if self.preset[id]['mandatory']: + question = question + " {!}" + if self.preset[id]["default"] is not None: + question = question + \ + " [" + str(self.preset[id]["default"])+"]" - question = question + ": " + question = question + ": " - value = input(question) + value = input(question) - # Mandatory loop - while value == "" and self.preset[id]["mandatory"]: - value = input("You must provide a value for this key: ") + # Mandatory loop + while value == "" and self.preset[id]["mandatory"]: + value = input("You must provide a value for this key: ") - if value == "": - if self.preset[id]["default"] is not None: + if value == "": + if self.preset[id]["default"] is not None: + # Set in the preset + self.preset[id]['value'] = self.preset[id]["default"] + return self.preset[id]["default"] # Also return it + else: + self.preset[id]['value'] = None # Set in the preset + return None # Also return it + else: + self.preset[id]['value'] = value # Set in the preset + return value # Also return it + else: + # If the entry is not displayed, set the default value if provided + if self.preset[id]["default"]: # if I have a default # Set in the preset self.preset[id]['value'] = self.preset[id]["default"] return self.preset[id]["default"] # Also return it else: - self.preset[id]['value'] = None # Set in the preset - return None # Also return it - else: - self.preset[id]['value'] = value # Set in the preset - return value # Also return it + return None # don't set anything + except Exception as e: print("Error while making the question:", e) + def RetrievePresetAnswerByKey(self, key): + for entry in self.preset: + if entry['key'] == key: + return entry['value'] + return None + def GetDict(self) -> dict: """ Get a dict with keys and responses""" result = {} diff --git a/IoTuring/Entity/Deployments/Notify/Notify.py b/IoTuring/Entity/Deployments/Notify/Notify.py index b3c161d78..b5980cbba 100644 --- a/IoTuring/Entity/Deployments/Notify/Notify.py +++ b/IoTuring/Entity/Deployments/Notify/Notify.py @@ -1,49 +1,73 @@ from IoTuring.Entity.Entity import Entity from IoTuring.Entity.EntityData import EntityCommand from IoTuring.MyApp.App import App - from IoTuring.Configurator.MenuPreset import MenuPreset +from IoTuring.Entity import consts -import os +import json -from IoTuring.Entity import consts +import os supports_win = True try: - import win10toast + import tinyWinToast.tinyWinToast as twt except: supports_win = False +commands = { + consts.OS_FIXED_VALUE_LINUX: 'notify-send "{}" "{}"', + consts.OS_FIXED_VALUE_MACOS: 'osascript -e \'display notification "{}" with title "{}"\'' +} -supports_unix = True -try: - import notify2 -except: - supports_unix = False KEY = 'notify' # To send notification data through message payload use these two PAYLOAD_KEY_TITLE = "title" PAYLOAD_KEY_MESSAGE = "message" +PAYLOAD_SEPARATOR = "|" CONFIG_KEY_TITLE = "title" CONFIG_KEY_MESSAGE = "message" -DEFAULT_DURATION = 10 # Seconds +ICON_FILENAME = "icon.png" +MODE_DATA_VIA_CONFIG = "data_via_config" +MODE_DATA_VIA_PAYLOAD = "data_via_payload" class Notify(Entity): NAME = "Notify" DEPENDENCIES = ["Os"] ALLOW_MULTI_INSTANCE = True + # Data is set from configurations if configurations contain both title and message + # Otherwise, data is set from payload (even if only one of title or message is set) def Initialize(self): + + # Check if both config is defined or both is empty: + if not bool(self.GetConfigurations()[CONFIG_KEY_TITLE]) == bool(self.GetConfigurations()[CONFIG_KEY_MESSAGE]): + raise Exception("Configuration error: Both title and message should be defined, or both should be empty!") + try: self.config_title = self.GetConfigurations()[CONFIG_KEY_TITLE] self.config_message = self.GetConfigurations()[CONFIG_KEY_MESSAGE] + self.data_mode = MODE_DATA_VIA_CONFIG except Exception as e: - raise Exception("Configuration error: " + str(e)) + self.data_mode = MODE_DATA_VIA_PAYLOAD + + if self.data_mode == MODE_DATA_VIA_CONFIG: + if not self.config_title or not self.config_message: + self.data_mode = MODE_DATA_VIA_PAYLOAD + + if self.data_mode == MODE_DATA_VIA_CONFIG: + self.Log(self.LOG_INFO, "Using data from configuration") + else: + self.Log(self.LOG_INFO, "Using data from payload") + + # In addition, if data is from payload, we add this info to entity name + # ! Changing the name we recognize the difference in warehouses only using the name + # e.g HomeAssistant warehouse can use the regex syntax with NotifyPaylaod to identify that the component needs the text message + self.NAME = self.NAME + ("Payload" if self.data_mode == MODE_DATA_VIA_PAYLOAD else "") self.RegisterEntityCommand(EntityCommand(self, KEY, self.Callback)) @@ -53,48 +77,59 @@ def PostInitialize(self): if self.os == consts.OS_FIXED_VALUE_WINDOWS: if not supports_win: raise Exception( - 'Notify not available, have you installed \'win10toast\' on pip ?') - elif self.os == consts.OS_FIXED_VALUE_LINUX: - if supports_unix: - # Init notify2 - notify2.init(App.getName()) - else: + 'Notify not available, have you installed \'tinyWinToast\' on pip ?') + + elif self.os == consts.OS_FIXED_VALUE_LINUX \ + or self.os == consts.OS_FIXED_VALUE_MACOS: + # Use 'command -v' to test if comman exists: + if os.system(f'command -v {commands[self.os].split(" ")[0]}') != 0: raise Exception( - 'Notify not available, have you installed \'notify2\' on pip ?') + f'Command not found {commands[self.os].split(" ")[0]}!' + ) + + else: + raise Exception( + 'Notify not available for this platorm!') + def Callback(self, message): - # Priority for configuration content and title. If not set there, will try to find them in the payload - if self.config_title and self.config_message: + if self.data_mode == MODE_DATA_VIA_PAYLOAD: + # Get data from payload: + payloadString = message.payload.decode('utf-8') + try: + payloadMessage = json.loads(payloadString) + self.notification_title = payloadMessage[PAYLOAD_KEY_TITLE] + self.notification_message = payloadMessage[PAYLOAD_KEY_MESSAGE] + except json.JSONDecodeError: + payloadMessage = payloadString.split(PAYLOAD_SEPARATOR) + self.notification_title = payloadMessage[0] + self.notification_message = PAYLOAD_SEPARATOR.join( + payloadMessage[1:]) + + else: # self.data_mode = MODE_DATA_VIA_CONFIG self.notification_title = self.config_title self.notification_message = self.config_message - else: - # Convert the payload to a dict: - messageDict = '' - try: - messageDict = eval(message.payload.decode('utf-8')) - self.notification_title = messageDict[PAYLOAD_KEY_TITLE] - self.notification_message = messageDict[PAYLOAD_KEY_MESSAGE] - except: - raise Exception( - 'Incorrect payload and no title and message set in configuration!' - ) - # Check only the os (if it's that os, it's supported because if it wasn't supported, # an exception would be thrown in post-inits) if self.os == consts.OS_FIXED_VALUE_WINDOWS: - toaster = win10toast.ToastNotifier() - toaster.show_toast( - self.notification_title, self.notification_message, duration=DEFAULT_DURATION, threaded=False) + toast_icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ICON_FILENAME) + twt.getToast( + title=self.notification_title, + message=self.notification_message, + icon=toast_icon_path, + appId=App.getName(), + isMute=False).show() + elif self.os == consts.OS_FIXED_VALUE_LINUX: - notification = notify2.Notification( - self.notification_title, self.notification_message) - notification.show() + os.system(commands[self.os] + .format(self.notification_title,self.notification_message)) + elif self.os == consts.OS_FIXED_VALUE_MACOS: - command = 'osascript -e \'display notification "{}" with title "{}"\''.format( - self.notification_message, self.notification_title,) - os.system(command) + os.system(commands[self.os] + .format(self.notification_message,self.notification_title)) + else: self.Log(self.LOG_WARNING, "No notify command available for this operating system (" + str(self.os) + ")... Aborting") @@ -102,8 +137,7 @@ def Callback(self, message): @classmethod def ConfigurationPreset(self): preset = MenuPreset() - preset.AddEntry( - "Notification title (Leave empty if you want to define it in the payload)", CONFIG_KEY_TITLE) - preset.AddEntry( - "Notification message (Leave empty if you want to define it in the payload)", CONFIG_KEY_MESSAGE) + preset.AddEntry("Notification title - leave empty to send this data via remote message", CONFIG_KEY_TITLE, mandatory=False) + # ask for the message only if the title is provided, otherwise don't ask (use display_if_value_for_following_key_provided) + preset.AddEntry("Notification message", CONFIG_KEY_MESSAGE, display_if_value_for_following_key_provided=CONFIG_KEY_TITLE, mandatory=True) return preset diff --git a/IoTuring/Entity/Deployments/Notify/icon.png b/IoTuring/Entity/Deployments/Notify/icon.png new file mode 100644 index 000000000..ba31199c5 Binary files /dev/null and b/IoTuring/Entity/Deployments/Notify/icon.png differ diff --git a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py index 63357730f..821c38577 100644 --- a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py +++ b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py @@ -189,11 +189,17 @@ def AddEntityDataCustomConfigurations(self, entityDataName, payload): """ Add custom info to the entity data, reading it from external file and accessing the information using the entity data name """ with open(os.path.join(os.path.dirname(inspect.getfile(HomeAssistantWarehouse)), EXTERNAL_ENTITY_DATA_CONFIGURATION_FILE_FILENAME)) as yaml_data: data = yaml.safe_load(yaml_data.read()) - for entityData, entityDataConfiguration in data.items(): - # entityData may be the correct name, or a regex expression that should return something applied to the real name - if re.search(entityData, entityDataName): - # merge payload and additional configurations - return {**payload, **entityDataConfiguration} + + # Try exact match: + try: + return {**payload, **data[entityDataName]} + except KeyError: + # No exact match, try regex: + for entityData, entityDataConfiguration in data.items(): + # entityData may be the correct name, or a regex expression that should return something applied to the real name + if re.search(entityData, entityDataName): + # merge payload and additional configurations + return {**payload, **entityDataConfiguration} return payload # if nothing found def MakeEntityDataTopic(self, entityData): diff --git a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml index c94480501..c60d10789 100644 --- a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml +++ b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml @@ -21,6 +21,9 @@ Username - username: icon: "mdi:account-supervisor-circle" Lock - lock: icon: "mdi:lock" +NotifyPayload: + icon: "mdi:forum" + custom_type: text Notify: icon: "mdi:forum" Hostname: diff --git a/README.md b/README.md index 13c5db7dd..201088f13 100644 --- a/README.md +++ b/README.md @@ -27,20 +27,13 @@ Some platforms may need other software for some entities. #### Install all requirements on ArchLinux ```shell -pacman -Syu base-devel git python python-pip +pacman -Syu git python python-pip ``` -#### Install and update all requirements on Debain +#### Install and update all requirements on Debain or Ubuntu ```shell -apt install git python3 python3-pip libdbus-glib-1-dev -pip install --upgrade pip -``` - -#### Install and update all requirements on Ubuntu - -``` -apt install git python3 python3-pip libdbus-glib-1-dev meson patchelf +apt install git python3 python3-pip -y pip install --upgrade pip ``` @@ -54,7 +47,7 @@ pip install --upgrade pip On Linux: ```shell -pip install IoTuring[linux] +pip install IoTuring ``` On Windows: @@ -101,29 +94,32 @@ The device will also have some properties like connectivity and battery status. You can see how your device will appear under the Devices section in Home Assistant in the following GIF (wait until it's loaded): -![device](https://user-images.githubusercontent.com/12238652/187725698-dafceb9c-c746-4a84-9b2c-caf5ea46a802.gif) +![device](docs/images/homeassistant-demo.gif) All sensors and switches will be available to be added to your dashboard in your favourite cards ! ### Available entities -- ActiveWindow: shares the name of the window you're working on -- AppInfo: shares app informations like the running version -- Battery: shares the battery level and charging status -- BootTime: shares the machine boot time -- Cpu: shares useful information about cpu usage (times, frequencies, percentages) -- DesktopEnvironment: shares the running desktop environment (useful only for Linux) -- Disk: shares disk usage data -- Hostname: shares the machine hostname -- Lock: permits a remote lock command to lock the machine -- Monitor: permits remote monitors control commands -- Notify: permits remote notify show on your machine -- Os: shares the operating system of your machine -- Power*: permits remote poweroff, reboot and sleep commands -- Ram: shares useful information about ram usage -- Time: shares the machine local time -- Uptime: shares the time since the machine is on -- Username: shares the name of the user who is working on the machine +| Name | Description | Supported platforms | +| ------------------ | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| ActiveWindow | shares the name of the window you're working on | ![win](docs/images/win.png) ![mac](docs/images/mac.png) ![linux](docs/images/linux.png) | +| AppInfo | shares app informations like the running version | ![win](docs/images/win.png) ![mac](docs/images/mac.png) ![linux](docs/images/linux.png) | +| Battery | shares the battery level and charging status | ![win](docs/images/win.png) ![mac](docs/images/mac.png) ![linux](docs/images/linux.png) | +| BootTime | shares the machine boot time | ![win](docs/images/win.png) ![mac](docs/images/mac.png) ![linux](docs/images/linux.png) | +| Cpu | shares useful information about cpu usage (times, frequencies, percentages) | ![win](docs/images/win.png) ![mac](docs/images/mac.png) ![linux](docs/images/linux.png) | +| DesktopEnvironment | shares the running desktop environment (useful only for Linux) | ![win](docs/images/win.png) ![mac](docs/images/mac.png) ![linux](docs/images/linux.png) | +| Disk | shares disk usage data | ![win](docs/images/win.png) ![mac](docs/images/mac.png) ![linux](docs/images/linux.png) | +| Hostname | shares the machine hostname | ![win](docs/images/win.png) ![mac](docs/images/mac.png) ![linux](docs/images/linux.png) | +| Lock | command for locking the machine | ![win](docs/images/win.png) ![mac](docs/images/mac.png) ![linux](docs/images/linux.png) | +| Monitor | command for switching monitors on/off | ![win](docs/images/win.png) ![linux](docs/images/linux.png) | +| Notify | displays a notification | ![win](docs/images/win.png) ![mac](docs/images/mac.png) ![linux](docs/images/linux.png) | +| Os | shares the operating system of your machine | ![win](docs/images/win.png) ![mac](docs/images/mac.png) ![linux](docs/images/linux.png) | +| Power* | commands for poweroff, reboot and sleep | ![win](docs/images/win.png) ![mac](docs/images/mac.png) ![linux](docs/images/linux.png) | +| Ram | shares useful information about ram usage | ![win](docs/images/win.png) ![mac](docs/images/mac.png) ![linux](docs/images/linux.png) | +| Time | shares the machine local time | ![win](docs/images/win.png) ![mac](docs/images/mac.png) ![linux](docs/images/linux.png) | +| Temperature | shares temperature sensor data | ![mac](docs/images/mac.png) ![linux](docs/images/linux.png) | +| Uptime | shares the time since the machine is on | ![win](docs/images/win.png) ![mac](docs/images/mac.png) ![linux](docs/images/linux.png) | +| Username | shares the name of the user who is working on the machine | ![win](docs/images/win.png) ![mac](docs/images/mac.png) ![linux](docs/images/linux.png) | \* To use the features from Power entity on Linux and macOS you need to give permissions to your user to shutdown and reboot without sudo password. You can easily do that by adding the following line at the end of the "/etc/sudoers" file (you can use the following command: sudo nano /etc/sudoers): @@ -164,3 +160,9 @@ The project uses [calendar versioning](https://calver.org/): - `YYYY`: Full year: 2022, 2023 ... - `0M`: Zero-padded month: 01, 02 ... 11, 12 - `n`: Build number in the month: 1, 2 ... + +## Acknowledgement + +Icons in this readme are from [Material Design Icons](https://materialdesignicons.com/), License: [Pictogrammers Free License](https://github.com/Templarian/MaterialDesign-SVG/blob/master/LICENSE) + +Notification icon is from [Home Assistant](https://github.com/home-assistant/assets/): License: [CC BY-SA 4.0](https://github.com/home-assistant/assets/blob/master/LICENSE.md) diff --git a/docs/images/homeassistant-demo.gif b/docs/images/homeassistant-demo.gif new file mode 100644 index 000000000..257e4fb7a Binary files /dev/null and b/docs/images/homeassistant-demo.gif differ diff --git a/docs/images/linux.png b/docs/images/linux.png new file mode 100644 index 000000000..2db98de71 Binary files /dev/null and b/docs/images/linux.png differ diff --git a/docs/images/mac.png b/docs/images/mac.png new file mode 100644 index 000000000..9eab50437 Binary files /dev/null and b/docs/images/mac.png differ diff --git a/docs/images/win.png b/docs/images/win.png new file mode 100644 index 000000000..9333d5305 Binary files /dev/null and b/docs/images/win.png differ diff --git a/pyproject.toml b/pyproject.toml index 091e8b07d..1e7a29531 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,21 +22,17 @@ classifiers = [ dependencies = [ "paho-mqtt", "psutil", - "notify2", "PyYAML", "importlib_metadata" ] [project.optional-dependencies] -linux = [ - "dbus-python" -] macos = [ "PyObjC", "ioturing_applesmc @ git+https://github.com/richibrics/IoTuring_applesmc.git" ] win=[ - "win10toast" + "tinyWinToast" ] [project.urls] @@ -52,4 +48,4 @@ IoTuring = "IoTuring:loop" requires = ["setuptools", "wheel"] [tool.setuptools.package-data] -"*" = ["entities.yaml"] +"*" = ["entities.yaml", "icon.png"]