290 lines
11 KiB
Python
290 lines
11 KiB
Python
|
|
"""
|
|||
|
|
Notification.py
|
|||
|
|
───────────────
|
|||
|
|
Monitors incoming sensor entries from USBRead and dispatches alerts via:
|
|||
|
|
• SMTP e-mail (plain text + HTML)
|
|||
|
|
• WhatsApp (Twilio Business API OR CallMeBot free API)
|
|||
|
|
|
|||
|
|
Two WhatsApp providers are supported and selected via settings.json:
|
|||
|
|
"provider": "twilio" – requires twilio package + Twilio account
|
|||
|
|
"provider": "callmebot" – free, no extra package needed (HTTP GET)
|
|||
|
|
|
|||
|
|
Usage
|
|||
|
|
-----
|
|||
|
|
import Notification
|
|||
|
|
Notification.init(settings) # pass full settings dict
|
|||
|
|
Notification.attach_to_usb() # registers USBRead callback
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import smtplib
|
|||
|
|
import threading
|
|||
|
|
import time
|
|||
|
|
import urllib.parse
|
|||
|
|
import urllib.request
|
|||
|
|
from datetime import datetime
|
|||
|
|
from email.mime.multipart import MIMEMultipart
|
|||
|
|
from email.mime.text import MIMEText
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
import USBRead
|
|||
|
|
|
|||
|
|
# ── Module-level state ────────────────────────────────────────────────────────
|
|||
|
|
_smtp_cfg: dict[str, Any] = {}
|
|||
|
|
_whatsapp_cfg: dict[str, Any] = {}
|
|||
|
|
_sensors: list[dict[str, Any]] = []
|
|||
|
|
_smtp_cooldown_s: float = 300.0
|
|||
|
|
_wa_cooldown_s: float = 300.0
|
|||
|
|
|
|||
|
|
# cooldown trackers: sensor_name → monotonic time of last sent alert
|
|||
|
|
_last_email: dict[str, float] = {}
|
|||
|
|
_last_whatsapp: dict[str, float] = {}
|
|||
|
|
_lock = threading.Lock()
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Public API ────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def init(settings: dict[str, Any]) -> None:
|
|||
|
|
"""Initialise with the full settings dict loaded from settings.json."""
|
|||
|
|
global _smtp_cfg, _whatsapp_cfg, _sensors, _smtp_cooldown_s, _wa_cooldown_s
|
|||
|
|
|
|||
|
|
_smtp_cfg = settings.get("smtp", {})
|
|||
|
|
_whatsapp_cfg = settings.get("whatsapp", {})
|
|||
|
|
_sensors = settings.get("sensors", [])
|
|||
|
|
|
|||
|
|
_smtp_cooldown_s = float(_smtp_cfg.get("cooldown_s", 300))
|
|||
|
|
_wa_cooldown_s = float(_whatsapp_cfg.get("cooldown_s", 300))
|
|||
|
|
|
|||
|
|
channels = []
|
|||
|
|
if _smtp_cfg.get("enabled", True):
|
|||
|
|
channels.append("SMTP")
|
|||
|
|
if _whatsapp_cfg.get("enabled", False):
|
|||
|
|
channels.append(f"WhatsApp/{_whatsapp_cfg.get('provider','?')}")
|
|||
|
|
|
|||
|
|
print(f"[Notification] {len(_sensors)} Sensor-Regeln geladen. "
|
|||
|
|
f"Kanäle: {', '.join(channels) or '–'}. "
|
|||
|
|
f"Cooldown E-Mail={_smtp_cooldown_s}s WA={_wa_cooldown_s}s")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def attach_to_usb() -> None:
|
|||
|
|
"""Register the threshold checker as a USBRead callback."""
|
|||
|
|
USBRead.register_callback(_on_entry)
|
|||
|
|
print("[Notification] An USBRead-Callback angehängt.")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def send_alert(subject: str, body: str) -> None:
|
|||
|
|
"""
|
|||
|
|
Dispatch an alert on all enabled channels synchronously.
|
|||
|
|
Call this from a daemon thread to avoid blocking USBRead.
|
|||
|
|
"""
|
|||
|
|
if _smtp_cfg.get("enabled", True):
|
|||
|
|
_send_email(subject, body)
|
|||
|
|
|
|||
|
|
if _whatsapp_cfg.get("enabled", False):
|
|||
|
|
short = f"{subject}\n\n{body[:400]}" # WA messages kept compact
|
|||
|
|
_send_whatsapp(short)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Threshold checker (USBRead callback) ──────────────────────────────────────
|
|||
|
|
|
|||
|
|
def _on_entry(entry: dict[str, Any]) -> None:
|
|||
|
|
values = entry.get("values", [])
|
|||
|
|
if not values:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
now = time.monotonic()
|
|||
|
|
|
|||
|
|
for sensor in _sensors:
|
|||
|
|
idx = sensor.get("field_index", 0)
|
|||
|
|
if idx >= len(values):
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
value = values[idx]
|
|||
|
|
name = sensor.get("name", f"Sensor {idx}")
|
|||
|
|
unit = sensor.get("unit", "")
|
|||
|
|
th_high = sensor.get("threshold_high")
|
|||
|
|
th_low = sensor.get("threshold_low")
|
|||
|
|
|
|||
|
|
triggered = False
|
|||
|
|
direction = ""
|
|||
|
|
threshold = None
|
|||
|
|
|
|||
|
|
if sensor.get("notify_on_high", True) and th_high is not None and value > th_high:
|
|||
|
|
triggered, direction, threshold = True, "HOCH", th_high
|
|||
|
|
elif sensor.get("notify_on_low", True) and th_low is not None and value < th_low:
|
|||
|
|
triggered, direction, threshold = True, "NIEDRIG", th_low
|
|||
|
|
|
|||
|
|
if not triggered:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
subject = f"[Arduino Alert] {name} zu {direction}: {value:.2f}{unit}"
|
|||
|
|
body = (
|
|||
|
|
f"Sensor-Alarm – {entry['ts']}\n\n"
|
|||
|
|
f" Sensor : {name}\n"
|
|||
|
|
f" Messwert : {value:.4f} {unit}\n"
|
|||
|
|
f" Schwelle : {threshold} {unit} ({direction})\n"
|
|||
|
|
f" Rohzeile : {entry['raw']}\n\n"
|
|||
|
|
f"Nächste Benachrichtigung frühestens nach {int(max(_smtp_cooldown_s, _wa_cooldown_s))}s."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Per-channel cooldown check + fire
|
|||
|
|
with _lock:
|
|||
|
|
send_email_now = _check_cooldown(_last_email, name, now, _smtp_cooldown_s)
|
|||
|
|
send_whatsapp_now = _check_cooldown(_last_whatsapp, name, now, _wa_cooldown_s)
|
|||
|
|
|
|||
|
|
if send_email_now or send_whatsapp_now:
|
|||
|
|
threading.Thread(
|
|||
|
|
target=_dispatch,
|
|||
|
|
args=(subject, body, send_email_now, send_whatsapp_now),
|
|||
|
|
daemon=True
|
|||
|
|
).start()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _check_cooldown(tracker: dict, name: str, now: float, cooldown: float) -> bool:
|
|||
|
|
"""Return True and update tracker if cooldown has elapsed. NOT thread-safe alone."""
|
|||
|
|
last = tracker.get(name, 0.0)
|
|||
|
|
if now - last >= cooldown:
|
|||
|
|
tracker[name] = now
|
|||
|
|
return True
|
|||
|
|
remaining = int(cooldown - (now - last))
|
|||
|
|
print(f"[Notification] Cooldown aktiv für '{name}' (noch {remaining}s)")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _dispatch(subject: str, body: str, do_email: bool, do_whatsapp: bool) -> None:
|
|||
|
|
if do_email and _smtp_cfg.get("enabled", True):
|
|||
|
|
_send_email(subject, body)
|
|||
|
|
if do_whatsapp and _whatsapp_cfg.get("enabled", False):
|
|||
|
|
_send_whatsapp(f"{subject}\n\n{body[:400]}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── E-Mail ────────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def _send_email(subject: str, body: str) -> None:
|
|||
|
|
cfg = _smtp_cfg
|
|||
|
|
msg = MIMEMultipart("alternative")
|
|||
|
|
msg["Subject"] = subject
|
|||
|
|
msg["From"] = cfg["from_address"]
|
|||
|
|
msg["To"] = ", ".join(cfg["to_addresses"])
|
|||
|
|
msg.attach(MIMEText(body, "plain", "utf-8"))
|
|||
|
|
msg.attach(MIMEText(_build_html(subject, body), "html", "utf-8"))
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
if cfg.get("use_tls", True):
|
|||
|
|
srv = smtplib.SMTP(cfg["host"], cfg["port"], timeout=10)
|
|||
|
|
srv.ehlo(); srv.starttls()
|
|||
|
|
else:
|
|||
|
|
srv = smtplib.SMTP_SSL(cfg["host"], cfg["port"], timeout=10)
|
|||
|
|
|
|||
|
|
srv.login(cfg["username"], cfg["password"])
|
|||
|
|
srv.sendmail(cfg["from_address"], cfg["to_addresses"], msg.as_string())
|
|||
|
|
srv.quit()
|
|||
|
|
print(f"[Notification] ✉ E-Mail gesendet: {subject}")
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[Notification] ✗ SMTP Fehler: {exc}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── WhatsApp ──────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def _send_whatsapp(text: str) -> None:
|
|||
|
|
provider = _whatsapp_cfg.get("provider", "twilio").lower()
|
|||
|
|
|
|||
|
|
if provider == "twilio":
|
|||
|
|
_send_whatsapp_twilio(text)
|
|||
|
|
elif provider == "callmebot":
|
|||
|
|
_send_whatsapp_callmebot(text)
|
|||
|
|
else:
|
|||
|
|
print(f"[Notification] ✗ Unbekannter WhatsApp-Provider: '{provider}'")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _send_whatsapp_twilio(text: str) -> None:
|
|||
|
|
"""
|
|||
|
|
Send via Twilio WhatsApp API.
|
|||
|
|
Requires: pip install twilio
|
|||
|
|
Sandbox: Join https://www.twilio.com/console/sms/whatsapp/sandbox first.
|
|||
|
|
"""
|
|||
|
|
cfg = _whatsapp_cfg.get("twilio", {})
|
|||
|
|
try:
|
|||
|
|
from twilio.rest import Client # type: ignore
|
|||
|
|
client = Client(cfg["account_sid"], cfg["auth_token"])
|
|||
|
|
for to in cfg.get("to_numbers", []):
|
|||
|
|
msg = client.messages.create(
|
|||
|
|
from_=cfg["from_number"],
|
|||
|
|
to=to,
|
|||
|
|
body=text,
|
|||
|
|
)
|
|||
|
|
print(f"[Notification] 📱 Twilio WA gesendet → {to} SID={msg.sid}")
|
|||
|
|
except ImportError:
|
|||
|
|
print("[Notification] ✗ Twilio nicht installiert: pip install twilio")
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[Notification] ✗ Twilio Fehler: {exc}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _send_whatsapp_callmebot(text: str) -> None:
|
|||
|
|
"""
|
|||
|
|
Send via CallMeBot (free, no account needed beyond one-time activation).
|
|||
|
|
Activate at: https://www.callmebot.com/blog/free-api-whatsapp-messages/
|
|||
|
|
No extra packages required.
|
|||
|
|
"""
|
|||
|
|
cfg = _whatsapp_cfg.get("callmebot", {})
|
|||
|
|
api_key = cfg.get("api_key", "")
|
|||
|
|
encoded = urllib.parse.quote(text)
|
|||
|
|
|
|||
|
|
for number in cfg.get("to_numbers", []):
|
|||
|
|
url = (
|
|||
|
|
f"https://api.callmebot.com/whatsapp.php"
|
|||
|
|
f"?phone={urllib.parse.quote(number)}"
|
|||
|
|
f"&text={encoded}"
|
|||
|
|
f"&apikey={api_key}"
|
|||
|
|
)
|
|||
|
|
try:
|
|||
|
|
with urllib.request.urlopen(url, timeout=10) as resp:
|
|||
|
|
status_code = resp.status
|
|||
|
|
print(f"[Notification] 📱 CallMeBot WA → {number} HTTP {status_code}")
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[Notification] ✗ CallMeBot Fehler ({number}): {exc}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── HTML e-mail builder ───────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def _build_html(subject: str, body: str) -> str:
|
|||
|
|
rows = ""
|
|||
|
|
for line in body.splitlines():
|
|||
|
|
line = line.strip()
|
|||
|
|
if not line:
|
|||
|
|
rows += "<tr><td colspan='2' style='padding:5px 0'></td></tr>"
|
|||
|
|
elif ":" in line:
|
|||
|
|
k, _, v = line.partition(":")
|
|||
|
|
rows += (
|
|||
|
|
f"<tr>"
|
|||
|
|
f"<td style='padding:4px 14px 4px 0;color:#888;white-space:nowrap;"
|
|||
|
|
f"font-size:12px'>{k.strip()}</td>"
|
|||
|
|
f"<td style='padding:4px 0;font-family:monospace;font-size:12px'>"
|
|||
|
|
f"{v.strip()}</td></tr>"
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
rows += (
|
|||
|
|
f"<tr><td colspan='2' style='padding:4px 0;color:#ccc;"
|
|||
|
|
f"font-size:13px'>{line}</td></tr>"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return f"""<!DOCTYPE html>
|
|||
|
|
<html><head><meta charset="UTF-8"/></head>
|
|||
|
|
<body style="margin:0;padding:0;background:#080b10;font-family:'Segoe UI',sans-serif;color:#c8d6e5">
|
|||
|
|
<table width="100%" cellpadding="0" cellspacing="0"
|
|||
|
|
style="max-width:520px;margin:40px auto">
|
|||
|
|
<tr>
|
|||
|
|
<td style="background:#0f1318;border:1px solid rgba(255,255,255,.08);
|
|||
|
|
border-radius:10px;padding:32px">
|
|||
|
|
<p style="margin:0 0 6px;font-size:10px;letter-spacing:.22em;
|
|||
|
|
text-transform:uppercase;color:#4a5568">Smart Mirror · Arduino Alert</p>
|
|||
|
|
<h1 style="margin:0 0 22px;font-size:17px;color:#e2e8f0;font-weight:600;
|
|||
|
|
line-height:1.3">{subject}</h1>
|
|||
|
|
<table cellpadding="0" cellspacing="0" style="width:100%">{rows}</table>
|
|||
|
|
<p style="margin:24px 0 0;font-size:10px;color:#4a5568">
|
|||
|
|
Automatisch generiert · bitte nicht antworten.
|
|||
|
|
</p>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
</table>
|
|||
|
|
</body></html>"""
|