diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9bea433 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ + +.DS_Store diff --git a/README.md b/README.md index 2bc04dc..bafe690 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Package-Notification-HUB-AppDaemon
The app works with Appdaemon v4.x. ## Installation -Use [HACS](https://github.com/custom-components/hacs) or [download](https://github.com/jumping2000/notifier) the `notifier` directory from inside the `apps` directory here to your local `apps` directory. +Use [HACS](https://github.com/custom-components/hacs) or [download](https://github.com/jumping2000/notifier) the `notifier` directory from inside the `apps` directory here to your local `apps` directory. Remember to enable [Appdaemon in HACS](https://hacs.xyz/docs/categories/appdaemon_apps#enable-appdaemon-apps-in-hacs). ## Remember it! diff --git a/apps/notifier/alexa_manager.py b/apps/notifier/alexa_manager.py old mode 100644 new mode 100755 index 99796a2..60ca681 --- a/apps/notifier/alexa_manager.py +++ b/apps/notifier/alexa_manager.py @@ -1,93 +1,66 @@ -import hassapi as hass - -import re -import sys -import time +import json from queue import Queue +import re from threading import Thread +import time + +import hassapi as hass # type: ignore """ Class Alexa Manager handles sending text to speech messages to Alexa media players Following features are implemented: - - Speak text to choosen media_player + - Speak text to choosen media player - Full queue support to manage async multiple TTS commands - Full wait to tts to finish to be able to supply a callback method + - Mobile PUSH message + - Media content + - SSML, language, voice + - Alexa Actions: Actionable Notification """ +ALEXA_SERVICE = "alexa_media" +CUSTOM_COMPONENT_URL = "https://github.com/custom-components/alexa_media_player" NOTIFY = "notify/" - +SKILL_ID = "skill_id" +DEFAULT_VOL = "default_vol" + +# Parameters +TITLE = "title" +MESSAGE = "message" +AUDIO = "audio" +AUTO_VOLUMES = "auto_volumes" +EVENT_ID = "event_id" +LANGUAGE = "language" +MEDIA_CONTENT_ID = "media_content_id" +MEDIA_CONTENT_TYPE = "media_content_type" +MEDIA_PLAYER = "media_player" +METHOD = "method" +NOTIFIER = "notifier" +PITCH = "pitch" +PUSH = "push" +RATE = "rate" +SSML = "ssml" +SSML_VOL = "ssml_volume" +TYPE = "type" +VOICE = "voice" +VOLUME = "volume" +WAIT_TIME = "wait_time" +WHISPER = "whisper" +# MODE = "mode" +# PRIORITY = "priority" + +MOBILE_PUSH_TYPE = (PUSH, "dropin", "dropin_notification") SUB_VOICE = [ - # ("[.]{2,}", "."), - ("[\?\.\!,]+(?=[\?\.\!,])", ""), # Exclude duplicate - ("(\s+\.|\s+\.\s+|[\.])(?! )(?![^{]*})(?![^\d.]*\d)", ". "), + ("[\U00010000-\U0010ffff]", ""), # strip emoji + ("[\?\.\!,]+(?=[\?\.\!,])", ""), # strip duplicate ., + ("(\s+\.|\s+\.\s+|[\.])(?! )(?![^{<]*[}>])(?![^\d.]*\d)", ". "), ("&", " and "), # escape - # ("(? None: - # self.set_log_level("DEBUG") - self.alexa_service = self.args.get("alexa_service") - # self.alexa_switch_entity = self.args.get("alexa_switch") - self.alexa_select_media_player = self.args.get("alexa_select_media_player") - self.alexa_type = self.args.get("alexa_type") - self.alexa_method = self.args.get("alexa_method") - self.alexa_sensor_media_player = self.args.get("alexa_sensor_media_player") - self.alexa_voice = self.args.get("alexa_voice") - # self.alexa_language = self.args.get("alexa_language") + self.debug_sensor = self.args.get("debug_sensor") + self.component_installed = self.is_component_installed(ALEXA_SERVICE) + self.notify_services = self.list_notify_services(ALEXA_SERVICE) + self.service2player = self.alexa_services_to_players(self.notify_services) + self.volumes_saved = {} # dict media players volume saved + # Entities + self.binary_speak = self.args.get("binary_speak") + self.sensor_player = self.args.get("sensor_player") + self.sensor_volume = self.args.get("sensor_day_volume") + self.select_language = self.args.get("select_language") + self.select_alexa_language = self.args.get("select_alexa_language") + self.select_player = self.args.get("select_player") + self.select_type = self.args.get("select_type") + self.select_method = self.args.get("select_method") + self.select_voice = self.args.get("select_voice") + self.bool_smart_volume_set = self.args.get("bool_smart_volume_set") + self.bool_ssml = self.args.get("bool_ssml") self.prosody = self.args.get("prosody") - self.wait_time = self.args.get("wait_time") - self.cehck_alexa_service = self._check_alexa(self.alexa_service) + self.number_wait_time = self.args.get("number_wait_time") + self.text_actionable_notification = self.args.get("actionable_notification") self.queue = Queue(maxsize=0) self._when_tts_done_callback_queue = Queue() - t = Thread(target=self.worker) t.daemon = True t.start() + self.set_state(self.debug_sensor, state="on") - def speak(self, alexa): + def speak(self, alexa: dict, skill_id: str) -> None: """Speak the provided text through the media player.""" - if not self.cehck_alexa_service: - self.set_sensor( - "I can't find the Alexa Media component", "https://github.com/custom-components/alexa_media_player" - ) - return - self.lg(f"-------------------- ALEXA START DISPATCH --------------------") + self.lg(f"------ ALEXA START DISPATCH ------") self.lg(f"FROM DISPATCH: {type(alexa)} value {alexa}") - # remove keys with None value from a dict # TODO - alexa = {k: v for k, v in alexa.items() if v not in [None, "None", ""]} - self.lg(f"REMOVE [NONE] VALUE: {type(alexa)} value {alexa}") - default_restore_volume = float(self.get_state(self.args.get("default_restore_volume"))) / 100 - volume = float(alexa.get("volume", default_restore_volume)) - message = str(alexa.get("message", alexa.get("message_tts", ""))) - alexa_player = self.player_get(alexa.get("media_player", self.get_state(self.alexa_sensor_media_player))) - alexa_type = ( - str(alexa.get("type", self.get_state(self.alexa_type))).lower().replace("dropin", "dropin_notification") - ) - # Push notification - push = bool(self.check_bool(alexa.get("push"))) - if push or alexa_type in MOBILE_PUSH and message: + if not self.service2player: + self.set_debug_sensor("Alexa Services not found", CUSTOM_COMPONENT_URL) + return + + default_vol = float(self.get_state(self.sensor_volume, default=10)) / 100 + volume = float(alexa.get(VOLUME, default_vol)) + auto_volumes = self.check_bool(alexa.get(AUTO_VOLUMES, False)) + if volume == 0.0 and not auto_volumes: + self.log("ALEXA VOLUME MUTED.", level="WARNING") + return + + # Backwards compatible message_tts + message = str(alexa.get("message_tts", alexa.get(MESSAGE, ""))) + get_players = alexa.get(MEDIA_PLAYER, self.get_state(self.sensor_player)) + media_player = self.check_media_player(get_players) + get_type = alexa.get(TYPE, self.get_state(self.select_type, default="tts")) + data_type = str(get_type).lower().replace("dropin", "dropin_notification") + + alexa_lang = self.get_state(self.select_alexa_language, default="Master") + master_lang = self.get_state(self.select_language, default="it-IT") + final_language = alexa_lang if alexa_lang != "Master" else master_lang + language = str(alexa.get(LANGUAGE, final_language)) + + state_method = self.get_state(self.select_method, default="all") + state_voice = self.get_state(self.select_voice, default="Alexa") + state_wait_time = self.get_state(self.number_wait_time, default=3.0) + state_rate = self.get_state(self.prosody[RATE], default=100.0) + state_pitch = self.get_state(self.prosody[PITCH], default=0.0) + state_ssml_volume = self.get_state(self.prosody[VOLUME], default=0.0) + state_ssml = self.get_state(self.bool_ssml, default="off") + + # Actionable notification + if event_id := alexa.get(EVENT_ID, ""): + self.set_textvalue( + self.text_actionable_notification, + json.dumps({"text": "", "event": event_id}), + ) + + # Push notification - Only one device is needed + push = self.check_bool(alexa.get(PUSH, False)) + if (push or data_type in MOBILE_PUSH_TYPE) and message: message_push = self.remove_tags(self.replace_regular(message, SUB_TEXT)) + type_ = {TYPE: PUSH} if push else {TYPE: data_type} self.call_service( - NOTIFY + self.alexa_service, - data={"type": "push"} if push else {"type": alexa_type}, - target=alexa_player[0], # only one device - title=str(alexa.get("title", "")), + NOTIFY + ALEXA_SERVICE, + data=type_, + target=media_player[0], + title=str(alexa.get(TITLE, "")), message=message_push, ) - self.lg(f"PUSH: {push} - TYPE: {alexa_type} - MESSAGE: {message_push}") + # Media Content # TODO Restore volume?? - media_content_id = alexa.get("media_content_id") - media_content_type = alexa.get("media_content_type") - if media_content_id: - self.volume_get(alexa_player, default_restore_volume) - self.volume_set(alexa_player, volume) + if media_content_id := alexa.get(MEDIA_CONTENT_ID): + self.volume_get_save(media_player, volume, default_vol) + self.volume_set(media_player, volume) self.call_service( "media_player/play_media", - entity_id=alexa_player, + entity_id=media_player, media_content_id=media_content_id, - media_content_type=media_content_type, - # extra = {"timer": 10} ##?? + media_content_type=alexa.get(MEDIA_CONTENT_TYPE), + extra={"timer": alexa.get("extra", 0)}, ) - self.lg(f"Content id: {media_content_id} - Content type: {media_content_type}") - # Queues the message to be handled async, use when_tts_done_do method to supply callback when tts is done - elif alexa_type not in MOBILE_PUSH and message: + # Queues the message to be handled async, use when_tts_done_do method + # to supply callback when tts is done + elif data_type not in MOBILE_PUSH_TYPE and message: self.queue.put( { - "text": message, - "volume": volume, - "alexa_type": alexa_type, - "alexa_player": alexa_player, # media_player - "default_restore_volume": default_restore_volume, - "alexa_notifier": str(alexa.get("notifier", self.alexa_service)), - "wait_time": float(alexa.get("wait_time", self.get_state(self.wait_time))), - "language": alexa.get("language"), # self.get_state(self.alexa_language)), - "alexa_method": str(alexa.get("method", self.get_state(self.alexa_method)).lower()), - "alexa_voice": str(alexa.get("voice", self.get_state(self.alexa_voice))).capitalize(), - "alexa_audio": alexa.get("audio", None), - "rate": float(alexa.get("rate", self.get_state(self.prosody["rate"]))), - "pitch": float(alexa.get("pitch", self.get_state(self.prosody["pitch"]))), - "ssml_volume": float(alexa.get("ssml_volume", self.get_state(self.prosody["volume"]))), - "whisper": bool(self.check_bool(alexa.get("whisper", False))), - "ssml_switch": bool(self.check_bool(alexa.get("ssml", self.get_state(self.args["ssml_switch"])))), + SKILL_ID: skill_id, + DEFAULT_VOL: default_vol, + VOLUME: volume, + AUTO_VOLUMES: auto_volumes, + MESSAGE: message, + MEDIA_PLAYER: media_player, + TYPE: data_type, + LANGUAGE: language, + EVENT_ID: event_id, + AUDIO: alexa.get(AUDIO, None), + NOTIFIER: str(alexa.get(NOTIFIER, ALEXA_SERVICE)), + METHOD: str(alexa.get(METHOD, state_method).lower()), + VOICE: str(alexa.get(VOICE, state_voice)).capitalize(), + WAIT_TIME: float(alexa.get(WAIT_TIME, state_wait_time)), + RATE: float(alexa.get(RATE, state_rate)), + PITCH: float(alexa.get(PITCH, state_pitch)), + SSML_VOL: float(alexa.get(SSML_VOL, state_ssml_volume)), + WHISPER: self.check_bool(alexa.get(WHISPER, False)), + SSML: self.check_bool(alexa.get(SSML, state_ssml)), } ) - self.lg(f"-------------------- ALEXA END DISPATCH --------------------") - def lg(self, message): - self.log(message, level="DEBUG", ascii_encode=False) + def lg(self, message: str) -> None: + self.log(str(message), level="DEBUG", ascii_encode=False) - def check_bool(self, value): + def check_bool(self, value) -> bool: + """Check if user input is a boolean.""" return str(value).lower() in ["true", "on", "yes", "1"] - def inbetween(self, minv, value, maxv): + def inbetween(self, minv: float, value: float, maxv: float) -> float: + """Check input number between minimum and maximum values range.""" return sorted([minv, value, maxv])[1] - def speak_tag(self, value): # TODO tags - return value if "" in value or not "{value}" + def replace_regular(self, text: str, substitutions: list) -> str: + for old, new in substitutions: + regex = re.compile(old) + text = re.sub(regex, new, str(text).strip()) + return text + + def str2list(self, string: str) -> list: + """Convert string to list.""" + regex = re.compile(r"\s*,\s*") + return self.split_device_list(re.sub(regex, ",", string)) + + def has_numbers(self, string: str): + """Check if a string contains a number.""" + numbers = re.compile("\d{2}:\d{2}|\d{4,}|\d{3,}\.\d") + return numbers.search(string) - def effect_tag(self, value): + def remove_tags(self, text: str) -> str: + """Remove all tags from a string.""" + regex = re.compile("<.*?>") + return re.sub(regex, "", str(text).strip()) + + def speak_tags(self, value: str) -> str: + """This will add a tag when using tts method""" + return f"{value}" + + def effect_tags(self, value: str) -> str: + """This will add a tag and applies a whispering effect.""" return f"{value}" - def prosody_tag(self, value, rate, pitch, volume): + def prosody_tags(self, value: str, rate: float, pitch: float, volume: float) -> str: + """This will add a tag for volume, pitch, and rate""" if rate != 100.0 or pitch != 0.0 or volume != 0.0: - rate = f"{self.inbetween(20, rate, 200)}%" # min 20% max 200% - pitch = f"{self.inbetween(-33.3, pitch, 50):+g}%" # min -33.3 max +50 - volume = f"{self.inbetween(-50, volume, 4.08):+g}dB" # max +4.08dB - return f" {value} " + r = f"{self.inbetween(20, rate, 200)}%" + p = f"{self.inbetween(-33.3, pitch, 50):+g}%" + v = f"{self.inbetween(-50, volume, 4.08):+g}dB" + return f" {value} " return value - def audio_tag(self, value: None): + def audio_tags(self, value: None) -> str: + """This will add the