From 2aafea5cf6837a54adf7ca96a31f1d27811c32e2 Mon Sep 17 00:00:00 2001 From: Rabosa616 Date: Fri, 4 Sep 2026 17:50:11 +0200 Subject: [PATCH 1/7] Fix notify.send_message target passed inside service_data HA 2024+ notify entities (mobile companion app) require target to be passed as a separate async_call parameter, not inside service_data. Passing target in service_data raises: extra keys not allowed @ data['target'] For notify.send_message calls, extract the target entities from the payload and pass them via the target= kwarg of async_call instead. --- custom_components/universal_notifier/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/custom_components/universal_notifier/__init__.py b/custom_components/universal_notifier/__init__.py index 42dfeb3..8679e8f 100644 --- a/custom_components/universal_notifier/__init__.py +++ b/custom_components/universal_notifier/__init__.py @@ -532,7 +532,15 @@ async def async_send_notification(call: ServiceCall): _LOGGER.debug(f"UniNotifier: Sezione J, Messaggio per {target_alias} accodato.") else: _LOGGER.debug(f"UniNotifier: Sezione J, Final payload {service_payload} - Service data {srv_domain}/{srv_name}") - tasks.append(hass.services.async_call(srv_domain, srv_name, service_payload)) + if srv_domain == "notify" and srv_name == "send_message": + # HA 2024+: notify.send_message expects target as a separate + # async_call parameter, not inside service_data — passing it + # in data raises "extra keys not allowed @ data['target']" + target_entities = service_payload.pop(CONF_TARGET, None) + call_target = {"entity_id": target_entities} if target_entities else None + tasks.append(hass.services.async_call(srv_domain, srv_name, service_payload, target=call_target)) + else: + tasks.append(hass.services.async_call(srv_domain, srv_name, service_payload)) if tasks: await asyncio.gather(*tasks) From e0ccfe7e38cdf5449186d90ffec5c49b1be50e3c Mon Sep 17 00:00:00 2001 From: Rabosa616 Date: Fri, 4 Sep 2026 18:14:05 +0200 Subject: [PATCH 2/7] Also strip 'data' key from notify.send_message payload notify.send_message does not accept 'data' in service_data either, causing 'extra keys not allowed @ data[data]'. --- custom_components/universal_notifier/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/universal_notifier/__init__.py b/custom_components/universal_notifier/__init__.py index 8679e8f..7932973 100644 --- a/custom_components/universal_notifier/__init__.py +++ b/custom_components/universal_notifier/__init__.py @@ -533,10 +533,10 @@ async def async_send_notification(call: ServiceCall): else: _LOGGER.debug(f"UniNotifier: Sezione J, Final payload {service_payload} - Service data {srv_domain}/{srv_name}") if srv_domain == "notify" and srv_name == "send_message": - # HA 2024+: notify.send_message expects target as a separate - # async_call parameter, not inside service_data — passing it - # in data raises "extra keys not allowed @ data['target']" + # notify.send_message requires target as a separate async_call + # parameter and does not accept 'target' or 'data' in service_data target_entities = service_payload.pop(CONF_TARGET, None) + service_payload.pop("data", None) call_target = {"entity_id": target_entities} if target_entities else None tasks.append(hass.services.async_call(srv_domain, srv_name, service_payload, target=call_target)) else: From fa0b4433270abfc2b36e64a26ded23c1347f6753 Mon Sep 17 00:00:00 2001 From: Rabosa616 Date: Fri, 4 Sep 2026 18:39:26 +0200 Subject: [PATCH 3/7] Strip HTML and skip HA prefix/greeting for notify.send_message channels companion app renders plain text only; no prefix [HA - time] or greeting needed for mobile push notifications. --- custom_components/universal_notifier/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/custom_components/universal_notifier/__init__.py b/custom_components/universal_notifier/__init__.py index 7932973..c291cfc 100644 --- a/custom_components/universal_notifier/__init__.py +++ b/custom_components/universal_notifier/__init__.py @@ -358,6 +358,11 @@ async def async_send_notification(call: ServiceCall): final_msg = full_spoken_text final_title = None text_content_for_duration = final_msg + elif srv_domain == "notify" and srv_name == "send_message": + # Plain-text channel (companion app): strip HTML, no HA prefix, no greeting + final_msg = re.sub(r'<[^>]+>', '', str(target_raw_message)).strip() + if final_title: + final_title = re.sub(r'<[^>]+>', '', str(final_title)).strip() else: clean_name = sanitize_text_visual(raw_name, parse_mode) clean_time = sanitize_text_visual(raw_time_str, parse_mode) From 081c9df6e7aa04df520e01516dad64bff17c69df Mon Sep 17 00:00:00 2001 From: Rabosa616 Date: Fri, 4 Sep 2026 18:42:37 +0200 Subject: [PATCH 4/7] Detect iOS vs Android via device registry for notify.send_message iOS (Apple manufacturer): plain text, no HTML, no HA prefix/greeting. Android: standard HTML formatting with prefix and greeting. --- .../universal_notifier/__init__.py | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/custom_components/universal_notifier/__init__.py b/custom_components/universal_notifier/__init__.py index c291cfc..41c1532 100644 --- a/custom_components/universal_notifier/__init__.py +++ b/custom_components/universal_notifier/__init__.py @@ -12,6 +12,7 @@ STATE_PLAYING) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import device_registry as dr from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -359,10 +360,46 @@ async def async_send_notification(call: ServiceCall): final_title = None text_content_for_duration = final_msg elif srv_domain == "notify" and srv_name == "send_message": - # Plain-text channel (companion app): strip HTML, no HA prefix, no greeting - final_msg = re.sub(r'<[^>]+>', '', str(target_raw_message)).strip() - if final_title: - final_title = re.sub(r'<[^>]+>', '', str(final_title)).strip() + # Companion app: detect iOS (Apple) vs Android via device registry. + # iOS does not render HTML → plain text, no HA prefix, no greeting. + # Android renders HTML → fall through to standard formatting. + _ent_reg = er.async_get(hass) + _dev_reg = dr.async_get(hass) + _is_apple = False + for _eid in (dynamic_entities or []): + _ent = _ent_reg.async_get(_eid) + if _ent and _ent.device_id: + _dev = _dev_reg.async_get(_ent.device_id) + if _dev and (_dev.manufacturer or "").lower() == "apple": + _is_apple = True + break + if _is_apple: + final_msg = re.sub(r'<[^>]+>', '', str(target_raw_message)).strip() + if final_title: + final_title = re.sub(r'<[^>]+>', '', str(final_title)).strip() + else: + # Android: standard formatting with HTML prefix/greeting + clean_name = sanitize_text_visual(raw_name, parse_mode) + clean_time = sanitize_text_visual(raw_time_str, parse_mode) + clean_msg = sanitize_text_visual(str(target_raw_message), parse_mode) + clean_greet = sanitize_text_visual(current_greeting, parse_mode) + clean_orig_title = sanitize_text_visual(final_title, parse_mode) if final_title else None + if use_bold_prefix: + clean_name = apply_formatting(clean_name, parse_mode, "bold") + clean_time = apply_formatting(clean_time, parse_mode, "bold") + clean_orig_title = apply_formatting(clean_orig_title, parse_mode, "bold") + prefix_parts = [] + if clean_name and not skip_assistant_name: + prefix_parts.append(clean_name) + if clean_time: + prefix_parts.append(clean_time) + clean_prefix = f"[{' - '.join(prefix_parts)}]" if prefix_parts else "" + greeting_part = f"{clean_greet}. " if clean_greet else "" + if clean_orig_title: + final_title = f"{clean_prefix} {clean_orig_title}" if clean_prefix else clean_orig_title + final_msg = f"{greeting_part}{clean_msg}" + else: + final_msg = f"{clean_prefix} {greeting_part}{clean_msg}" if clean_prefix else f"{greeting_part}{clean_msg}" else: clean_name = sanitize_text_visual(raw_name, parse_mode) clean_time = sanitize_text_visual(raw_time_str, parse_mode) From 7825c3362f08379ecb80bbdf47d742805fb4a549 Mon Sep 17 00:00:00 2001 From: Rabosa616 Date: Fri, 4 Sep 2026 18:54:17 +0200 Subject: [PATCH 5/7] Refactor: move iOS detection and HTML strip to utils.py - strip_html(): strips HTML tags returning plain text - is_apple_device(): checks device registry for Apple manufacturer __init__.py now uses these helpers instead of inline logic. --- .../universal_notifier/__init__.py | 17 +- custom_components/universal_notifier/utils.py | 422 +++++++++--------- 2 files changed, 224 insertions(+), 215 deletions(-) diff --git a/custom_components/universal_notifier/__init__.py b/custom_components/universal_notifier/__init__.py index 41c1532..3720772 100644 --- a/custom_components/universal_notifier/__init__.py +++ b/custom_components/universal_notifier/__init__.py @@ -12,7 +12,6 @@ STATE_PLAYING) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv -from homeassistant.helpers import device_registry as dr from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -363,20 +362,10 @@ async def async_send_notification(call: ServiceCall): # Companion app: detect iOS (Apple) vs Android via device registry. # iOS does not render HTML → plain text, no HA prefix, no greeting. # Android renders HTML → fall through to standard formatting. - _ent_reg = er.async_get(hass) - _dev_reg = dr.async_get(hass) - _is_apple = False - for _eid in (dynamic_entities or []): - _ent = _ent_reg.async_get(_eid) - if _ent and _ent.device_id: - _dev = _dev_reg.async_get(_ent.device_id) - if _dev and (_dev.manufacturer or "").lower() == "apple": - _is_apple = True - break - if _is_apple: - final_msg = re.sub(r'<[^>]+>', '', str(target_raw_message)).strip() + if is_apple_device(hass, dynamic_entities): + final_msg = strip_html(str(target_raw_message)) if final_title: - final_title = re.sub(r'<[^>]+>', '', str(final_title)).strip() + final_title = strip_html(str(final_title)) else: # Android: standard formatting with HTML prefix/greeting clean_name = sanitize_text_visual(raw_name, parse_mode) diff --git a/custom_components/universal_notifier/utils.py b/custom_components/universal_notifier/utils.py index 1bff5a6..3542215 100644 --- a/custom_components/universal_notifier/utils.py +++ b/custom_components/universal_notifier/utils.py @@ -1,202 +1,222 @@ -# /config/custom_components/universal_notifier/utils.py -import re - -from homeassistant.util import dt as dt_util - -# ============================================================================== -# HELPER FUNCTIONS -# ============================================================================== - -DEFAULT_TIME_SLOTS = { - "weekday": { - "morning": {"start": "07:00", "volume": 0.35}, - "afternoon": {"start": "12:00", "volume": 0.40}, - "evening": {"start": "19:00", "volume": 0.30}, - "night": {"start": "22:00", "volume": 0.10}, - }, - "weekend": { - "morning": {"start": "07:00", "volume": 0.35}, - "afternoon": {"start": "12:00", "volume": 0.40}, - "evening": {"start": "19:00", "volume": 0.30}, - "night": {"start": "22:00", "volume": 0.10}, - }, -} - -def estimate_tts_duration(text: str, buffer: float) -> float: - """Stima la durata del messaggio in secondi basandosi sulle parole.""" - if not text: return 0 - words = len(text.split()) - estimated_seconds = (words / 1.5) + buffer - return max(buffer + 2.0, estimated_seconds) - -def is_time_in_range(start_str: str, end_str: str, now_time) -> bool: - """Controlla se l'orario attuale è in un range (gestisce accavallamento della notte).""" - start = dt_util.parse_time(start_str) - end = dt_util.parse_time(end_str) - if start <= end: - return start <= now_time <= end - else: - return start <= now_time or now_time <= end - -def get_current_slot_info(slots_conf: dict, now_time, - now_weekday: int, - weekend_days: list) -> tuple: - """Restituisce (nome_slot, volume) basandosi sull'ora attuale e giorno.""" - # Se la config è vuota, usiamo i default - if not slots_conf: - slots_conf = DEFAULT_TIME_SLOTS - - # Determine which group to use: weekend vs weekday - # Convert weekend_days to ints (HA selector now returns strings) - if weekend_days is not None: - weekend_days = [int(d) if isinstance(d, str) else d for d in weekend_days] - if (weekend_days is not None and now_weekday is not None - and now_weekday in weekend_days): - group = slots_conf.get("weekend", slots_conf) - else: - group = slots_conf.get("weekday", slots_conf) - - # If the selected group is itself flat (old format or fallback), - # "weekday" / "weekend" key won't exist, so group = slots_conf (flat dict). - # If group is a nested dict with slot keys, use it; otherwise fall back. - if not group or not isinstance(group, dict): - group = slots_conf - - # Check if group is flat (old format: {"morning": {...}, ...}) - # vs nested (new format). If the first value is a dict with "start", it's flat. - sample_val = next(iter(group.values()), None) - if isinstance(sample_val, dict) and "start" in sample_val: - working_slots = group - else: - # Fallback: use the original slots_conf directly (old flat format) - working_slots = slots_conf - - sorted_slots = [] - for name, data in working_slots.items(): - if not isinstance(data, dict): - continue - t_str = data.get("start") - t_obj = dt_util.parse_time(t_str) if t_str else None - vol_val = data.get("volume", 0.2) - if t_obj: - sorted_slots.append((name, t_obj, vol_val)) - # Ordina per orario di inizio - sorted_slots.sort(key=lambda x: x[1]) - if not sorted_slots: - return "default", 0.2 - # Logica: Inizializziamo con l'ultimo slot della lista. - # Questo copre il caso "notte" (es. dalle 23:00 alle 07:00). - current_slot = sorted_slots[-1][0] - current_vol = sorted_slots[-1][2] - for name, start_time, vol_val in sorted_slots: - if now_time >= start_time: - current_slot = name - current_vol = vol_val - return current_slot, current_vol - -# Tag SSML supportati dai motori TTS (Alexa, Google): vanno preservati. -SSML_TAGS = { - "speak", "voice", "prosody", "break", "say-as", "emphasis", - "lang", "phoneme", -} -_TAG_PATTERN = re.compile(r'<\s*/?\s*([a-zA-Z][\w:-]*)[^>]*>') - -def _strip_non_ssml_tags(text: str) -> str: - """Rimuove i tag HTML mantenendo i tag SSML noti.""" - return _TAG_PATTERN.sub( - lambda m: m.group(0) if m.group(1).lower() in SSML_TAGS else '', text - ) - -def clean_text_for_tts(text: str) -> str: - """Rimuove caratteri speciali per la sintesi vocale.""" - if not text: return "" - text = _strip_non_ssml_tags(text) # Via HTML tags, tiene SSML - text = re.sub(r'[*_`\[\]]', '', text) # Via markdown - text = re.sub(r'http\S+', '', text) # Via URL - # Via emoji/icon (preserva lettere accentate latin-1) - text = re.sub( - r'[\U00002100-\U000027BF' # Simboli BMP: frecce, box drawing, geometrici, dingbats, ⏰⌨ etc. - r'\U00002B00-\U00002BFF' # Misc Symbols & Arrows (⬜⬆⬇⬅➡) - r'\U00003000-\U0000303F' # CJK Symbols (〇〒など) - r'\U00003200-\U000032FF' # Enclosed CJK Letters (㋀㋁㋂) - r'\U0000FE00-\U0000FE0F' # Variation Selectors - r'\U0000200D' # Zero Width Joiner - r'\U000E0000-\U000E007F' # Tag characters (flag subtags) - r'\U0001F000-\U0001FBFF' # Simboli SMP: mahjong, carte, bandiere, emoticons, trasporti, ecc. - r'\U0001FC00-\U0001FFFF' # Symbols Extended, compatibilità - r']+', '', text) - return re.sub(r'\s{2,}', ' ', text).strip() - -def sanitize_text_visual(text: str, parse_mode: str) -> str: - """Pulisce il testo in base al parse_mode del canale.""" - if not text: return "" - mode = (parse_mode or "").lower() - if "html" in mode: - # HTML mode (mobile_app, telegram HTML): rimuove markdown, tiene HTML e icone - text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) # **bold** → bold - text = re.sub(r'\*(.+?)\*', r'\1', text) # *italic* → italic - elif "markdown" in mode: - # Markdown / MarkdownV2 mode (telegram): rimuove HTML, tiene markdown e icone - text = re.sub(r'<[^>]+>', '', text) - else: - # plain_text o None: rimuovi marker markdown che altrimenti restano visibili - text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) # **bold** → bold - text = re.sub(r'\*(.+?)\*', r'\1', text) # *italic* → italic - return text - -def apply_formatting(text: str, parse_mode: str, style: str = "bold") -> str: - """Applica la formattazione (grassetto) in base al parse_mode.""" - if not text: return "" - mode = parse_mode.lower() if parse_mode else "" - if "html" in mode: - if style == "bold": return f"{text}" - elif "markdownv2" in mode: - # Telegram MarkdownV2 usa * singolo per bold - if style == "bold": return f"*{text}*" - elif "markdown" in mode: - return f"**{text}**" - return text - - -def escape_markdownv2(text: str) -> str: - """Escape caratteri speciali per Telegram MarkdownV2, preservando **bold** → *bold* e *italic*.""" - if not text: return "" - special = r'\_[]()~`>#+=|{}.!-' - saved = {} - counter = [0] - - def _protect(m): - key = f'\x00{counter[0]}\x00' - counter[0] += 1 - inner = m.group(1) - for ch in special: - inner = inner.replace(ch, '\\' + ch) - saved[key] = f'*{inner}*' - return key - - # Protegge **bold** (→ *bold*) e *italic* - result = re.sub(r'\*\*(.+?)\*\*', _protect, text) - result = re.sub(r'(? str | None: - """Normalizza parse_mode per il dominio di servizio specifico.""" - if not parse_mode: - return None - pm = parse_mode.strip() - if srv_domain == "telegram_bot": - low = pm.lower() - # [0.8.1] Fix: HA ora valida parse_mode strict lowercase. - # Valori validi: html, markdown, markdownv2, plain_text. - # Qualsiasi altro valore → fallback a "html". - VALID = ("html", "markdown", "markdownv2", "plain_text") - return low if low in VALID else "html" - else: +# /config/custom_components/universal_notifier/utils.py +import re + +from homeassistant.util import dt as dt_util + +# ============================================================================== +# HELPER FUNCTIONS +# ============================================================================== + +DEFAULT_TIME_SLOTS = { + "weekday": { + "morning": {"start": "07:00", "volume": 0.35}, + "afternoon": {"start": "12:00", "volume": 0.40}, + "evening": {"start": "19:00", "volume": 0.30}, + "night": {"start": "22:00", "volume": 0.10}, + }, + "weekend": { + "morning": {"start": "07:00", "volume": 0.35}, + "afternoon": {"start": "12:00", "volume": 0.40}, + "evening": {"start": "19:00", "volume": 0.30}, + "night": {"start": "22:00", "volume": 0.10}, + }, +} + +def estimate_tts_duration(text: str, buffer: float) -> float: + """Stima la durata del messaggio in secondi basandosi sulle parole.""" + if not text: return 0 + words = len(text.split()) + estimated_seconds = (words / 1.5) + buffer + return max(buffer + 2.0, estimated_seconds) + +def is_time_in_range(start_str: str, end_str: str, now_time) -> bool: + """Controlla se l'orario attuale è in un range (gestisce accavallamento della notte).""" + start = dt_util.parse_time(start_str) + end = dt_util.parse_time(end_str) + if start <= end: + return start <= now_time <= end + else: + return start <= now_time or now_time <= end + +def get_current_slot_info(slots_conf: dict, now_time, + now_weekday: int, + weekend_days: list) -> tuple: + """Restituisce (nome_slot, volume) basandosi sull'ora attuale e giorno.""" + # Se la config è vuota, usiamo i default + if not slots_conf: + slots_conf = DEFAULT_TIME_SLOTS + + # Determine which group to use: weekend vs weekday + # Convert weekend_days to ints (HA selector now returns strings) + if weekend_days is not None: + weekend_days = [int(d) if isinstance(d, str) else d for d in weekend_days] + if (weekend_days is not None and now_weekday is not None + and now_weekday in weekend_days): + group = slots_conf.get("weekend", slots_conf) + else: + group = slots_conf.get("weekday", slots_conf) + + # If the selected group is itself flat (old format or fallback), + # "weekday" / "weekend" key won't exist, so group = slots_conf (flat dict). + # If group is a nested dict with slot keys, use it; otherwise fall back. + if not group or not isinstance(group, dict): + group = slots_conf + + # Check if group is flat (old format: {"morning": {...}, ...}) + # vs nested (new format). If the first value is a dict with "start", it's flat. + sample_val = next(iter(group.values()), None) + if isinstance(sample_val, dict) and "start" in sample_val: + working_slots = group + else: + # Fallback: use the original slots_conf directly (old flat format) + working_slots = slots_conf + + sorted_slots = [] + for name, data in working_slots.items(): + if not isinstance(data, dict): + continue + t_str = data.get("start") + t_obj = dt_util.parse_time(t_str) if t_str else None + vol_val = data.get("volume", 0.2) + if t_obj: + sorted_slots.append((name, t_obj, vol_val)) + # Ordina per orario di inizio + sorted_slots.sort(key=lambda x: x[1]) + if not sorted_slots: + return "default", 0.2 + # Logica: Inizializziamo con l'ultimo slot della lista. + # Questo copre il caso "notte" (es. dalle 23:00 alle 07:00). + current_slot = sorted_slots[-1][0] + current_vol = sorted_slots[-1][2] + for name, start_time, vol_val in sorted_slots: + if now_time >= start_time: + current_slot = name + current_vol = vol_val + return current_slot, current_vol + +# Tag SSML supportati dai motori TTS (Alexa, Google): vanno preservati. +SSML_TAGS = { + "speak", "voice", "prosody", "break", "say-as", "emphasis", + "lang", "phoneme", +} +_TAG_PATTERN = re.compile(r'<\s*/?\s*([a-zA-Z][\w:-]*)[^>]*>') + +def _strip_non_ssml_tags(text: str) -> str: + """Rimuove i tag HTML mantenendo i tag SSML noti.""" + return _TAG_PATTERN.sub( + lambda m: m.group(0) if m.group(1).lower() in SSML_TAGS else '', text + ) + +def clean_text_for_tts(text: str) -> str: + """Rimuove caratteri speciali per la sintesi vocale.""" + if not text: return "" + text = _strip_non_ssml_tags(text) # Via HTML tags, tiene SSML + text = re.sub(r'[*_`\[\]]', '', text) # Via markdown + text = re.sub(r'http\S+', '', text) # Via URL + # Via emoji/icon (preserva lettere accentate latin-1) + text = re.sub( + r'[\U00002100-\U000027BF' # Simboli BMP: frecce, box drawing, geometrici, dingbats, ⏰⌨ etc. + r'\U00002B00-\U00002BFF' # Misc Symbols & Arrows (⬜⬆⬇⬅➡) + r'\U00003000-\U0000303F' # CJK Symbols (〇〒など) + r'\U00003200-\U000032FF' # Enclosed CJK Letters (㋀㋁㋂) + r'\U0000FE00-\U0000FE0F' # Variation Selectors + r'\U0000200D' # Zero Width Joiner + r'\U000E0000-\U000E007F' # Tag characters (flag subtags) + r'\U0001F000-\U0001FBFF' # Simboli SMP: mahjong, carte, bandiere, emoticons, trasporti, ecc. + r'\U0001FC00-\U0001FFFF' # Symbols Extended, compatibilità + r']+', '', text) + return re.sub(r'\s{2,}', ' ', text).strip() + +def sanitize_text_visual(text: str, parse_mode: str) -> str: + """Pulisce il testo in base al parse_mode del canale.""" + if not text: return "" + mode = (parse_mode or "").lower() + if "html" in mode: + # HTML mode (mobile_app, telegram HTML): rimuove markdown, tiene HTML e icone + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) # **bold** → bold + text = re.sub(r'\*(.+?)\*', r'\1', text) # *italic* → italic + elif "markdown" in mode: + # Markdown / MarkdownV2 mode (telegram): rimuove HTML, tiene markdown e icone + text = re.sub(r'<[^>]+>', '', text) + else: + # plain_text o None: rimuovi marker markdown che altrimenti restano visibili + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) # **bold** → bold + text = re.sub(r'\*(.+?)\*', r'\1', text) # *italic* → italic + return text + +def apply_formatting(text: str, parse_mode: str, style: str = "bold") -> str: + """Applica la formattazione (grassetto) in base al parse_mode.""" + if not text: return "" + mode = parse_mode.lower() if parse_mode else "" + if "html" in mode: + if style == "bold": return f"{text}" + elif "markdownv2" in mode: + # Telegram MarkdownV2 usa * singolo per bold + if style == "bold": return f"*{text}*" + elif "markdown" in mode: + return f"**{text}**" + return text + + +def escape_markdownv2(text: str) -> str: + """Escape caratteri speciali per Telegram MarkdownV2, preservando **bold** → *bold* e *italic*.""" + if not text: return "" + special = r'\_[]()~`>#+=|{}.!-' + saved = {} + counter = [0] + + def _protect(m): + key = f'\x00{counter[0]}\x00' + counter[0] += 1 + inner = m.group(1) + for ch in special: + inner = inner.replace(ch, '\\' + ch) + saved[key] = f'*{inner}*' + return key + + # Protegge **bold** (→ *bold*) e *italic* + result = re.sub(r'\*\*(.+?)\*\*', _protect, text) + result = re.sub(r'(? str: + """Strip HTML tags from text, returning plain text.""" + return re.sub(r'<[^>]+>', '', str(text)).strip() + + +def is_apple_device(hass, entity_ids: list) -> bool: + """Return True if any target notify entity belongs to an Apple (iOS) device.""" + from homeassistant.helpers import device_registry as dr + from homeassistant.helpers import entity_registry as er + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + for eid in (entity_ids or []): + ent = ent_reg.async_get(eid) + if ent and ent.device_id: + dev = dev_reg.async_get(ent.device_id) + if dev and (dev.manufacturer or "").lower() == "apple": + return True + return False + + +def normalize_parse_mode(parse_mode: str, srv_domain: str) -> str | None: + """Normalizza parse_mode per il dominio di servizio specifico.""" + if not parse_mode: + return None + pm = parse_mode.strip() + if srv_domain == "telegram_bot": + low = pm.lower() + # [0.8.1] Fix: HA ora valida parse_mode strict lowercase. + # Valori validi: html, markdown, markdownv2, plain_text. + # Qualsiasi altro valore → fallback a "html". + VALID = ("html", "markdown", "markdownv2", "plain_text") + return low if low in VALID else "html" + else: return pm.lower() \ No newline at end of file From 3e78743334520a53b175f384dffdb151d19ff70f Mon Sep 17 00:00:00 2001 From: Rabosa616 Date: Fri, 4 Sep 2026 19:07:36 +0200 Subject: [PATCH 6/7] Refactor mobile notify formatting into utils.py helpers Add apply_apple_notify_text_formatting, apply_android_notify_text_formatting and apply_mobile_notify_text_formatting to utils.py. Simplify __init__.py Section D: replace duplicated iOS/Android logic with a single call to apply_mobile_notify_text_formatting. --- .../universal_notifier/__init__.py | 42 ++++--------- custom_components/universal_notifier/utils.py | 63 +++++++++++++++++++ 2 files changed, 75 insertions(+), 30 deletions(-) diff --git a/custom_components/universal_notifier/__init__.py b/custom_components/universal_notifier/__init__.py index 3720772..8ddd910 100644 --- a/custom_components/universal_notifier/__init__.py +++ b/custom_components/universal_notifier/__init__.py @@ -359,36 +359,18 @@ async def async_send_notification(call: ServiceCall): final_title = None text_content_for_duration = final_msg elif srv_domain == "notify" and srv_name == "send_message": - # Companion app: detect iOS (Apple) vs Android via device registry. - # iOS does not render HTML → plain text, no HA prefix, no greeting. - # Android renders HTML → fall through to standard formatting. - if is_apple_device(hass, dynamic_entities): - final_msg = strip_html(str(target_raw_message)) - if final_title: - final_title = strip_html(str(final_title)) - else: - # Android: standard formatting with HTML prefix/greeting - clean_name = sanitize_text_visual(raw_name, parse_mode) - clean_time = sanitize_text_visual(raw_time_str, parse_mode) - clean_msg = sanitize_text_visual(str(target_raw_message), parse_mode) - clean_greet = sanitize_text_visual(current_greeting, parse_mode) - clean_orig_title = sanitize_text_visual(final_title, parse_mode) if final_title else None - if use_bold_prefix: - clean_name = apply_formatting(clean_name, parse_mode, "bold") - clean_time = apply_formatting(clean_time, parse_mode, "bold") - clean_orig_title = apply_formatting(clean_orig_title, parse_mode, "bold") - prefix_parts = [] - if clean_name and not skip_assistant_name: - prefix_parts.append(clean_name) - if clean_time: - prefix_parts.append(clean_time) - clean_prefix = f"[{' - '.join(prefix_parts)}]" if prefix_parts else "" - greeting_part = f"{clean_greet}. " if clean_greet else "" - if clean_orig_title: - final_title = f"{clean_prefix} {clean_orig_title}" if clean_prefix else clean_orig_title - final_msg = f"{greeting_part}{clean_msg}" - else: - final_msg = f"{clean_prefix} {greeting_part}{clean_msg}" if clean_prefix else f"{greeting_part}{clean_msg}" + device_type = "apple" if is_apple_device(hass, dynamic_entities) else "android" + final_msg, final_title = apply_mobile_notify_text_formatting( + message=target_raw_message, + title=final_title, + device_type=device_type, + name=raw_name, + time_str=raw_time_str, + greeting=current_greeting, + parse_mode=parse_mode, + use_bold_prefix=use_bold_prefix, + skip_assistant_name=skip_assistant_name, + ) else: clean_name = sanitize_text_visual(raw_name, parse_mode) clean_time = sanitize_text_visual(raw_time_str, parse_mode) diff --git a/custom_components/universal_notifier/utils.py b/custom_components/universal_notifier/utils.py index 3542215..71494e7 100644 --- a/custom_components/universal_notifier/utils.py +++ b/custom_components/universal_notifier/utils.py @@ -206,6 +206,69 @@ def is_apple_device(hass, entity_ids: list) -> bool: return False +def apply_apple_notify_text_formatting( + message: str, + title: str | None, +) -> tuple: + """iOS: plain text only, no HTML tags, no HA prefix, no greeting.""" + return strip_html(str(message)), strip_html(str(title)) if title else title + + +def apply_android_notify_text_formatting( + message: str, + title: str | None, + name: str = "", + time_str: str = "", + greeting: str = "", + parse_mode: str | None = None, + use_bold_prefix: bool = True, + skip_assistant_name: bool = False, +) -> tuple: + """Android: HTML formatting with HA prefix [name - time] and greeting.""" + clean_name = sanitize_text_visual(name, parse_mode) + clean_time = sanitize_text_visual(time_str, parse_mode) + clean_msg = sanitize_text_visual(str(message), parse_mode) + clean_greet = sanitize_text_visual(greeting, parse_mode) + clean_orig_title = sanitize_text_visual(title, parse_mode) if title else None + if use_bold_prefix: + clean_name = apply_formatting(clean_name, parse_mode, "bold") + clean_time = apply_formatting(clean_time, parse_mode, "bold") + clean_orig_title = apply_formatting(clean_orig_title, parse_mode, "bold") + prefix_parts = [] + if clean_name and not skip_assistant_name: + prefix_parts.append(clean_name) + if clean_time: + prefix_parts.append(clean_time) + clean_prefix = f"[{' - '.join(prefix_parts)}]" if prefix_parts else "" + greeting_part = f"{clean_greet}. " if clean_greet else "" + if clean_orig_title: + final_title = f"{clean_prefix} {clean_orig_title}" if clean_prefix else clean_orig_title + final_msg = f"{greeting_part}{clean_msg}" + else: + final_msg = f"{clean_prefix} {greeting_part}{clean_msg}" if clean_prefix else f"{greeting_part}{clean_msg}" + return final_msg, final_title + + +def apply_mobile_notify_text_formatting( + message: str, + title: str | None, + device_type: str, + name: str = "", + time_str: str = "", + greeting: str = "", + parse_mode: str | None = None, + use_bold_prefix: bool = True, + skip_assistant_name: bool = False, +) -> tuple: + """Dispatch to iOS or Android formatting based on device_type ('apple' | 'android').""" + if device_type == "apple": + return apply_apple_notify_text_formatting(message, title) + return apply_android_notify_text_formatting( + message, title, name, time_str, greeting, + parse_mode, use_bold_prefix, skip_assistant_name, + ) + + def normalize_parse_mode(parse_mode: str, srv_domain: str) -> str | None: """Normalizza parse_mode per il dominio di servizio specifico.""" if not parse_mode: From 211d60961e815f08dd23c1fa83d55dd510d9a7ff Mon Sep 17 00:00:00 2001 From: Rabosa616 Date: Wed, 16 Sep 2026 16:00:39 +0200 Subject: [PATCH 7/7] fix: make mobile notify formatting robust for notify.send_message Two failures surfaced by the test suite on the merged branch: - is_apple_device() raised "Device registry not set up" whenever the entity/device registries are unavailable (early startup, mocked hass), breaking every notify.send_message call. It now returns False in that case, accepts a plain string entity_id and skips non-string entries. - apply_android_notify_text_formatting() left final_title unbound when called without a title, raising UnboundLocalError. It now returns None. Add unit tests for strip_html, is_apple_device and apply_mobile_notify_text_formatting (apple/android, prefix, greeting, skip_assistant_name, unknown device type). --- custom_components/universal_notifier/utils.py | 24 +++- tests/test_utils.py | 128 ++++++++++++++++-- 2 files changed, 139 insertions(+), 13 deletions(-) diff --git a/custom_components/universal_notifier/utils.py b/custom_components/universal_notifier/utils.py index 70b1fdb..4cc9a37 100644 --- a/custom_components/universal_notifier/utils.py +++ b/custom_components/universal_notifier/utils.py @@ -191,13 +191,26 @@ def strip_html(text: str) -> str: return re.sub(r'<[^>]+>', '', str(text)).strip() -def is_apple_device(hass, entity_ids: list) -> bool: - """Return True if any target notify entity belongs to an Apple (iOS) device.""" +def is_apple_device(hass, entity_ids) -> bool: + """Return True if any target notify entity belongs to an Apple (iOS) device. + + Falls back to False when the registries are not available (for example + during early startup or in tests with a mocked hass). + """ + if not entity_ids: + return False + if isinstance(entity_ids, str): + entity_ids = [entity_ids] from homeassistant.helpers import device_registry as dr from homeassistant.helpers import entity_registry as er - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) - for eid in (entity_ids or []): + try: + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + except (KeyError, RuntimeError, AttributeError, TypeError): + return False + for eid in entity_ids: + if not isinstance(eid, str): + continue ent = ent_reg.async_get(eid) if ent and ent.device_id: dev = dev_reg.async_get(ent.device_id) @@ -245,6 +258,7 @@ def apply_android_notify_text_formatting( final_title = f"{clean_prefix} {clean_orig_title}" if clean_prefix else clean_orig_title final_msg = f"{greeting_part}{clean_msg}" else: + final_title = None final_msg = f"{clean_prefix} {greeting_part}{clean_msg}" if clean_prefix else f"{greeting_part}{clean_msg}" return final_msg, final_title diff --git a/tests/test_utils.py b/tests/test_utils.py index d0a070b..20e25f0 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -5,14 +5,11 @@ import pytest -from custom_components.universal_notifier.utils import (apply_formatting, - clean_text_for_tts, - escape_markdownv2, - estimate_tts_duration, - get_current_slot_info, - is_time_in_range, - normalize_parse_mode, - sanitize_text_visual) +from custom_components.universal_notifier.utils import ( + apply_formatting, apply_mobile_notify_text_formatting, clean_text_for_tts, + escape_markdownv2, estimate_tts_duration, get_current_slot_info, + is_apple_device, is_time_in_range, normalize_parse_mode, + sanitize_text_visual, strip_html) # ============================================================================ # estimate_tts_duration @@ -341,3 +338,118 @@ def test_none_returns_none(self): def test_empty_returns_none(self): assert normalize_parse_mode("", "telegram_bot") is None + + +# ============================================================================ +# strip_html +# ============================================================================ + +class TestStripHTML: + def test_removes_tags(self): + assert strip_html("Bold text") == "Bold text" + + def test_removes_nested_tags(self): + assert strip_html("Hi") == "Hi" + + def test_plain_text_unchanged(self): + assert strip_html("no tags here") == "no tags here" + + def test_strips_surrounding_whitespace(self): + assert strip_html(" x ") == "x" + + def test_non_string_input(self): + assert strip_html(123) == "123" # type: ignore[arg-type] + + +# ============================================================================ +# is_apple_device +# ============================================================================ + +class TestIsAppleDevice: + def test_empty_entities_returns_false(self): + assert is_apple_device(object(), []) is False + + def test_none_entities_returns_false(self): + assert is_apple_device(object(), None) is False + + def test_missing_registries_returns_false(self): + """A hass without registries must not raise, just report non-Apple.""" + assert is_apple_device(object(), ["notify.mobile_app_phone"]) is False + + +# ============================================================================ +# apply_mobile_notify_text_formatting +# ============================================================================ + +class TestApplyMobileNotifyTextFormatting: + def test_apple_strips_html_and_prefix(self): + msg, title = apply_mobile_notify_text_formatting( + message="Hello", + title="Casa", + device_type="apple", + name="Assistant", + time_str="10:00", + greeting="Good morning", + parse_mode="html", + ) + assert msg == "Hello" + assert title == "Casa" + + def test_apple_without_title(self): + msg, title = apply_mobile_notify_text_formatting( + message="Hello", title=None, device_type="apple" + ) + assert msg == "Hello" + assert title is None + + def test_android_keeps_prefix_and_greeting(self): + msg, title = apply_mobile_notify_text_formatting( + message="Hello", + title="Casa", + device_type="android", + name="Assistant", + time_str="10:00", + greeting="Good morning", + parse_mode="html", + ) + assert "Good morning" in msg + assert "Hello" in msg + assert "Assistant" in title + assert "Casa" in title + + def test_android_without_title_puts_prefix_in_message(self): + msg, title = apply_mobile_notify_text_formatting( + message="Hello", + title=None, + device_type="android", + name="Assistant", + time_str="10:00", + parse_mode="html", + ) + assert "Assistant" in msg + assert "10:00" in msg + assert title is None + + def test_android_skip_assistant_name(self): + msg, _ = apply_mobile_notify_text_formatting( + message="Hello", + title=None, + device_type="android", + name="Assistant", + time_str="10:00", + parse_mode="html", + skip_assistant_name=True, + ) + assert "Assistant" not in msg + assert "10:00" in msg + + def test_unknown_device_type_falls_back_to_android(self): + msg, _ = apply_mobile_notify_text_formatting( + message="Hello", + title=None, + device_type="whatever", + name="Assistant", + time_str="10:00", + parse_mode="html", + ) + assert "Assistant" in msg