diff --git a/README.md b/README.md
index f8b51dd..7888eb7 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,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**
@@ -102,5 +121,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/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,
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