Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Separate Notify from NotifyPayload, support for mqtt text type, other Notification fixes #28

Merged
merged 13 commits into from
Jan 26, 2023
Merged
69 changes: 49 additions & 20 deletions IoTuring/Configurator/MenuPreset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {}
Expand Down
124 changes: 79 additions & 45 deletions IoTuring/Entity/Deployments/Notify/Notify.py
Original file line number Diff line number Diff line change
@@ -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
infeeeee marked this conversation as resolved.
Show resolved Hide resolved

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))

Expand All @@ -53,57 +77,67 @@ 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")

@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
Binary file added IoTuring/Entity/Deployments/Notify/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading