From 7e3db1b0c16dc61cf25f5caa493a6406cce88335 Mon Sep 17 00:00:00 2001 From: Jumping Date: Sun, 6 Sep 2026 09:22:18 +0200 Subject: [PATCH 1/5] devcontainer mod --- .devcontainer/Dockerfile | 39 ++++++++++++++++++++++++++++++++ .devcontainer/devcontainer.json | 1 + .devcontainer/docker-compose.yml | 15 +++++++----- .dockerignore | 25 ++++++++++++++++++++ .vscode/settings.json | 2 +- 5 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .dockerignore diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..4120403 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,39 @@ +# ============================================================================= +# Debian-based Home Assistant development container. +# +# The official `homeassistant/home-assistant` image is built on Alpine Linux +# (musl libc), which the VS Code Server (Node.js) cannot run on. This image +# uses Debian (glibc) and installs Home Assistant Core via pip, so the custom +# component can be developed live inside the container (mounted at +# /config/custom_components/universal_notifier). +# ============================================================================= + +FROM mcr.microsoft.com/devcontainers/python:3.13-bookworm + +# System libraries used by Home Assistant Core and common integrations. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ffmpeg \ + libturbojpeg0 \ + libjpeg62-turbo \ + libxml2 \ + libxslt1.1 \ + tzdata \ + && rm -rf /var/lib/apt/lists/* + +# Tooling: uv (project dependency management) + Home Assistant Core (latest stable). +# Home Assistant is installed system-wide so the `hass` entry point is on PATH; +# the project's own test dependencies are managed separately by `uv sync`. +# +# NOTE: `pip install homeassistant` installs CORE ONLY — no integration deps. +# La dev config NON usa `default_config:` (troppe integrazioni con deps pip +# pesanti), ma una lista esplicita di integrazioni core-only. L'unica +# requirement extra è il frontend: il pacchetto PyPI `home-assistant-frontend` +# (che fornisce il modulo importabile `hass_frontend`). +RUN pip install --no-cache-dir \ + uv \ + homeassistant \ + home-assistant-frontend + +WORKDIR /workspace + diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3df55ab..a339653 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,6 +3,7 @@ "dockerComposeFile": "docker-compose.yml", "service": "homeassistant", "workspaceFolder": "/workspace", + "remoteUser": "root", "shutdownAction": "stopCompose", "forwardPorts": [ 8123 diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index cc09dca..3eb5ed9 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -1,12 +1,15 @@ -version: "3.8" - services: homeassistant: - # build: - # context: . - # dockerfile: Dockerfile - image: homeassistant/home-assistant:stable + build: + context: . + dockerfile: Dockerfile container_name: universal_notifier_ha_dev + # Home Assistant Core needs to write to /config (a bind mount); running as + # root avoids permission issues on bind mounts (same as the official image). + user: root + + # Start Home Assistant against the mounted config directory. + command: [ "hass", "-c", "/config" ] volumes: # Cartella config HA (configuration.yaml, .storage, db, ecc.) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1d8063e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +# Version control / IDE +.git/ +.vscode/ +.github/ + +# Python — .venv contains Windows-only symlinks (e.g. lib64) that break +# the Docker build context on Windows ("The file cannot be accessed by the system") +.venv/ +__pycache__/ +.pytest_cache/ +*.pyc + +# Home Assistant runtime data +config/ + +# Docs / artifacts not needed in the image +docs/ +img/ +htmlcov/ +.coverage + +# Docker/devcontainer config (not needed inside the build context) +.devcontainer/ +tests/Dockerfile.test +.dockerignore diff --git a/.vscode/settings.json b/.vscode/settings.json index 45ac7c9..0eac4e7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "python.defaultInterpreterPath": "./.venv/Scripts/python.exe", + "python.defaultInterpreterPath": "./.venv/bin/python3.14", "editor.formatOnSave": true, "python.testing.pytestEnabled": true, "python.analysis.typeCheckingMode": "standard", From ef36b7799feda60e5e97d656004aa77a8ab3b5e8 Mon Sep 17 00:00:00 2001 From: Jumping Date: Sun, 6 Sep 2026 09:54:46 +0200 Subject: [PATCH 2/5] ruff style modified: custom_components/universal_notifier/__init__.py --- .../universal_notifier/__init__.py | 432 ++++++++++++------ 1 file changed, 304 insertions(+), 128 deletions(-) diff --git a/custom_components/universal_notifier/__init__.py b/custom_components/universal_notifier/__init__.py index 42dfeb3..39d9129 100644 --- a/custom_components/universal_notifier/__init__.py +++ b/custom_components/universal_notifier/__init__.py @@ -2,29 +2,54 @@ import asyncio import logging -import math import random -import re +# import re +# import math import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import (ATTR_ENTITY_ID, CONF_SERVICE, CONF_TYPE, - STATE_PLAYING) +from homeassistant.const import ATTR_ENTITY_ID, CONF_SERVICE, CONF_TYPE, STATE_PLAYING from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from .const import ( # Config keys; Service keys (Inputs); Inner Channel keys; Entity IDs (auto-created); Other - COMPANION_COMMANDS, CONF_ALT_SERVICES, CONF_ASSISTANT_NAME, - CONF_BOLD_PREFIX, CONF_CHANNELS, CONF_CHAT_ID, CONF_DATA, CONF_DATE_FORMAT, - CONF_DEFAULT_MEDIA_PLAYER, CONF_DND, CONF_ENTITY_ID, CONF_GREETINGS, - CONF_IGNORE_TITLE_VOICE, CONF_INCLUDE_TIME, CONF_IS_VOICE, CONF_MESSAGE, - CONF_OVERRIDE_GREETINGS, CONF_PERSON_ENTITIES, CONF_PRIORITY, - CONF_PRIORITY_VOLUME, CONF_SERVICE, CONF_SKIP_ASSISTANT_NAME, - CONF_SKIP_GREETING, CONF_TARGET, - CONF_TARGET_DATA, CONF_TARGETS, CONF_TIME_SLOTS, CONF_TITLE, CONF_TYPE, - DOMAIN, ENTITY_DND_OVERRIDE, ENTITY_LAST_MESSAGE, PLATFORMS) + COMPANION_COMMANDS, + CONF_ALT_SERVICES, + CONF_ASSISTANT_NAME, + CONF_BOLD_PREFIX, + CONF_CHANNELS, + CONF_CHAT_ID, + CONF_DATA, + CONF_DATE_FORMAT, + CONF_DEFAULT_MEDIA_PLAYER, + CONF_DND, + CONF_ENTITY_ID, + CONF_GREETINGS, + CONF_IGNORE_TITLE_VOICE, + CONF_INCLUDE_TIME, + CONF_IS_VOICE, + CONF_MESSAGE, + CONF_OVERRIDE_GREETINGS, + CONF_PERSON_ENTITIES, + CONF_PRIORITY, + CONF_PRIORITY_VOLUME, + CONF_SERVICE, + CONF_SKIP_ASSISTANT_NAME, + CONF_SKIP_GREETING, + CONF_TARGET, + CONF_TARGET_DATA, + CONF_TARGETS, + CONF_TIME_SLOTS, + CONF_TITLE, + CONF_TYPE, + DOMAIN, + ENTITY_DND_OVERRIDE, + ENTITY_LAST_MESSAGE, + PLATFORMS, +) + #### from .utils import * @@ -57,17 +82,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok + # ============================================================================== # LOGICA DI RESUME # ============================================================================== + async def _get_player_snapshot(hass: HomeAssistant, entity_id: str) -> dict: """Cattura lo stato originale SOLO se non siamo già in una sessione di notifica.""" if entity_id in _ORIGINAL_STATES: _LOGGER.debug(f"UniNotifier: {entity_id} già in sessione, salto snapshot.") return None state = hass.states.get(entity_id) - if not state: return None + if not state: + return None attr = state.attributes snap = { "state": state.state, @@ -76,19 +104,24 @@ async def _get_player_snapshot(hass: HomeAssistant, entity_id: str) -> dict: "media_content_id": attr.get("media_content_id"), "media_content_type": attr.get("media_content_type"), "media_position": attr.get("media_position"), - "entity_id": entity_id + "entity_id": entity_id, } _ORIGINAL_STATES[entity_id] = snap _LOGGER.debug(f"UniNotifier: Snapshot originale salvato per {entity_id}: {snap}") return snap + async def _apply_resume(hass: HomeAssistant, entity_id: str, target_volume: float): """Ripristina lo stato salvato all'inizio della sessione.""" snap = _ORIGINAL_STATES.pop(entity_id, None) - restore_volume = (snap["volume"] if snap and snap["volume"] is not None else target_volume) - await hass.services.async_call("media_player", "volume_set", { - "entity_id": entity_id, "volume_level": restore_volume - }) + restore_volume = ( + snap["volume"] if snap and snap["volume"] is not None else target_volume + ) + await hass.services.async_call( + "media_player", + "volume_set", + {"entity_id": entity_id, "volume_level": restore_volume}, + ) _LOGGER.debug(f"UniNotifier: Resume del volume di {entity_id} con {restore_volume}") # Ripristino Contenuto (solo se stava suonando prima della prima notifica) if snap and snap["state"] == STATE_PLAYING: @@ -96,43 +129,61 @@ async def _apply_resume(hass: HomeAssistant, entity_id: str, target_volume: floa c_id = snap.get("media_content_id") try: if "spotify" in app or (c_id and "spotify" in c_id): - await hass.services.async_call("media_player", "play_media", { - "entity_id": entity_id, "media_content_id": c_id, "media_content_type": "music" - }) + await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": entity_id, + "media_content_id": c_id, + "media_content_type": "music", + }, + ) else: - await hass.services.async_call("media_player", "play_media", { - "entity_id": entity_id, "media_content_id": c_id, - "media_content_type": snap.get("media_content_type", "audio/mpeg") - }) + await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": entity_id, + "media_content_id": c_id, + "media_content_type": snap.get( + "media_content_type", "audio/mpeg" + ), + }, + ) _LOGGER.debug(f"UniNotifier: Tentativo di resume eseguito per {entity_id}") except Exception as e: _LOGGER.error(f"UniNotifier: Errore nel resume di {entity_id}: {e}") + # ============================================================================== # SERVICE SCHEMA # ============================================================================== -SEND_SERVICE_SCHEMA = vol.Schema({ - vol.Required(CONF_MESSAGE): cv.string, - vol.Required(CONF_TARGETS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_TITLE): cv.string, - vol.Optional(CONF_DATA): dict, - vol.Optional(CONF_TARGET_DATA): dict, - vol.Optional(CONF_PRIORITY): cv.boolean, - vol.Optional(CONF_SKIP_GREETING): cv.boolean, - vol.Optional(CONF_SKIP_ASSISTANT_NAME): cv.boolean, - vol.Optional(CONF_INCLUDE_TIME): cv.boolean, - vol.Optional(CONF_PRIORITY_VOLUME): cv.string, - vol.Optional(CONF_ASSISTANT_NAME): cv.string, - vol.Optional(CONF_BOLD_PREFIX): cv.boolean, - vol.Optional(CONF_IGNORE_TITLE_VOICE): cv.boolean, - vol.Optional(CONF_OVERRIDE_GREETINGS): dict, -}, extra=vol.ALLOW_EXTRA) +SEND_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_MESSAGE): cv.string, + vol.Required(CONF_TARGETS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_TITLE): cv.string, + vol.Optional(CONF_DATA): dict, + vol.Optional(CONF_TARGET_DATA): dict, + vol.Optional(CONF_PRIORITY): cv.boolean, + vol.Optional(CONF_SKIP_GREETING): cv.boolean, + vol.Optional(CONF_SKIP_ASSISTANT_NAME): cv.boolean, + vol.Optional(CONF_INCLUDE_TIME): cv.boolean, + vol.Optional(CONF_PRIORITY_VOLUME): cv.string, + vol.Optional(CONF_ASSISTANT_NAME): cv.string, + vol.Optional(CONF_BOLD_PREFIX): cv.boolean, + vol.Optional(CONF_IGNORE_TITLE_VOICE): cv.boolean, + vol.Optional(CONF_OVERRIDE_GREETINGS): dict, + }, + extra=vol.ALLOW_EXTRA, +) # ============================================================================== # CONFIG ENTRY SETUP # ============================================================================== + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Setup del componente Universal Notifier da config entry (UI).""" @@ -140,20 +191,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: conf = {**entry.data, **entry.options} # --- Estrazione variabili di configurazione --- - channels_config = conf.get(CONF_CHANNELS, {}) - global_name = conf.get(CONF_ASSISTANT_NAME, "") - global_date_fmt = conf.get(CONF_DATE_FORMAT, "%H:%M:%S") + channels_config = conf.get(CONF_CHANNELS, {}) + global_name = conf.get(CONF_ASSISTANT_NAME, "") + global_date_fmt = conf.get(CONF_DATE_FORMAT, "%H:%M:%S") global_include_time = conf.get(CONF_INCLUDE_TIME, True) global_priority_vol = conf.get(CONF_PRIORITY_VOLUME, 0.9) - time_slots_conf = conf.get(CONF_TIME_SLOTS, {}) - dnd_conf = conf.get(CONF_DND, {"start": "23:00", "end": "06:00"}) + time_slots_conf = conf.get(CONF_TIME_SLOTS, {}) + dnd_conf = conf.get(CONF_DND, {"start": "23:00", "end": "06:00"}) # Retrocompat: migrate old flat DND to nested weekday/weekend if isinstance(dnd_conf, dict) and "weekday" not in dnd_conf and "start" in dnd_conf: dnd_conf = {"weekday": dnd_conf, "weekend": dnd_conf} - base_greetings = conf.get(CONF_GREETINGS, {}) - global_bold_setting = conf.get(CONF_BOLD_PREFIX, True) - global_ignore_title_voice = conf.get(CONF_IGNORE_TITLE_VOICE, True) - weekend_days = [int(d) if isinstance(d, str) else d for d in conf.get("weekend_days", ["5", "6"])] + base_greetings = conf.get(CONF_GREETINGS, {}) + global_bold_setting = conf.get(CONF_BOLD_PREFIX, True) + global_ignore_title_voice = conf.get(CONF_IGNORE_TITLE_VOICE, True) + weekend_days = [ + int(d) if isinstance(d, str) else d + for d in conf.get("weekend_days", ["5", "6"]) + ] # --- Inizializzazione coda TTS --- voice_queue = asyncio.Queue() @@ -162,12 +216,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { - "conf": conf, - "voice_queue": voice_queue, - "runtime_priority_vol": None, # override runtime da select entity - "tts_buffer": 2.5, # sovrascrivibile da number entity - "text_format": "html", # sovrascrivibile da select entity - "notification_mode": "Normal", # sovrascrivibile da select entity + "conf": conf, + "voice_queue": voice_queue, + "runtime_priority_vol": None, # override runtime da select entity + "tts_buffer": 2.5, # sovrascrivibile da number entity + "text_format": "html", # sovrascrivibile da select entity + "notification_mode": "Normal", # sovrascrivibile da select entity } ############################################################################ @@ -177,32 +231,48 @@ async def voice_queue_worker(): while True: task = await voice_queue.get() try: - players = task['physical_players'] - target_vol = task['target_volume'] + players = task["physical_players"] + target_vol = task["target_volume"] # 1. SNAPSHOT (Solo all'inizio della catena) for eid in players: await _get_player_snapshot(hass, eid) # 2. IMPOSTA VOLUME TARGET if players and target_vol is not None: - await hass.services.async_call("media_player", "volume_set", { - "entity_id": players, "volume_level": target_vol - }) + await hass.services.async_call( + "media_player", + "volume_set", + {"entity_id": players, "volume_level": target_vol}, + ) # 3. ESECUZIONE NOTIFICA - _LOGGER.debug(f"UniNotifier: Elaborazione messaggio vocale: {task['text_content'][:35]}...") - _LOGGER.debug(f"UniNotifier: {task['physical_players']} {task['target_volume']} {task['service']} {task['payload']} ") - await hass.services.async_call(task['domain'], task['service'], task['payload']) + _LOGGER.debug( + f"UniNotifier: Elaborazione messaggio vocale: {task['text_content'][:35]}..." + ) + _LOGGER.debug( + f"UniNotifier: {task['physical_players']} {task['target_volume']} {task['service']} {task['payload']} " + ) + await hass.services.async_call( + task["domain"], task["service"], task["payload"] + ) # 4. ATTESA FINE MESSAGGIO tts_buffer = hass.data[DOMAIN][entry.entry_id].get("tts_buffer", 2.5) - wait_time = estimate_tts_duration(task['text_content'], buffer=tts_buffer) - _LOGGER.debug(f"UniNotifier: Attesa di {wait_time:.2f}s per fine messaggio.") + wait_time = estimate_tts_duration( + task["text_content"], buffer=tts_buffer + ) + _LOGGER.debug( + f"UniNotifier: Attesa di {wait_time:.2f}s per fine messaggio." + ) await asyncio.sleep(wait_time) # 5. CONTROLLO CODA PER RESUME if voice_queue.empty(): - _LOGGER.debug("UniNotifier: Coda vuota, chiusura sessione e ripristino media.") + _LOGGER.debug( + "UniNotifier: Coda vuota, chiusura sessione e ripristino media." + ) for eid in list(_ORIGINAL_STATES.keys()): await _apply_resume(hass, eid, target_vol) else: - _LOGGER.debug(f"UniNotifier: Coda non vuota ({voice_queue.qsize()} messaggi), posticipo resume.") + _LOGGER.debug( + f"UniNotifier: Coda non vuota ({voice_queue.qsize()} messaggi), posticipo resume." + ) except Exception as e: _ORIGINAL_STATES.clear() _LOGGER.error(f"UniNotifier: Errore nel worker vocale: {e}") @@ -234,11 +304,13 @@ async def async_send_notification(call: ServiceCall): runtime_pv = hass.data[DOMAIN][entry.entry_id].get("runtime_priority_vol") priority_volume = call.data.get( CONF_PRIORITY_VOLUME, - runtime_pv if runtime_pv is not None else float(global_priority_vol) + runtime_pv if runtime_pv is not None else float(global_priority_vol), ) is_priority = call.data.get(CONF_PRIORITY, False) use_bold_prefix = call.data.get(CONF_BOLD_PREFIX, global_bold_setting) - ignore_title_voice = call.data.get(CONF_IGNORE_TITLE_VOICE, global_ignore_title_voice) + ignore_title_voice = call.data.get( + CONF_IGNORE_TITLE_VOICE, global_ignore_title_voice + ) # 2. Analisi Contesto now = dt_util.now() now_time = now.time() @@ -247,16 +319,24 @@ async def async_send_notification(call: ServiceCall): time_slots_conf, now_time, now_weekday, weekend_days ) if now_weekday in weekend_days: - active_dnd = dnd_conf.get("weekend", dnd_conf.get("weekday", {"start": "23:00", "end": "06:00"})) + active_dnd = dnd_conf.get( + "weekend", dnd_conf.get("weekday", {"start": "23:00", "end": "06:00"}) + ) else: active_dnd = dnd_conf.get("weekday", {"start": "23:00", "end": "06:00"}) - is_dnd_active = is_time_in_range(active_dnd["start"], active_dnd["end"], now_time) + is_dnd_active = is_time_in_range( + active_dnd["start"], active_dnd["end"], now_time + ) # DND Override: switch entity forces DND on regardless of time ent_reg = er.async_get(hass) dnd_override_uid = f"{DOMAIN}_{entry.entry_id}_{ENTITY_DND_OVERRIDE}" - dnd_override_entry = ent_reg.async_get_entity_id("switch", DOMAIN, dnd_override_uid) - dnd_override_state = hass.states.get(dnd_override_entry) if dnd_override_entry else None + dnd_override_entry = ent_reg.async_get_entity_id( + "switch", DOMAIN, dnd_override_uid + ) + dnd_override_state = ( + hass.states.get(dnd_override_entry) if dnd_override_entry else None + ) if dnd_override_state and dnd_override_state.state == "on": is_dnd_active = True @@ -267,14 +347,18 @@ async def async_send_notification(call: ServiceCall): effective_greetings = base_greetings.copy() for key, value in override_greetings_data.items(): if key in effective_greetings: - if not isinstance(value, list): value = [value] + if not isinstance(value, list): + value = [value] effective_greetings[key] = value options = effective_greetings.get(slot_key, []) - current_greeting = random.choice(options) if options and not skip_greeting else "" + current_greeting = ( + random.choice(options) if options and not skip_greeting else "" + ) raw_name = override_name raw_time_str = now.strftime(global_date_fmt) if include_time else "" - if isinstance(targets, str): targets = [targets] + if isinstance(targets, str): + targets = [targets] tasks = [] @@ -296,16 +380,24 @@ async def async_send_notification(call: ServiceCall): _LOGGER.debug(f"UniNotifier: Sezione A, Specific Data {specific_data}") target_raw_message = specific_data.pop(CONF_MESSAGE, global_raw_message) - dynamic_entities = specific_data.pop(CONF_ENTITY_ID, channel_conf.get(CONF_TARGET)) + dynamic_entities = specific_data.pop( + CONF_ENTITY_ID, channel_conf.get(CONF_TARGET) + ) if isinstance(dynamic_entities, str): - dynamic_entities = [e.strip() for e in dynamic_entities.split(",") if e.strip()] + dynamic_entities = [ + e.strip() for e in dynamic_entities.split(",") if e.strip() + ] elif dynamic_entities is None: dynamic_entities = [] - _LOGGER.debug(f"UniNotifier: Sezione A, Dynamic Entities {dynamic_entities}") + _LOGGER.debug( + f"UniNotifier: Sezione A, Dynamic Entities {dynamic_entities}" + ) #################################################################### # B. Selezione Servizio - service_type = specific_data.pop(CONF_TYPE, runtime_data.get(CONF_TYPE, None)) + service_type = specific_data.pop( + CONF_TYPE, runtime_data.get(CONF_TYPE, None) + ) alt_services_conf = channel_conf.get(CONF_ALT_SERVICES, {}) _LOGGER.debug(f"UniNotifier: Sezione B, Service type {service_type}") _LOGGER.debug(f"UniNotifier: Sezione B, Service alt {alt_services_conf}") @@ -317,26 +409,36 @@ async def async_send_notification(call: ServiceCall): else: full_service_name = channel_conf[CONF_SERVICE] is_voice_channel = channel_conf.get(CONF_IS_VOICE, False) - _LOGGER.debug(f"UniNotifier: Sezione B, Full Service Name {full_service_name}") + _LOGGER.debug( + f"UniNotifier: Sezione B, Full Service Name {full_service_name}" + ) try: srv_domain, srv_name = full_service_name.split(".", 1) except ValueError: - _LOGGER.error(f"UniNotifier: Sezione B, Servizio non valido {full_service_name}") + _LOGGER.error( + f"UniNotifier: Sezione B, Servizio non valido {full_service_name}" + ) continue #################################################################### # C. Check Comandi MOBILE APP is_command_message = False - if target_raw_message in COMPANION_COMMANDS or str(target_raw_message).startswith("command_"): + if target_raw_message in COMPANION_COMMANDS or str( + target_raw_message + ).startswith("command_"): is_command_message = True - _LOGGER.debug(f"UniNotifier: Sezione C, Is Command Message {is_command_message}") + _LOGGER.debug( + f"UniNotifier: Sezione C, Is Command Message {is_command_message}" + ) #################################################################### # D. COSTRUZIONE MESSAGGIO E TITOLO parse_mode = specific_data.get("parse_mode", runtime_data.get("parse_mode")) if not parse_mode and not is_voice_channel: - parse_mode = hass.data[DOMAIN][entry.entry_id].get("text_format", "html") + parse_mode = hass.data[DOMAIN][entry.entry_id].get( + "text_format", "html" + ) parse_mode = normalize_parse_mode(parse_mode, srv_domain) final_msg = "" final_title = global_title @@ -361,25 +463,43 @@ async def async_send_notification(call: ServiceCall): else: 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_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 + 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") + 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 "" + 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_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}" + final_msg = ( + f"{clean_prefix} {greeting_part}{clean_msg}" + if clean_prefix + else f"{greeting_part}{clean_msg}" + ) # Escape MarkdownV2 special chars for Telegram if not is_voice_channel and parse_mode == "markdownv2": @@ -392,8 +512,10 @@ async def async_send_notification(call: ServiceCall): # E. Determinazione del Volume attuale override_volume = specific_data.get("volume", runtime_data.get("volume")) if override_volume is not None: - try: target_volume = float(override_volume) - except ValueError: target_volume = slot_volume + try: + target_volume = float(override_volume) + except ValueError: + target_volume = slot_volume elif is_priority: target_volume = priority_volume else: @@ -403,26 +525,38 @@ async def async_send_notification(call: ServiceCall): # F. Applicazione Volume e Check DND (Solo Canali Voce) if is_voice_channel: if is_dnd_active and not is_priority and override_volume is None: - _LOGGER.debug(f"UniNotifier: Sezione F, Skipped '{target_alias}' (DND attivo)") + _LOGGER.debug( + f"UniNotifier: Sezione F, Skipped '{target_alias}' (DND attivo)" + ) continue #################################################################### # F2. Filtro routing in base a notification_mode # Attivo SOLO se ci sono person entities configurate - notification_mode = hass.data[DOMAIN][entry.entry_id].get("notification_mode", "Normal") + notification_mode = hass.data[DOMAIN][entry.entry_id].get( + "notification_mode", "Normal" + ) if person_entities_conf and notification_mode != "Normal": if notification_mode == "Voice home": family_state = hass.states.get(f"sensor.{DOMAIN}_family") - family_home = family_state is not None and family_state.state == "home" + family_home = ( + family_state is not None and family_state.state == "home" + ) if family_home and not is_voice_channel: - _LOGGER.debug(f"UniNotifier: Sezione F2, Skipped '{target_alias}' (Voice home, family a casa → solo voce)") + _LOGGER.debug( + f"UniNotifier: Sezione F2, Skipped '{target_alias}' (Voice home, family a casa → solo voce)" + ) continue elif not family_home and is_voice_channel: - _LOGGER.debug(f"UniNotifier: Sezione F2, Skipped '{target_alias}' (Voice home, family non a casa → solo testo)") + _LOGGER.debug( + f"UniNotifier: Sezione F2, Skipped '{target_alias}' (Voice home, family non a casa → solo testo)" + ) continue elif notification_mode == "Text home": if is_voice_channel: - _LOGGER.debug(f"UniNotifier: Sezione F2, Skipped '{target_alias}' (Text home mode)") + _LOGGER.debug( + f"UniNotifier: Sezione F2, Skipped '{target_alias}' (Text home mode)" + ) continue #################################################################### @@ -443,7 +577,9 @@ async def async_send_notification(call: ServiceCall): if service_type in ["photo", "video", "animation", "voice", "document"]: service_payload["caption"] = final_title + " " + final_msg service_payload.pop("title", None) - _LOGGER.debug(f"UniNotifier: Sezione G, service_payload '{service_payload}' ") + _LOGGER.debug( + f"UniNotifier: Sezione G, service_payload '{service_payload}' " + ) #################################################################### # H. Routing dei Target nel Payload @@ -452,7 +588,9 @@ async def async_send_notification(call: ServiceCall): # Normalize: always produce list[str] regardless of input type if not isinstance(conf_target_value, list): if isinstance(conf_target_value, str) and "," in conf_target_value: - conf_target_value = [s.strip() for s in conf_target_value.split(",") if s.strip()] + conf_target_value = [ + s.strip() for s in conf_target_value.split(",") if s.strip() + ] else: conf_target_value = [conf_target_value] conf_target_value = [str(x) for x in conf_target_value] @@ -462,23 +600,36 @@ async def async_send_notification(call: ServiceCall): elif srv_domain != "telegram_bot": # Telegram gestito da Sezione J (iterazione per chat_id) service_payload[CONF_TARGET] = conf_target_value - _LOGGER.debug(f"UniNotifier: Sezione H, service_payload '{service_payload}' ") + _LOGGER.debug( + f"UniNotifier: Sezione H, service_payload '{service_payload}' " + ) #################################################################### # I. Merge Dati Accessori (alexa type, telegram images, etc.) all_additional_data = {} - if runtime_data: all_additional_data.update(runtime_data) - if specific_data: all_additional_data.update(specific_data) - for k in ["volume", CONF_TYPE, "parse_mode"]: all_additional_data.pop(k, None) + if runtime_data: + all_additional_data.update(runtime_data) + if specific_data: + all_additional_data.update(specific_data) + for k in ["volume", CONF_TYPE, "parse_mode"]: + all_additional_data.pop(k, None) if all_additional_data: if srv_domain == "notify": - if "data" not in service_payload: service_payload["data"] = {} + if "data" not in service_payload: + service_payload["data"] = {} service_payload["data"].update(all_additional_data) else: service_payload.update(all_additional_data) - _LOGGER.debug(f"UniNotifier: Sezione I, service_payload '{service_payload}' ") - if not is_voice_channel and not is_command_message and srv_domain == "notify" and parse_mode: + _LOGGER.debug( + f"UniNotifier: Sezione I, service_payload '{service_payload}' " + ) + if ( + not is_voice_channel + and not is_command_message + and srv_domain == "notify" + and parse_mode + ): if "data" not in service_payload: service_payload["data"] = {} service_payload["data"].setdefault("parse_mode", parse_mode) @@ -491,17 +642,25 @@ async def async_send_notification(call: ServiceCall): for chat_id in conf_target_value: p = service_payload.copy() p[CONF_CHAT_ID] = int(chat_id) - _LOGGER.debug(f"UniNotifier: Sezione J, Telegram to chat_id={chat_id} payload {p}") + _LOGGER.debug( + f"UniNotifier: Sezione J, Telegram to chat_id={chat_id} payload {p}" + ) tasks.append(hass.services.async_call(srv_domain, srv_name, p)) elif is_voice_channel: default_mp = channel_conf.get(CONF_DEFAULT_MEDIA_PLAYER, "") if srv_domain == "tts": # Per TTS: dynamic_entities contiene il target TTS (tts.xxx), # media_player_entity_id indica dove riprodurre l'audio - tts_media_players = specific_data.pop("media_player_entity_id", None) + tts_media_players = specific_data.pop( + "media_player_entity_id", None + ) if not tts_media_players and dynamic_entities: # entity_id in target_data può contenere media_player da usare - tts_media_players = [e for e in dynamic_entities if isinstance(e, str) and e.startswith("media_player.")] + tts_media_players = [ + e + for e in dynamic_entities + if isinstance(e, str) and e.startswith("media_player.") + ] if not tts_media_players and default_mp: tts_media_players = [default_mp] if tts_media_players: @@ -518,21 +677,33 @@ async def async_send_notification(call: ServiceCall): notify_targets = conf_target_value or [] physical_players.extend(notify_targets) service_payload[CONF_TARGET] = notify_targets - physical_players = [p for p in physical_players if isinstance(p, str) and p.startswith("media_player.")] - _LOGGER.debug(f"UniNotifier: Sezione J, Media players coinvolti {physical_players}.") + physical_players = [ + p + for p in physical_players + if isinstance(p, str) and p.startswith("media_player.") + ] + _LOGGER.debug( + f"UniNotifier: Sezione J, Media players coinvolti {physical_players}." + ) queue_item = { - 'domain': srv_domain, - 'service': srv_name, - 'payload': service_payload, - 'text_content': text_content_for_duration, - 'physical_players': physical_players, - 'target_volume': target_volume + "domain": srv_domain, + "service": srv_name, + "payload": service_payload, + "text_content": text_content_for_duration, + "physical_players": physical_players, + "target_volume": target_volume, } voice_queue.put_nowait(queue_item) - _LOGGER.debug(f"UniNotifier: Sezione J, Messaggio per {target_alias} accodato.") + _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)) + _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 tasks: await asyncio.gather(*tasks) @@ -543,9 +714,12 @@ async def async_send_notification(call: ServiceCall): last_msg_eid = last_msg_entry try: if not last_msg_eid: - raise ValueError(f"Entity with unique_id {last_msg_uid} not found in registry") + raise ValueError( + f"Entity with unique_id {last_msg_uid} not found in registry" + ) await hass.services.async_call( - "text", "set_value", + "text", + "set_value", {"entity_id": last_msg_eid, "value": global_raw_message[:255]}, blocking=True, ) @@ -554,10 +728,12 @@ async def async_send_notification(call: ServiceCall): ############################################################################ # Registrazione servizio e caricamento piattaforme - hass.services.async_register(DOMAIN, "send", async_send_notification, schema=SEND_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, "send", async_send_notification, schema=SEND_SERVICE_SCHEMA + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Ricarica l'entry quando l'utente salva le opzioni entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - return True \ No newline at end of file + return True From 2d00247f844360862feb9d04d0c7d02a205889e4 Mon Sep 17 00:00:00 2001 From: Jumping Date: Sun, 6 Sep 2026 18:44:25 +0200 Subject: [PATCH 3/5] v0.9.0 notify.send_messages support and other fixes --- CHANGELOG.md | 8 +++ .../universal_notifier/__init__.py | 31 +++++++++-- tests/conftest.py | 3 +- tests/test_init.py | 54 +++++++++++++++++++ 4 files changed, 90 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20cf353..7efdb79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.9.0] - 2026-09-06 - PRODUCTION + +### Added +- **`notify.send_message` support**: Channels using the Home Assistant notify entity architecture (`service: notify.send_message` + `target: notify.`) are now dispatched correctly. The target is passed as the `target` kwarg of the service call and the `data` key is no longer injected, both of which `notify.send_message` rejects. + +### Fixed +- **Empty target no longer forwarded as `['']`**: A channel with a blank `target` field no longer overrides the default recipient of `notify.*` services (e.g. SMTP) with an empty list. Empty and whitespace-only entries are now filtered out during target normalization. + ## [0.8.4] - 2026-07-26 - PRODUCTION ### Added diff --git a/custom_components/universal_notifier/__init__.py b/custom_components/universal_notifier/__init__.py index 39d9129..41506ba 100644 --- a/custom_components/universal_notifier/__init__.py +++ b/custom_components/universal_notifier/__init__.py @@ -420,6 +420,10 @@ async def async_send_notification(call: ServiceCall): f"UniNotifier: Sezione B, Servizio non valido {full_service_name}" ) continue + # ADDED: Check if the service is notify.send_message for special handling + is_notify_send_message = ( + srv_domain == "notify" and srv_name == "send_message" + ) #################################################################### # C. Check Comandi MOBILE APP @@ -584,8 +588,9 @@ async def async_send_notification(call: ServiceCall): #################################################################### # H. Routing dei Target nel Payload conf_target_value = channel_conf.get(CONF_TARGET) - if conf_target_value is not None: - # Normalize: always produce list[str] regardless of input type + if conf_target_value: + # Normalize: always produce list[str] regardless of input type, + # dropping empty / whitespace-only entries. if not isinstance(conf_target_value, list): if isinstance(conf_target_value, str) and "," in conf_target_value: conf_target_value = [ @@ -593,7 +598,9 @@ async def async_send_notification(call: ServiceCall): ] else: conf_target_value = [conf_target_value] - conf_target_value = [str(x) for x in conf_target_value] + conf_target_value = [ + str(x).strip() for x in conf_target_value if str(x).strip() + ] if conf_target_value: if srv_domain == "tts": service_payload[ATTR_ENTITY_ID] = conf_target_value @@ -614,7 +621,8 @@ async def async_send_notification(call: ServiceCall): for k in ["volume", CONF_TYPE, "parse_mode"]: all_additional_data.pop(k, None) - if all_additional_data: + # if all_additional_data: + if all_additional_data and not is_notify_send_message: if srv_domain == "notify": if "data" not in service_payload: service_payload["data"] = {} @@ -628,6 +636,7 @@ async def async_send_notification(call: ServiceCall): not is_voice_channel and not is_command_message and srv_domain == "notify" + and not is_notify_send_message and parse_mode ): if "data" not in service_payload: @@ -697,6 +706,19 @@ async def async_send_notification(call: ServiceCall): _LOGGER.debug( f"UniNotifier: Sezione J, Messaggio per {target_alias} accodato." ) + elif is_notify_send_message: + target_entities = service_payload.pop(CONF_TARGET, None) + call_kwargs = {} + if target_entities: + call_kwargs["target"] = {ATTR_ENTITY_ID: target_entities} + _LOGGER.debug( + f"UniNotifier: Sezione J, notify.send_message target={target_entities} payload {service_payload}" + ) + tasks.append( + hass.services.async_call( + srv_domain, srv_name, service_payload, **call_kwargs + ) + ) else: _LOGGER.debug( f"UniNotifier: Sezione J, Final payload {service_payload} - Service data {srv_domain}/{srv_name}" @@ -704,7 +726,6 @@ async def async_send_notification(call: ServiceCall): tasks.append( hass.services.async_call(srv_domain, srv_name, service_payload) ) - if tasks: await asyncio.gather(*tasks) diff --git a/tests/conftest.py b/tests/conftest.py index 65a006b..7db9bf8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -103,7 +103,7 @@ def has_service(self, domain, service=None): return (domain, service) in self._handlers return any(d == domain for d, _ in self._handlers) - async def async_call(self, domain, service, data=None, blocking=True): + async def async_call(self, domain, service, data=None, blocking=True, target=None): key = (domain, service) if key in self._handlers: call = MagicMock() @@ -114,6 +114,7 @@ async def async_call(self, domain, service, data=None, blocking=True): "domain": domain, "service": service, "data": data or {}, + "target": target, }) @property diff --git a/tests/test_init.py b/tests/test_init.py index 9017d7e..6f809cd 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -67,6 +67,60 @@ async def test_multiple_targets( assert "notify" in domains +# ============================================================================ +# Empty target / notify.send_message +# ============================================================================ + +class TestEmptyTarget: + @freeze_time("2026-06-17 10:00:00") + async def test_empty_target_not_forwarded( + self, hass, _call_send, service_calls, setup_integration + ): + """An empty target must not be forwarded as [''].""" + entry = setup_integration + conf = hass.data[DOMAIN][entry.entry_id]["conf"] + original = conf[CONF_CHANNELS]["test_text"][CONF_TARGET] + conf[CONF_CHANNELS]["test_text"][CONF_TARGET] = "" + try: + await _call_send(message="Test", targets=["test_text"]) + notify_calls = [c for c in service_calls if c["domain"] == "notify"] + assert len(notify_calls) >= 1 + assert "target" not in notify_calls[0]["data"] + finally: + conf[CONF_CHANNELS]["test_text"][CONF_TARGET] = original + + +class TestNotifySendMessage: + @freeze_time("2026-06-17 10:00:00") + async def test_notify_send_message_target_kwarg( + self, hass, _call_send, service_calls, setup_integration + ): + """notify.send_message must get target as kwarg, not inside data.""" + entry = setup_integration + conf = hass.data[DOMAIN][entry.entry_id]["conf"] + conf[CONF_CHANNELS]["mail_gianpi"] = { + CONF_SERVICE: "notify.send_message", + CONF_TARGET: "notify.gianpi_homeassistant", + CONF_IS_VOICE: False, + CONF_DEFAULT_MEDIA_PLAYER: "", + } + try: + await _call_send( + message="Prova", title="Casa Smart", targets=["mail_gianpi"] + ) + calls = [ + c for c in service_calls + if c["domain"] == "notify" and c["service"] == "send_message" + ] + assert len(calls) == 1 + assert "target" not in calls[0]["data"] + assert "data" not in calls[0]["data"] + assert calls[0]["data"].get("message") + assert calls[0]["target"] == {"entity_id": ["notify.gianpi_homeassistant"]} + finally: + conf[CONF_CHANNELS].pop("mail_gianpi", None) + + # ============================================================================ # DND logic # ============================================================================ From a6f67c8c8f92e43bf8fa26d36d444c44bcafa516 Mon Sep 17 00:00:00 2001 From: Jumping Date: Sun, 6 Sep 2026 18:47:15 +0200 Subject: [PATCH 4/5] v0.9.0 fixed manifest and pyproject.toml --- custom_components/universal_notifier/manifest.json | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/universal_notifier/manifest.json b/custom_components/universal_notifier/manifest.json index 7751d16..0fcd6ed 100644 --- a/custom_components/universal_notifier/manifest.json +++ b/custom_components/universal_notifier/manifest.json @@ -12,5 +12,5 @@ "issue_tracker": "https://github.com/jumping2000/universal_notifier/issues", "requirements": [], "single_config_entry": true, - "version": "0.8.3" + "version": "0.9.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d169ea7..7ba6a65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "universal-notifier" -version = "0.8.2" +version = "0.9.0" requires-python = ">=3.13" description = "Universal Notifier — Home Assistant Custom Component" From 2961a9ce4875d114c19b7c9c9d7ea32f2965a0c6 Mon Sep 17 00:00:00 2001 From: Jumping Date: Sun, 6 Sep 2026 18:49:23 +0200 Subject: [PATCH 5/5] v0.9.0 fixed manifest and pyproject.toml --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 254042d..6111958 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ a new release from an appdaemon app by @caiosweet and @jumping2000 ![License](https://img.shields.io/github/license/jumping2000/universal_notifier?style=for-the-badge) ![HA integration](https://img.shields.io/badge/Home%20Assistant-Integration-blue?style=for-the-badge) -> **🆕 Latest (v0.8.0):** Weekday/Weekend DND split, comma-separated multi-target support. See the [Changelog](CHANGELOG.md) for details. +> **🆕 Latest (v0.9.0):** notify.send_message support and some fixes. See the [Changelog](CHANGELOG.md) for details. > > [User configuration Guide](USER_GUIDE.md)