From 3ac4ab2c38806bc3dc565baa12040cb193d7bad5 Mon Sep 17 00:00:00 2001 From: Michael Saba Date: Thu, 24 Sep 2026 23:42:03 -0400 Subject: [PATCH 1/2] fix(eero): don't show eero's 'Unassigned' profile as a device owner Devices outside any eero profile report the profile name 'Unassigned', which showed up in explanations as if it were a person. Co-Authored-By: Claude Opus 5.5 --- hogwatch/eero.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hogwatch/eero.py b/hogwatch/eero.py index a004f61..74b08db 100644 --- a/hogwatch/eero.py +++ b/hogwatch/eero.py @@ -235,6 +235,8 @@ def parse_device(d: dict) -> dict: f"{manufacturer or 'Unknown device'} ({mac[-5:]})") usage = d.get("usage") or {} profile = d.get("profile") or {} + if isinstance(profile, dict) and (profile.get("name") or "").strip().lower() in ("", "unassigned"): + profile = {} # eero's placeholder for "no profile", not a person wired = d.get("connection_type") == "wired" or d.get("wireless") is False return { "mac": mac, From be7f9aa48632fb412fc167298b9f760c2e1769ae Mon Sep 17 00:00:00 2001 From: Michael Saba Date: Thu, 24 Sep 2026 23:42:03 -0400 Subject: [PATCH 2/2] feat: daily email report of slowdowns and dropouts with their reasons - Dashboard card: recipients, sending account, app password, daily on/off, Send report now, and Preview report. - Sent once a day at startup (or 8 AM if already running) through the user's own SMTP account, TLS only. The password is DPAPI-encrypted and never returned by the API. - send-report CLI (with --preview). - Dashboard server now only answers Host 127.0.0.1/localhost (blocks DNS rebinding), and every POST needs the X-HogWatch header. Co-Authored-By: Claude Opus 5.5 --- README.md | 23 ++- hogwatch/__main__.py | 36 +++- hogwatch/report.py | 402 +++++++++++++++++++++++++++++++++++++ hogwatch/secret.py | 70 +++++++ hogwatch/static/app.js | 84 ++++++++ hogwatch/static/index.html | 38 ++++ hogwatch/static/style.css | 19 ++ hogwatch/web.py | 62 +++++- tests/test_report.py | 258 ++++++++++++++++++++++++ 9 files changed, 983 insertions(+), 9 deletions(-) create mode 100644 hogwatch/report.py create mode 100644 hogwatch/secret.py create mode 100644 tests/test_report.py diff --git a/README.md b/README.md index 658a19c..fdaa56e 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,25 @@ that **share your wireless link** are marked. The same card lists any eero that lost its connection and reconnected, which shows drops even when they happen between pings. +## Daily email report + +The dashboard's **Daily email report** card emails you every slowdown and dropout +since the last report, with its reason, the game you were playing, and who was busy +on the network. It's useful for tracking a problem over days, or for showing it to +whoever owns the eero, or to AT&T. + +- **When:** once a day when HogWatch starts, or at 8 AM if it's already running, + plus **Send report now** anytime. **Preview report** shows it without sending. +- **Sending account:** mail goes out through your own email account. For Gmail, + create an [App Password](https://myaccount.google.com/apppasswords) (needs 2-Step + Verification). It's a separate password that can only send mail and can be revoked + on its own. HogWatch stores it encrypted with Windows DPAPI in `data\email.json`, + so only your Windows account can read it, and the dashboard never sends it back. +- **Other providers:** open "Not using Gmail?" and enter your provider's server and port. + HogWatch only sends over an encrypted connection. +- **From a terminal:** `.venv\Scripts\python.exe -m hogwatch send-report` sends one now; + add `--preview` to save it as `data\report_preview.html` instead. + ## Reading the results - **"Internet crawled"**: the line was full. Look at "Biggest user". An **upload** @@ -93,5 +112,7 @@ Settings › eero Labs › **Optimize for Conferencing and Gaming**. - Per-program data comes from the `Microsoft-Windows-Kernel-Network` ETW provider (TCP + UDP, IPv4 + IPv6). Only internet traffic is counted per program; traffic to devices at home is left out. - The dashboard has no external scripts, so it keeps working while the internet is down. -- Command line: `.venv\Scripts\python.exe -m hogwatch run | eero-login | eero-check | selftest` +- The dashboard only answers requests addressed to `127.0.0.1` or `localhost` (blocks DNS + rebinding), and every change needs a custom header that other websites can't send. +- Command line: `.venv\Scripts\python.exe -m hogwatch run | eero-login | eero-check | selftest | send-report` - Tests: `.venv\Scripts\python.exe -m unittest discover -s tests` diff --git a/hogwatch/__main__.py b/hogwatch/__main__.py index 51e218d..355d4aa 100644 --- a/hogwatch/__main__.py +++ b/hogwatch/__main__.py @@ -4,6 +4,7 @@ python -m hogwatch eero-login connect to the eero (one time) python -m hogwatch eero-check show what the eero reports right now python -m hogwatch selftest 15-second check that everything works + python -m hogwatch send-report email the last 24 hours now (--preview: save it as HTML instead) """ from __future__ import annotations @@ -45,6 +46,7 @@ def cmd_run(args) -> int: """Start everything and serve the dashboard until stopped.""" from .collector import Collector from .db import DB + from .report import EmailSettings, Reporter from .web import make_server _setup_logging() @@ -54,8 +56,9 @@ def cmd_run(args) -> int: done = threading.Event() collector_ref: dict = {} db_ref: dict = {} + reporter_ref: dict = {} try: - server = make_server(cfg["port"], collector_ref, db_ref, on_shutdown=done.set) + server = make_server(cfg["port"], collector_ref, db_ref, on_shutdown=done.set, reporter_ref=reporter_ref) except OSError: log.info("HogWatch is already running -- opening the dashboard") if not args.no_browser: @@ -70,6 +73,10 @@ def cmd_run(args) -> int: collector = Collector(cfg, db) collector.start() collector_ref["c"] = collector + # Daily email report (does nothing until an address and app password are saved). + reporter = Reporter(db, EmailSettings(DATA_DIR / "email.json"), stopping=done) + reporter_ref["r"] = reporter + threading.Thread(target=reporter.loop, name="report", daemon=True).start() if not args.no_browser: webbrowser.open(url) try: @@ -194,6 +201,28 @@ def out(s=""): return 0 +def cmd_send_report(args) -> int: + """Email a report of the last N hours now, or (--preview) save it as an HTML file without sending.""" + from .db import DB + from .report import EmailSettings, Reporter, build_report + + db = DB(DATA_DIR / "hogwatch.db") + if args.preview: + now = time.time() + rep = build_report(db, now - args.hours * 3600, now) + out = DATA_DIR / "report_preview.html" + out.write_text(f"{rep['subject']}{rep['html']}", + encoding="utf-8") + print(f"Subject: {rep['subject']}\n\n{rep['text']}\nHTML version saved to {out}") + return 0 + result = Reporter(db, EmailSettings(DATA_DIR / "email.json"), threading.Event()).send(daily=False, hours=args.hours) + if result["ok"]: + print(f"Sent to {', '.join(result['to'])}: {result['subject']}") + return 0 + print(f"Not sent: {result['error']}") + return 1 + + def main() -> int: """Parse the command and run it.""" p = argparse.ArgumentParser(prog="hogwatch", description="Find out who is slowing the internet down.") @@ -204,12 +233,15 @@ def main() -> int: sub.add_parser("eero-check", help="show what the eero reports right now") st = sub.add_parser("selftest", help="15-second check that everything works") st.add_argument("--seconds", type=int, default=15) + sr = sub.add_parser("send-report", help="email the recent slowdowns and dropouts now") + sr.add_argument("--hours", type=float, default=24, help="how far back to cover (default 24)") + sr.add_argument("--preview", action="store_true", help="save the report as HTML instead of sending") args = p.parse_args() if args.cmd is None: args = p.parse_args(["run"]) DATA_DIR.mkdir(parents=True, exist_ok=True) return {"run": cmd_run, "eero-login": cmd_eero_login, "eero-check": cmd_eero_check, - "selftest": cmd_selftest}[args.cmd](args) + "selftest": cmd_selftest, "send-report": cmd_send_report}[args.cmd](args) if __name__ == "__main__": diff --git a/hogwatch/report.py b/hogwatch/report.py new file mode 100644 index 0000000..606413d --- /dev/null +++ b/hogwatch/report.py @@ -0,0 +1,402 @@ +"""Daily email report: every slowdown and dropout since the last report, with reasons. + +Mail goes out through the user's own email account over SMTP (TLS only). For +Gmail that means an App Password -- a 16-character password that can only +send mail and can be revoked on its own. It's stored encrypted with Windows +DPAPI (see secret.py) and never returned by the dashboard API. +""" + +from __future__ import annotations + +import base64 +import datetime as dt +import html +import json +import logging +import re +import smtplib +import ssl +import threading +import time +from collections import Counter, defaultdict +from email.message import EmailMessage +from email.utils import formatdate, make_msgid +from pathlib import Path + +from . import secret +from .incidents import fmt_mbps + +log = logging.getLogger(__name__) + +DEFAULTS = { + "to": "", # one or more addresses, comma-separated + "from": "", # the account that sends it (e.g. your Gmail address) + "username": "", # SMTP login; empty = same as "from" + "smtp_host": "smtp.gmail.com", + "smtp_port": 587, # 587 = STARTTLS, 465 = TLS from the start + "daily": True, + "send_hour": 8, # if HogWatch is already running, the daily report goes out at this hour +} +_EMAIL_RE = re.compile(r"^[^@\s,;<>\"]+@[^@\s,;<>\"]+\.[^@\s,;<>\"]+$") +MAX_REPORT_DAYS = 7 + + +class ReportError(Exception): + """A problem the user can fix (bad address, rejected login, unreachable server).""" + + +def parse_recipients(value: str) -> list[str]: + """Split 'a@b.com, c@d.com' into addresses; raise ReportError on anything malformed.""" + parts = [p.strip() for p in re.split(r"[,;\s]+", value or "") if p.strip()] + bad = [p for p in parts if not _EMAIL_RE.match(p)] + if bad: + raise ReportError(f"That doesn't look like an email address: {bad[0]}") + return parts + + +# ------------------------------------------------------------------ settings + + +class EmailSettings: + """data/email.json: where reports go and how to send them. The password is stored DPAPI-encrypted.""" + + def __init__(self, path: Path): + """`path` is the JSON file (data/email.json in normal use).""" + self.path = path + + def _raw(self) -> dict: + try: + return json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + def load(self) -> dict: + """Settings for display: never includes the password, just whether one is saved.""" + raw = self._raw() + out = dict(DEFAULTS) + out.update({k: v for k, v in raw.items() if k in DEFAULTS}) + out["password_set"] = bool(raw.get("password_dpapi")) + return out + + def password(self) -> str | None: + """The decrypted app password, or None if none is saved.""" + blob = self._raw().get("password_dpapi") + if not blob: + return None + return secret.unprotect(base64.b64decode(blob)) + + def save(self, updates: dict) -> dict: + """Validate and store changes. An empty password leaves the saved one alone.""" + raw = self._raw() + for key in ("to", "from", "username", "smtp_host"): + if key in updates: + raw[key] = str(updates[key] or "").strip() + if "to" in updates and raw["to"]: + raw["to"] = ", ".join(parse_recipients(raw["to"])) + for key in ("from", "username"): + if raw.get(key) and not _EMAIL_RE.match(raw[key]): + raise ReportError(f"That doesn't look like an email address: {raw[key]}") + if "smtp_port" in updates: + try: + port = int(updates["smtp_port"]) + except (TypeError, ValueError): + raise ReportError("The port must be a number (587 for Gmail)") from None + if not 1 <= port <= 65535: + raise ReportError("The port must be between 1 and 65535") + raw["smtp_port"] = port + if "daily" in updates: + raw["daily"] = bool(updates["daily"]) + if updates.get("clear_password"): + raw.pop("password_dpapi", None) + elif updates.get("password"): + # Google shows app passwords with spaces ("abcd efgh ijkl mnop"); they work either way. + pw = str(updates["password"]).strip() + raw["password_dpapi"] = base64.b64encode(secret.protect(pw)).decode("ascii") + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text(json.dumps(raw, indent=2), encoding="utf-8") + return self.load() + + +# ------------------------------------------------------------------ building the report + + +def _clock(ts: float) -> str: + """'Thu Sep 24, 9:17 PM' (no leading zero; Windows strftime lacks %-I).""" + d = dt.datetime.fromtimestamp(ts) + return f"{d:%a %b} {d.day}, {d.hour % 12 or 12}:{d.minute:02d} {'AM' if d.hour < 12 else 'PM'}" + + +def _dur(secs: float) -> str: + secs = max(1, int(round(secs))) + if secs < 60: + return f"{secs} sec" + m, s = divmod(secs, 60) + return f"{m} min" + (f" {s} sec" if s and m < 5 else "") + + +_KIND_LABEL = { + "eero_link": "Link between eeros dropped", "congested": "Internet crawled", "outage": "Internet dropped out", + "home": "Home network slow", "home_outage": "PC lost the eero", +} + + +def _advice(where: str, path: dict | None) -> str | None: + """The same fix suggestion the dashboard shows for the most common dropout location.""" + wired = bool(path and path.get("wired")) + return { + "eero_link": ("Check that both ends of the cable between your eeros are plugged in, or try another cable." + if wired else + "Most dropouts are on the wireless link between your eeros, not your internet line. " + "An Ethernet cable between those two eeros (a wired backhaul) would fix them."), + "pc_link": "Most dropouts are between this PC and its eero. Try a different cable and the eero's other port.", + "eero": "Most dropouts are inside your main eero. Restart it and check the eero app for updates.", + "att": "Most dropouts are at the AT&T gateway. Restart it; if they continue, give AT&T these times.", + "internet": "Most dropouts happen past your eeros (AT&T or beyond). If frequent, give AT&T these times.", + }.get(where) + + +def build_report(db, since: float, until: float) -> dict: + """Everything that went wrong in [since, until): subject line, plain text, and HTML.""" + hiccups = db.query("SELECT * FROM hiccup WHERE start_ts >= ? AND start_ts < ? ORDER BY start_ts", (since, until)) + incidents = db.query("SELECT * FROM incident WHERE start_ts >= ? AND start_ts < ? AND end_ts IS NOT NULL " + "ORDER BY start_ts", (since, until)) + events = db.query("SELECT * FROM eero_event WHERE ts >= ? AND ts < ? ORDER BY ts", (since, until)) + raw_path = db.get_meta("eero_path") + path = json.loads(raw_path) if raw_path else None + + where_counts = Counter(h["where_"] for h in hiccups) + where_text: dict[str, str] = {} + where_secs: dict[str, float] = defaultdict(float) + for h in hiccups: + where_text.setdefault(h["where_"], h["where_text"]) + where_secs[h["where_"]] += h["end_ts"] - h["start_ts"] + in_game = sum(1 for h in hiccups if h["game"]) + down_secs = sum(h["end_ts"] - h["start_ts"] for h in hiccups) + advice = None + if hiccups: + top, n = where_counts.most_common(1)[0] + if len(hiccups) >= 3 and n / len(hiccups) >= 0.6: + advice = _advice(top, path) + + period = f"{_clock(since)} to {_clock(until)}" + nd, ns = len(hiccups), len(incidents) + if nd or ns: + bits = [] + if nd: + bits.append(f"{nd} dropout{'s' if nd != 1 else ''}") + if ns: + bits.append(f"{ns} slowdown{'s' if ns != 1 else ''}") + subject = f"HogWatch: {' and '.join(bits)} since {_clock(since)}" + else: + subject = f"HogWatch: all clear since {_clock(since)}" + + def busy_text(h) -> str: + busy = json.loads(h["busy"]) if h["busy"] else [] + if not busy: + return "nobody heavy" + return ", ".join(f"{b['name']} ↓{fmt_mbps(b['down_mbps'])} ↑{fmt_mbps(b['up_mbps'])} Mbps" + + (" (shares your link)" if b.get("on_link") and not b.get("this_pc") else "") + for b in busy[:3]) + + def how_bad(h) -> str: + parts = [] + if h["lost"]: + parts.append(f"{h['lost']} ping{'s' if h['lost'] != 1 else ''} lost") + if h["worst_ms"]: + parts.append(f"worst {h['worst_ms']:.0f} ms") + return ", ".join(parts) or "slow" + + # ---- plain text + lines = [f"HogWatch report for {period}", ""] + if not (nd or ns): + lines.append("No slowdowns or dropouts. Everything ran normally.") + else: + lines.append(f"Dropouts (lag spikes): {nd}, about {_dur(down_secs)} offline in total" + + (f", {in_game} during a game" if in_game else "")) + for where, n in where_counts.most_common(): + lines.append(f" {n} {where_text[where]} ({_dur(where_secs[where])})") + lines.append(f"Slowdowns: {ns}") + if advice: + lines += ["", f"Suggested fix: {advice}"] + if hiccups: + lines += ["", "DROPOUTS"] + for h in hiccups: + lines.append(f"- {_clock(h['start_ts'])}, {_dur(h['end_ts'] - h['start_ts'])}, {how_bad(h)}: {h['where_text']}" + + (f". Game: {h['game']}" if h["game"] else "") + f". Busy: {busy_text(h)}") + if incidents: + lines += ["", "SLOWDOWNS"] + for i in incidents: + lines.append(f"- {_clock(i['start_ts'])} ({_KIND_LABEL.get(i['kind'], i['kind'])}): {i['headline']}") + if events: + lines += ["", "EEROS THAT LOST THEIR CONNECTION"] + for e in events: + lines.append(f"- {_clock(e['ts'])}: {e['unit']} eero reconnected") + lines += ["", "Open the dashboard for charts: http://127.0.0.1:8765/ (on the PC running HogWatch)"] + text = "\n".join(lines) + "\n" + + # ---- HTML (inline styles: most email apps strip