import asyncio import json import secrets import shutil import smtplib import sys import string import subprocess import time from email.message import EmailMessage from pathlib import Path import aiohttp from . import paths from .profiles import load_yaml, write_text CONFIG_PATH = paths.BASE_DIR / "notifications.yaml" SEND_TIMEOUT = 15 CONFIG_TEMPLATE = """# Notification channels for solixauto. # # Secrets live here, NOT in your power profiles, so profiles stay safe to # share or commit. This file is created with owner-only permissions. # # Enable a channel by setting enabled: true and filling in its settings. # Test with: # solixauto notify-test # solixauto notify-test --channel ntfy # # --------------------------------------------------------------------- # ntfy - recommended. Free, no account needed. # 1. install the ntfy app on your phone # 2. subscribe to a topic name that nobody else would guess # 3. put that topic below # Anyone who knows the topic name can read your alerts, so make it long. # --------------------------------------------------------------------- ntfy: enabled: false server: https://ntfy.sh topic: solix-CHANGE-ME-to-something-random priority: default token: "" # --------------------------------------------------------------------- # Pushover - $5 one time per platform. Very reliable delivery. # Get both keys from https://pushover.net # --------------------------------------------------------------------- pushover: enabled: false user_key: "" api_token: "" priority: 0 sound: "" # --------------------------------------------------------------------- # Email over SMTP. # For Gmail you must use an App Password, not your normal password. # --------------------------------------------------------------------- email: enabled: false host: smtp.gmail.com port: 587 use_tls: true username: "" password: "" sender: "" recipients: [] # --------------------------------------------------------------------- # Telegram - free. Create a bot with @BotFather, then message it once and # read your chat id from https://api.telegram.org/bot/getUpdates # --------------------------------------------------------------------- telegram: enabled: false bot_token: "" chat_id: "" # --------------------------------------------------------------------- # Generic webhook. Works with Slack and Discord incoming webhooks. # format: slack | discord | json | form # --------------------------------------------------------------------- webhook: enabled: false url: "" format: json method: POST # --------------------------------------------------------------------- # Desktop notification on the machine running the engine. # Useful while testing. Does not reach your phone. # # macOS uses osascript, built in # Linux uses notify-send, from libnotify-bin # Windows uses PowerShell, built in # # sound is macOS only and ignored elsewhere. # --------------------------------------------------------------------- desktop: enabled: false sound: Submarine """ class SafeDict(dict): def __missing__(self, key): return "?" class TemplateFormatter(string.Formatter): def get_value(self, key, args, kwargs): if isinstance(key, str): return kwargs.get(key, "?") return "?" def format_field(self, value, format_spec): try: return super().format_field(value, format_spec) except (TypeError, ValueError): return str(value) FORMATTER = TemplateFormatter() def render(template, context): try: return FORMATTER.vformat(str(template), (), SafeDict(context)) except Exception: return str(template) def template_fields(template): found = set() try: for _, field, _, _ in string.Formatter().parse(str(template)): if field: found.add(field.split(".")[0].split("[")[0]) except ValueError: pass return found def ensure_config(): paths.ensure_dirs() if not CONFIG_PATH.exists(): write_text(CONFIG_PATH, CONFIG_TEMPLATE) paths.secure_file(CONFIG_PATH) return CONFIG_PATH def set_value(text, channel, key, value): lines = text.splitlines() output = [] inside = False replaced = False if isinstance(value, bool): rendered = "true" if value else "false" elif isinstance(value, list): rendered = "[" + ", ".join(str(v) for v in value) + "]" elif value == "": rendered = '""' else: rendered = str(value) for line in lines: stripped = line.strip() if not line.startswith((" ", "\t")) and stripped.endswith(":"): if inside and not replaced: pass inside = stripped[:-1] == channel if inside and not replaced: without_indent = line.lstrip() if without_indent.startswith(f"{key}:"): indent = line[: len(line) - len(without_indent)] output.append(f"{indent}{key}: {rendered}") replaced = True continue output.append(line) return "\n".join(output) + "\n", replaced def apply_settings(channel, values): ensure_config() text = CONFIG_PATH.read_text(encoding="utf-8") missing = [] for key, value in values.items(): text, replaced = set_value(text, channel, key, value) if not replaced: missing.append(key) CONFIG_PATH.write_text(text, encoding="utf-8") paths.secure_file(CONFIG_PATH) return missing def generate_topic(prefix="solix"): return f"{prefix}-{secrets.token_hex(10)}" NTFY_IOS_URL = "https://apps.apple.com/us/app/ntfy/id1625396347" NTFY_ANDROID_URL = "https://play.google.com/store/apps/details?id=io.heckel.ntfy" NTFY_FDROID_URL = "https://f-droid.org/en/packages/io.heckel.ntfy/" NTFY_DOCS_URL = "https://docs.ntfy.sh/subscribe/phone/" def subscribe_url(topic, server="https://ntfy.sh"): return f"{server.rstrip('/')}/{topic}" def _can_encode(sample): encoding = getattr(sys.stdout, "encoding", None) or "" if not encoding: return False try: sample.encode(encoding) except (UnicodeEncodeError, LookupError): return False return True def render_qr(data, border=2, dark_terminal=True): try: import qrcode except ImportError: return None, "qrcode package not installed" try: code = qrcode.QRCode(border=border) code.add_data(data) code.make(fit=True) matrix = code.get_matrix() except Exception as err: return None, f"{type(err).__name__}: {err}" if not _can_encode("\u2588\u2580\u2584"): return None, "this console cannot render block characters" def ink(value): return (not value) if dark_terminal else value lines = [] for index in range(0, len(matrix), 2): top = matrix[index] bottom = matrix[index + 1] if index + 1 < len(matrix) else [False] * len(top) row = [] for upper, lower in zip(top, bottom): upper_on = ink(upper) lower_on = ink(lower) if upper_on and lower_on: row.append("\u2588") elif upper_on: row.append("\u2580") elif lower_on: row.append("\u2584") else: row.append(" ") lines.append("".join(row)) if dark_terminal: width = len(lines[0]) if lines else 0 pad = "\u2588" * width lines = [pad] + lines + [pad] return "\n".join(lines), None def show_qr(data, label=None, indent=" ", dark_terminal=True): art, problem = render_qr(data, dark_terminal=dark_terminal) if label: print(f"{indent}{label}") print() if art is None: print(f"{indent}[QR unavailable: {problem}]") print(f"{indent}Open this link on your phone instead:") print(f"{indent}{data}") return False try: for line in art.splitlines(): print(f"{indent}{line}") except UnicodeEncodeError: print(f"{indent}[QR unavailable: console encoding]") print(f"{indent}{data}") return False print() print(f"{indent}{data}") return True def load_config(): if not CONFIG_PATH.exists(): return {} try: return load_yaml(CONFIG_PATH) except Exception: return {} def enabled_channels(config=None): config = config if config is not None else load_config() return sorted( name for name, settings in config.items() if isinstance(settings, dict) and settings.get("enabled") ) async def _send_ntfy(session, settings, title, body, priority): server = str(settings.get("server") or "https://ntfy.sh").rstrip("/") topic = settings.get("topic") if not topic: raise ValueError("ntfy.topic is not set") headers = {"Title": title} level = priority or settings.get("priority") if level: headers["Priority"] = str(level) token = settings.get("token") if token: headers["Authorization"] = f"Bearer {token}" async with session.post( f"{server}/{topic}", data=body.encode("utf-8"), headers=headers, timeout=aiohttp.ClientTimeout(total=SEND_TIMEOUT), ) as response: if response.status >= 300: raise RuntimeError(f"HTTP {response.status}") async def _send_pushover(session, settings, title, body, priority): user_key = settings.get("user_key") api_token = settings.get("api_token") if not user_key or not api_token: raise ValueError("pushover.user_key and pushover.api_token are required") payload = { "token": api_token, "user": user_key, "title": title, "message": body, "priority": int(priority if priority is not None else settings.get("priority", 0)), } if settings.get("sound"): payload["sound"] = settings["sound"] if payload["priority"] == 2: payload["retry"] = 60 payload["expire"] = 3600 async with session.post( "https://api.pushover.net/1/messages.json", data=payload, timeout=aiohttp.ClientTimeout(total=SEND_TIMEOUT), ) as response: if response.status >= 300: raise RuntimeError(f"HTTP {response.status}: {await response.text()}") async def _send_telegram(session, settings, title, body, priority): token = settings.get("bot_token") chat_id = settings.get("chat_id") if not token or not chat_id: raise ValueError("telegram.bot_token and telegram.chat_id are required") async with session.post( f"https://api.telegram.org/bot{token}/sendMessage", json={"chat_id": str(chat_id), "text": f"{title}\n{body}"}, timeout=aiohttp.ClientTimeout(total=SEND_TIMEOUT), ) as response: if response.status >= 300: raise RuntimeError(f"HTTP {response.status}: {await response.text()}") async def _send_webhook(session, settings, title, body, priority): url = settings.get("url") if not url: raise ValueError("webhook.url is not set") style = str(settings.get("format") or "json").lower() method = str(settings.get("method") or "POST").upper() kwargs = {"timeout": aiohttp.ClientTimeout(total=SEND_TIMEOUT)} if style == "slack": kwargs["json"] = {"text": f"*{title}*\n{body}"} elif style == "discord": kwargs["json"] = {"content": f"**{title}**\n{body}"} elif style == "form": kwargs["data"] = {"title": title, "message": body} else: kwargs["json"] = {"title": title, "message": body, "priority": priority} async with session.request(method, url, **kwargs) as response: if response.status >= 300: raise RuntimeError(f"HTTP {response.status}") def _send_email_blocking(settings, title, body): recipients = settings.get("recipients") or [] if isinstance(recipients, str): recipients = [recipients] sender = settings.get("sender") or settings.get("username") if not recipients or not sender: raise ValueError("email.sender and email.recipients are required") message = EmailMessage() message["Subject"] = title message["From"] = sender message["To"] = ", ".join(recipients) message.set_content(body) host = settings.get("host") or "localhost" port = int(settings.get("port") or 587) if int(port) == 465: server = smtplib.SMTP_SSL(host, port, timeout=SEND_TIMEOUT) else: server = smtplib.SMTP(host, port, timeout=SEND_TIMEOUT) try: if int(port) != 465 and settings.get("use_tls", True): server.starttls() if settings.get("username") and settings.get("password"): server.login(settings["username"], settings["password"]) server.send_message(message) finally: try: server.quit() except Exception: pass async def _send_email(session, settings, title, body, priority): await asyncio.get_running_loop().run_in_executor( None, _send_email_blocking, settings, title, body ) def desktop_backend(): if sys.platform == "darwin": return "osascript" if shutil.which("osascript") else None if sys.platform.startswith("win"): for candidate in ("powershell", "pwsh"): if shutil.which(candidate): return candidate return None for candidate in ("notify-send", "kdialog", "zenity"): if shutil.which(candidate): return candidate return None def _desktop_macos(settings, title, body): escaped_body = body.replace("\\", "\\\\").replace('"', '\\"') escaped_title = title.replace("\\", "\\\\").replace('"', '\\"') script = f'display notification "{escaped_body}" with title "{escaped_title}"' if settings.get("sound"): sound = str(settings["sound"]).replace('"', "") script += f' sound name "{sound}"' return ["osascript", "-e", script] def _desktop_windows(executable, title, body): safe_title = title.replace("'", "''") safe_body = body.replace("'", "''") script = ( "[reflection.assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null; " "[reflection.assembly]::LoadWithPartialName('System.Drawing') | Out-Null; " "$n = New-Object System.Windows.Forms.NotifyIcon; " "$n.Icon = [System.Drawing.SystemIcons]::Information; " "$n.Visible = $true; " f"$n.ShowBalloonTip(10000, '{safe_title}', '{safe_body}', " "[System.Windows.Forms.ToolTipIcon]::Info); " "Start-Sleep -Seconds 6; " "$n.Dispose()" ) return [ executable, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script, ] def _desktop_linux(backend, title, body): if backend == "notify-send": return ["notify-send", title, body] if backend == "kdialog": return ["kdialog", "--title", title, "--passivepopup", body, "10"] return ["zenity", "--notification", "--text", f"{title}\n{body}"] def _send_desktop_blocking(settings, title, body): backend = desktop_backend() if backend is None: if sys.platform.startswith("linux"): raise RuntimeError( "no desktop notifier found. Install libnotify-bin " "(apt install libnotify-bin) or use a push channel instead." ) raise RuntimeError(f"no desktop notifier available on {sys.platform}") if backend == "osascript": command = _desktop_macos(settings, title, body) elif backend in ("powershell", "pwsh"): command = _desktop_windows(backend, title, body) else: command = _desktop_linux(backend, title, body) result = subprocess.run( command, capture_output=True, text=True, timeout=SEND_TIMEOUT ) if result.returncode != 0: raise RuntimeError(result.stderr.strip() or f"{backend} failed") async def _send_desktop(session, settings, title, body, priority): await asyncio.get_running_loop().run_in_executor( None, _send_desktop_blocking, settings, title, body ) SENDERS = { "ntfy": _send_ntfy, "pushover": _send_pushover, "telegram": _send_telegram, "webhook": _send_webhook, "email": _send_email, "desktop": _send_desktop, "macos": _send_desktop, } class Notifier: def __init__(self, settings, reporter=None, dry_run=False): self.settings = settings self.reporter = reporter or (lambda message: None) self.dry_run = dry_run self.config = load_config() self._last_sent = {} self._last_message = {} def available(self): configured = enabled_channels(self.config) wanted = self.settings.channels if not wanted: return configured return [name for name in wanted if name in configured] def missing(self): configured = set(enabled_channels(self.config)) return [name for name in (self.settings.channels or []) if name not in configured] def throttled(self, key, message, now): window = self.settings.throttle or 0 last_at = self._last_sent.get(key) if self._last_message.get(key) == message and last_at and window: if now - last_at < window: return f"identical message within {int(window)}s" if last_at and window and now - last_at < window: return f"throttled, {int(window - (now - last_at))}s remaining" return None async def send(self, title, body, priority=None, key="default", force=False): channels = self.available() if not channels: return False now = time.monotonic() if not force: blocked = self.throttled(key, body, now) if blocked: self.reporter(f"notification suppressed: {blocked}") return False if self.dry_run: self.reporter(f"DRY RUN would notify via {', '.join(channels)}: {body}") self._last_sent[key] = now self._last_message[key] = body return True sent = 0 async with aiohttp.ClientSession() as session: for name in channels: sender = SENDERS.get(name) if sender is None: continue try: await sender(session, self.config.get(name) or {}, title, body, priority) sent += 1 except Exception as err: self.reporter( f"notification via {name} failed: {type(err).__name__}: {err}" ) if sent: self._last_sent[key] = now self._last_message[key] = body self.reporter(f"notified via {sent} channel(s)") return sent > 0 async def send_test(channel=None, message=None): ensure_config() config = load_config() available = enabled_channels(config) if channel: if channel not in SENDERS: raise ValueError(f"unknown channel {channel!r}. Known: {sorted(SENDERS)}") if channel not in available: raise ValueError( f"channel {channel!r} is not enabled in {paths.relative(CONFIG_PATH)}" ) available = [channel] if not available: raise ValueError( f"no channels enabled. Edit {paths.relative(CONFIG_PATH)} and set " "enabled: true on at least one." ) title = "solixauto test" body = message or "Test notification. If you can read this, the channel works." results = {} async with aiohttp.ClientSession() as session: for name in available: try: await SENDERS[name](session, config.get(name) or {}, title, body, None) results[name] = "ok" except Exception as err: results[name] = f"{type(err).__name__}: {err}" return results