Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down Expand Up @@ -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`
36 changes: 34 additions & 2 deletions hogwatch/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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"<!doctype html><meta charset=utf-8><title>{rep['subject']}</title>{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.")
Expand All @@ -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__":
Expand Down
2 changes: 2 additions & 0 deletions hogwatch/eero.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading