From 277a66845209d183e525749825904f7c31dbce25 Mon Sep 17 00:00:00 2001 From: Michael Saba Date: Fri, 2 Oct 2026 20:51:06 -0400 Subject: [PATCH 1/2] feat: installer report and deeper non-invasive monitoring - Installer report (dashboard card, /installer-report, installer-report CLI with PDF via headless Edge/Chrome): findings with numbers, requested work, eero layout, per-hop delay/jitter/loss, dropouts by day and time of day, reconnects, Ethernet port checks, radios/channels incl. DFS overlap, mesh route changes, double NAT. Reads no device names, people, programs or games. - Monitoring: each eero's backhaul, radios and Ethernet ports every 2 minutes; pings to every eero; per-bucket jitter; PC network-card link events; eero speed-test history; gzip for eero API calls (~90% less traffic). - Daily email: games are left out (it may be forwarded). - Fix: a dropout or slowdown open when the PC slept was closed on wake with the sleep included; now closed at the last ping before the pause, and old records are capped by their bad-ping count. - Fix: if HogWatch started before the network was up, the main eero and ISP gateway were never pinged, so eero-link dropouts read as AT&T-side. Discovery now retries and falls back to the eero's own address; the report labels unmeasured dropouts instead of guessing. Co-Authored-By: Claude Opus 5.5 --- hogwatch/__main__.py | 25 +- hogwatch/collector.py | 115 +++++++-- hogwatch/db.py | 40 +++- hogwatch/eero.py | 46 +++- hogwatch/incidents.py | 10 + hogwatch/installer.py | 474 +++++++++++++++++++++++++++++++++++++ hogwatch/report.py | 17 +- hogwatch/static/index.html | 12 + hogwatch/static/style.css | 3 + hogwatch/web.py | 11 +- tests/test_installer.py | 199 ++++++++++++++++ tests/test_report.py | 6 +- 12 files changed, 924 insertions(+), 34 deletions(-) create mode 100644 hogwatch/installer.py create mode 100644 tests/test_installer.py diff --git a/hogwatch/__main__.py b/hogwatch/__main__.py index 355d4aa..c8dc8a5 100644 --- a/hogwatch/__main__.py +++ b/hogwatch/__main__.py @@ -5,6 +5,7 @@ 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) + python -m hogwatch installer-report technical report for the eero installer (HTML + PDF in data\\) """ from __future__ import annotations @@ -223,6 +224,24 @@ def cmd_send_report(args) -> int: return 1 +def cmd_installer_report(args) -> int: + """Write the installer report as HTML and (with Edge or Chrome installed) PDF in data\\.""" + from .db import DB + from .installer import build_installer_report, save_pdf + + db = DB(DATA_DIR / "hogwatch.db") + page = build_installer_report(db, days=args.days, note=args.note or "") + html_path = DATA_DIR / "installer_report.html" + html_path.write_text(page, encoding="utf-8") + print(f"HTML report: {html_path}") + pdf_path = DATA_DIR / "installer_report.pdf" + if save_pdf(html_path, pdf_path, DATA_DIR / "pdf-browser-profile"): + print(f"PDF report: {pdf_path}") + else: + print("No Edge or Chrome found for the PDF: open the HTML report and use Print > Save as PDF.") + return 0 + + def main() -> int: """Parse the command and run it.""" p = argparse.ArgumentParser(prog="hogwatch", description="Find out who is slowing the internet down.") @@ -236,12 +255,16 @@ def main() -> int: 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") + ir = sub.add_parser("installer-report", help="technical report for whoever services the eeros (HTML + PDF)") + ir.add_argument("--days", type=float, default=7, help="how far back to cover (1-14, default 7)") + ir.add_argument("--note", help="notes from the homeowner to include") 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, "send-report": cmd_send_report}[args.cmd](args) + "selftest": cmd_selftest, "send-report": cmd_send_report, + "installer-report": cmd_installer_report}[args.cmd](args) if __name__ == "__main__": diff --git a/hogwatch/collector.py b/hogwatch/collector.py index 13d095b..dc0b1ef 100644 --- a/hogwatch/collector.py +++ b/hogwatch/collector.py @@ -78,11 +78,11 @@ def __init__(self, cfg: dict, db: DB): def start(self) -> None: """Discover the network path, start per-program tracing, launch the threads.""" self._close_stale_incidents() - lan = self.cfg.get("lan_target") or ping.hop(1) - isp = ping.hop(2, toward=self.cfg["internet_targets"][0]) - self.targets = {"lan": lan, "isp": isp, "internet": self.cfg["internet_targets"]} - self.db.set_meta("targets", json.dumps(self.targets)) - log.info("targets: %s", self.targets) + self.targets = {"lan": None, "isp": None, "internet": self.cfg["internet_targets"]} + self._discover_targets() # retried from the ping loop if the network isn't up yet + if not self.db.get_meta("link_watch_since"): + # Reports may only claim "the PC's cable never dropped" for the time it was watched. + self.db.set_meta("link_watch_since", str(int(time.time()))) self.pc = PCMonitor() for fn in (self._ping_loop, self._pc_loop, self._eero_loop, self._housekeeping_loop): threading.Thread(target=self._guard(fn), name=fn.__name__.strip("_"), daemon=True).start() @@ -122,28 +122,47 @@ def _ping_loop(self) -> None: interval = float(self.cfg["ping_interval_s"]) timeout = int(self.cfg["ping_timeout_ms"]) inet = list(self.cfg["internet_targets"]) - pool = ThreadPoolExecutor(max_workers=len(inet) + 3, thread_name_prefix="icmp") + pool = ThreadPoolExecutor(max_workers=12, thread_name_prefix="icmp") buckets: dict[str, dict] = {} + last_rtt: dict[str, float | None] = {} # previous ping per role, for jitter bucket_ts = int(time.time()) // BUCKET_S * BUCKET_S + last_round = time.time() + last_discovery = 0.0 while not self.stopping.is_set(): t0 = time.time() + if t0 - last_round > 30: + # Monitoring paused (the PC slept or hung): close anything open at the last + # moment we saw it, so the gap isn't counted as a 10-hour dropout. + self._close_after_gap(last_round) + last_rtt.clear() + last_round = t0 + if (not self.targets.get("lan") or not self.targets.get("isp")) and t0 - last_discovery > 30: + # At boot the network may not be up when HogWatch starts; keep looking. + last_discovery = t0 + self._discover_targets() lan = self.targets.get("lan") node = self.targets.get("node") # the eero this PC plugs into, once eero tells us + units = dict(self.targets.get("units") or {}) # the other eeros: one record per link futs = {("inet", n): pool.submit(ping.ping, ip, timeout) for n, ip in enumerate(inet)} if lan: futs["lan"] = pool.submit(ping.ping, lan, timeout) if node: futs["node"] = pool.submit(ping.ping, node, timeout) + for name, ip in units.items(): + futs[("unit", name)] = pool.submit(ping.ping, ip, timeout) futs["isp"] = pool.submit(ping.ping, inet[0], timeout, 2) res = {k: f.result()[0] for k, f in futs.items()} - internet = min((v for k, v in res.items() if isinstance(k, tuple) and v is not None), default=None) + internet = min((v for k, v in res.items() if k[0] == "inet" and v is not None), default=None) lan_ms, isp_ms, node_ms = res.get("lan"), res.get("isp"), res.get("node") - for role, v, probed in (("internet", internet, True), ("lan", lan_ms, bool(lan)), - ("isp", isp_ms, True), ("node", node_ms, bool(node))): + rows = [("internet", internet, True), ("lan", lan_ms, bool(lan)), + ("isp", isp_ms, True), ("node", node_ms, bool(node))] + rows += [(f"unit:{name}", res.get(("unit", name)), True) for name in units] + for role, v, probed in rows: if not probed: continue - b = buckets.setdefault(role, {"sent": 0, "lost": 0, "sum": 0.0, "n": 0, "max": None}) + b = buckets.setdefault(role, {"sent": 0, "lost": 0, "sum": 0.0, "n": 0, "max": None, + "jsum": 0.0, "jn": 0}) b["sent"] += 1 if v is None: b["lost"] += 1 @@ -151,6 +170,13 @@ def _ping_loop(self) -> None: b["sum"] += v b["n"] += 1 b["max"] = v if b["max"] is None else max(b["max"], v) + # Jitter = how much each ping differs from the one before. Games and voice + # suffer from uneven delay even when the average looks fine. + prev = last_rtt.get(role) + if prev is not None: + b["jsum"] += abs(v - prev) + b["jn"] += 1 + last_rtt[role] = v with self.lock: self.rounds.append((t0, internet, lan_ms, isp_ms, node_ms)) @@ -169,13 +195,46 @@ def _ping_loop(self) -> None: if t0 >= bucket_ts + BUCKET_S: self.db.executemany( - "INSERT INTO latency(ts, role, sent, lost, avg_ms, max_ms) VALUES(?,?,?,?,?,?)", - [(bucket_ts, role, b["sent"], b["lost"], (b["sum"] / b["n"]) if b["n"] else None, b["max"]) + "INSERT INTO latency(ts, role, sent, lost, avg_ms, max_ms, jitter_ms) VALUES(?,?,?,?,?,?,?)", + [(bucket_ts, role, b["sent"], b["lost"], (b["sum"] / b["n"]) if b["n"] else None, b["max"], + (b["jsum"] / b["jn"]) if b["jn"] else None) for role, b in buckets.items()]) buckets = {} bucket_ts = int(t0) // BUCKET_S * BUCKET_S self.stopping.wait(max(0.0, interval - (time.time() - t0))) + def _discover_targets(self) -> None: + """Find the main eero (hop 1) and the ISP gateway (hop 2); fall back to the eero's own data.""" + lan = self.cfg.get("lan_target") or ping.hop(1) + isp = ping.hop(2, toward=self.cfg["internet_targets"][0]) + with self.lock: + if lan: + self.targets["lan"] = lan + elif not self.targets.get("lan"): + gw = next((u for u in self.path.get("units") or [] if u.get("gateway") and u.get("ip")), None) + if gw: + self.targets["lan"] = gw["ip"] + if isp: + self.targets["isp"] = isp + targets = dict(self.targets) + self.db.set_meta("targets", json.dumps(targets)) + if isp: + self.db.set_meta("isp_seen", isp) # remembered even if a later discovery fails + if lan or isp: + log.info("targets: %s", targets) + + def _close_after_gap(self, last_seen: float) -> None: + """Close an open dropout or slowdown at `last_seen` after a pause in monitoring.""" + hiccup = self.hiccups.flush() + if hiccup: + self._record_hiccup(hiccup) + with self.lock: + if self.incident: + self._incident_end(last_seen) + self.detector.active = False + self.detector.recent.clear() + log.info("monitoring resumed after a %.0f s pause", time.time() - last_seen) + # ------------------------------------------------------------------ lag spikes def _record_hiccup(self, hic: dict) -> None: @@ -216,16 +275,29 @@ def _refresh_units(self, connected: list[dict], now: float) -> None: "gateway": gateway["name"] if gateway else None, "chain": [c["name"] for c in chain], "radio": node["radio"] if node else None, "wired": node["wired"] if node else None, "on_link_units": on_link, - "units": [{k: u[k] for k in ("name", "gateway", "wired", "upstream", "radio", "model")} for u in units]} + "units": [{k: u[k] for k in ("name", "gateway", "wired", "upstream", "radio", "model", "ip")} + for u in units]} with self.lock: self.path = path if node and node["ip"]: self.targets["node"] = node["ip"] else: self.targets.pop("node", None) + if not self.targets.get("lan") and gateway and gateway["ip"]: + self.targets["lan"] = gateway["ip"] # hop discovery failed; eero told us anyway + # The other eeros (not ours, not the main one, which are pinged already) get pinged + # too, so every wireless link between eeros has its own record. + skip = {node["name"] if node else None, gateway["name"] if gateway else None} + self.targets["units"] = {u["name"]: u["ip"] for u in units if u["ip"] and u["name"] not in skip} targets = dict(self.targets) self.db.set_meta("eero_path", json.dumps(path)) self.db.set_meta("targets", json.dumps(targets)) + self.db.executemany( + "INSERT INTO unit_sample(ts, unit, gateway, wired, upstream, radio, bars, status, firmware, " + "wired_clients, wireless_clients, bands, ports) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", + [(int(now), u["name"], int(u["gateway"]), int(u["wired"]), u["upstream"], u["radio"], u["bars"], + u["status"], u["firmware"], u["wired_clients"], u["wireless_clients"], json.dumps(u["bands"]), + json.dumps(u["ports"])) for u in units]) for u in units: ts = u["reconnected_ts"] if not ts: @@ -297,12 +369,23 @@ def _incident_end(self, ts: float) -> None: # ------------------------------------------------------------------ this PC def _pc_loop(self) -> None: - """Every 10s: record card totals and per-program usage.""" + """Every 10s: record card totals and per-program usage, and note the PC's cable link changing.""" last_update = 0.0 + last_link = None while not self.stopping.wait(BUCKET_S): s = self.pc.sample() ts = int(time.time()) secs = s["secs"] + # This PC's own Ethernet link: a drop or a fall to 100 Mbps would point at the + # PC's cable or port rather than the eeros. + stats = psutil.net_if_stats().get(self.pc.nic) if self.pc.nic else None + link = (bool(stats.isup), int(stats.speed or 0)) if stats else None + if link and last_link and link != last_link: + what = "up" if link[0] else "down" + self.db.execute("INSERT INTO link_event(ts, what, detail) VALUES(?,?,?)", + (ts, what, json.dumps({"speed_mbps": link[1], "before_mbps": last_link[1]}))) + log.warning("this PC's network link went %s (%s Mbps)", what, link[1]) + last_link = link or last_link self.db.execute("INSERT INTO pc_total(ts, secs, rx_bytes, tx_bytes) VALUES(?,?,?,?)", (ts, secs, s["rx"], s["tx"])) self.db.executemany("INSERT INTO pc_app(ts, app, rx_bytes, tx_bytes) VALUES(?,?,?,?)", @@ -379,7 +462,7 @@ def _eero_loop(self) -> None: "usage_reported": bool(with_usage), "network": self.eero.network_name} if self.incident: self.incident.add_eero(connected) - if now - last_units > 300: + if now - last_units > 120: last_units = now self._refresh_units(connected, now) if now - last_speed > 1800: @@ -387,6 +470,8 @@ def _eero_loop(self) -> None: speed = speed_from_network(self.eero.network()) if speed: self.db.set_meta("eero_speed", json.dumps(speed)) + self.db.execute("INSERT OR IGNORE INTO speedtest(date, ts, down, up) VALUES(?,?,?,?)", + (speed.get("date") or str(int(now)), int(now), speed.get("down"), speed.get("up"))) except LoginNeeded as e: with self.lock: self.eero_live = {"status": "login_needed", "message": str(e), "devices": [], "ts": None} diff --git a/hogwatch/db.py b/hogwatch/db.py index ddd449e..8c3a0a4 100644 --- a/hogwatch/db.py +++ b/hogwatch/db.py @@ -12,7 +12,8 @@ from pathlib import Path SCHEMA = """ --- Ping results, one row per 10-second bucket per role (lan / isp / internet). +-- Ping results, one row per 10-second bucket per role (node / lan / isp / internet / +-- unit:). jitter_ms = average change between consecutive pings. CREATE TABLE IF NOT EXISTS latency ( ts INTEGER NOT NULL, role TEXT NOT NULL, sent INTEGER NOT NULL, lost INTEGER NOT NULL, @@ -75,9 +76,34 @@ CREATE TABLE IF NOT EXISTS eero_event (ts INTEGER NOT NULL, unit TEXT NOT NULL, kind TEXT, detail TEXT); CREATE INDEX IF NOT EXISTS eero_event_ts ON eero_event(ts); +-- Each eero's health every 2 minutes: how it links to the others, its radios and +-- Ethernet ports. Evidence for an installer (e.g. a wall cable that never links). +CREATE TABLE IF NOT EXISTS unit_sample ( + ts INTEGER NOT NULL, unit TEXT NOT NULL, gateway INTEGER, wired INTEGER, + upstream TEXT, radio TEXT, bars INTEGER, status TEXT, firmware TEXT, + wired_clients INTEGER, wireless_clients INTEGER, + bands TEXT, -- JSON {"5 GHz": {"channel", "width", "utilization", "clients", "tx_power"}, ...} + ports TEXT -- JSON [{"port", "carrier", "speed", "wan"}, ...] +); +CREATE INDEX IF NOT EXISTS unit_sample_ts ON unit_sample(ts); + +-- This PC's own network card going down or up (rules the PC's cable in or out). +CREATE TABLE IF NOT EXISTS link_event (ts INTEGER NOT NULL, what TEXT NOT NULL, detail TEXT); +CREATE INDEX IF NOT EXISTS link_event_ts ON link_event(ts); + +-- The eero's own speed tests (shows whether the internet line itself is healthy). +CREATE TABLE IF NOT EXISTS speedtest (date TEXT PRIMARY KEY, ts INTEGER, down REAL, up REAL); + CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT); """ +# A dropout's end, capped by how many bad pings it had (at most 2 s apart). Dropouts +# recorded before sleep handling existed could stretch across hours of the PC sleeping. +HICCUP_END = "(start_ts + MIN(end_ts - start_ts, rounds * 2 + 4))" + +# Columns added after the first release; older databases get them on startup. +MIGRATIONS = [("latency", "jitter_ms", "REAL")] + class DB: """Thread-safe wrapper: a single connection guarded by a lock. @@ -96,6 +122,10 @@ def __init__(self, path: Path): self.conn.execute("PRAGMA journal_mode=WAL") self.conn.execute("PRAGMA synchronous=NORMAL") self.conn.executescript(SCHEMA) + for table, column, kind in MIGRATIONS: + cols = {r[1] for r in self.conn.execute(f"PRAGMA table_info({table})")} + if column not in cols: + self.conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {kind}") def execute(self, sql: str, params=()) -> int | None: """Run one statement; returns lastrowid (useful after INSERT).""" @@ -121,6 +151,11 @@ def query(self, sql: str, params=()) -> list[dict]: with self.lock: return [dict(r) for r in self.conn.execute(sql, params).fetchall()] + def rows(self, sql: str, params=()) -> list: + """Like query(), but plain rows instead of dicts: lighter for big scans (reports).""" + with self.lock: + return self.conn.execute(sql, params).fetchall() + def get_meta(self, key: str, default: str | None = None) -> str | None: """Read a small persisted value (e.g. the eero speed test result).""" rows = self.query("SELECT v FROM meta WHERE k = ?", (key,)) @@ -133,7 +168,8 @@ def set_meta(self, key: str, value: str) -> None: def prune(self, retention_days: int) -> None: """Delete history older than the retention window.""" cutoff = int(time.time()) - retention_days * 86400 - for table in ("latency", "pc_total", "pc_app", "pc_remote", "eero_sample", "eero_event"): + for table in ("latency", "pc_total", "pc_app", "pc_remote", "eero_sample", "eero_event", "unit_sample", + "link_event", "speedtest"): self.execute(f"DELETE FROM {table} WHERE ts < ?", (cutoff,)) self.execute("DELETE FROM incident WHERE start_ts < ?", (cutoff,)) self.execute("DELETE FROM hiccup WHERE start_ts < ?", (cutoff,)) diff --git a/hogwatch/eero.py b/hogwatch/eero.py index 74b08db..a4205b8 100644 --- a/hogwatch/eero.py +++ b/hogwatch/eero.py @@ -17,6 +17,7 @@ from __future__ import annotations +import gzip import json import logging import re @@ -29,6 +30,14 @@ API = "https://api-user.e2ro.com" +def _body(resp) -> bytes: + """Read a response body, un-gzipping it if the server compressed it.""" + raw = resp.read() + if (resp.headers.get("Content-Encoding") or "").lower() == "gzip": + raw = gzip.decompress(raw) + return raw + + class EeroError(Exception): """Any failure talking to eero.""" @@ -68,19 +77,20 @@ def save(self) -> None: def _call(self, method: str, path: str, body: dict | None = None, retry: bool = True): """Make one API call and return its `data` field, refreshing the session once if needed.""" + # gzip: the device list is ~90 KB of JSON every 30 s; compressed it's a tenth of that. headers = {"Content-Type": "application/json", "Accept": "application/json", - "User-Agent": "HogWatch/1.0 (home network monitor)"} + "Accept-Encoding": "gzip", "User-Agent": "HogWatch/1.0 (home network monitor)"} if self.token: headers["Cookie"] = f"s={self.token}" data = json.dumps(body).encode() if body is not None else (b"" if method == "POST" else None) req = urllib.request.Request(API + path, data=data, method=method, headers=headers) try: with urllib.request.urlopen(req, timeout=20) as resp: - payload = json.load(resp) + payload = json.loads(_body(resp)) except urllib.error.HTTPError as e: try: - err = (json.load(e).get("meta") or {}).get("error", "") - except ValueError: + err = (json.loads(_body(e)).get("meta") or {}).get("error", "") + except (ValueError, OSError): err = "" if e.code == 401 and retry and path != "/2.2/login/refresh" and self.token: self.refresh() @@ -143,6 +153,21 @@ def parse_unit(u: dict, now: float) -> dict: up = u.get("uptime") or {} parent = u.get("wireless_upstream_node") or {} cloud_s = up.get("since_cloud_connection_s") + reboot_s = up.get("since_last_reboot_s") + bands = {} + for key, stats in (u.get("radio_channel_stats") or {}).items(): + if isinstance(stats, dict) and stats.get("channel"): + bands[_BAND_NAMES.get(key, key)] = { + "channel": stats.get("channel"), "width": stats.get("channel_width"), + "utilization": stats.get("channel_utilization"), "clients": stats.get("client_count"), + "tx_power": stats.get("tx_power"), + } + ports = [] + for p in (u.get("ethernet_status") or {}).get("statuses") or []: + speed = str(p.get("speed") or "").lstrip("P") + ports.append({"port": str(p.get("port_name") or p.get("interfaceNumber")), "carrier": bool(p.get("hasCarrier")), + "speed": int(speed) if p.get("hasCarrier") and speed.isdigit() else None, + "wan": bool(p.get("isWanPort"))}) return { "name": u.get("location") or u.get("serial") or "eero", "ip": u.get("ip_address"), @@ -152,9 +177,22 @@ def parse_unit(u: dict, now: float) -> dict: "radio": (parent.get("primary_mesh_radio") or "").replace("GHz", " GHz") or None, "model": u.get("model"), "reconnected_ts": int(now - cloud_s) if isinstance(cloud_s, (int, float)) else None, + # Health details for the installer report. + "rebooted_ts": int(now - reboot_s) if isinstance(reboot_s, (int, float)) and reboot_s else None, + "bars": u.get("mesh_quality_bars"), + "status": u.get("status"), + "firmware": u.get("os_version"), + "wired_clients": u.get("connected_wired_clients_count"), + "wireless_clients": u.get("connected_wireless_clients_count"), + "bands": bands, + "ports": ports, } +_BAND_NAMES = {"band_2_4GHz": "2.4 GHz", "band_5GHz_full": "5 GHz", "band_5GHz_lower": "5 GHz (low)", + "band_5GHz_upper": "5 GHz (high)", "band_6GHz": "6 GHz"} + + def uplink_chain(units: list[dict], start: str | None) -> list[dict]: """The eeros from `start` up to the main one, e.g. [Upstairs, Main] or [Den, Upstairs, Main].""" by_name = {u["name"]: u for u in units} diff --git a/hogwatch/incidents.py b/hogwatch/incidents.py index 34e5d8e..519efc1 100644 --- a/hogwatch/incidents.py +++ b/hogwatch/incidents.py @@ -170,7 +170,17 @@ def feed(self, ts: float, node_ms, node_probed: bool, lan_ms, lan_probed: bool, self.good += 1 if self.good < self.END_AFTER_GOOD: return None + return self._finish() + + def flush(self) -> dict | None: + """End any open dropout at its last bad round (used when monitoring pauses, e.g. + the PC sleeps, so the sleep isn't counted as part of the dropout).""" + return self._finish() if self.cur else None + + def _finish(self) -> dict | None: + """Close the open dropout and decide where it was; None if it was too small to count.""" c, self.cur = self.cur, None + self.good = 0 # One slightly slow ping is noise; keep drops, repeats, and big spikes. if not (c["lost"] or c["rounds"] >= 2 or (c["worst"] or 0) >= 200 or (c["worst_lan"] or 0) >= 200): return None diff --git a/hogwatch/installer.py b/hogwatch/installer.py new file mode 100644 index 0000000..a455985 --- /dev/null +++ b/hogwatch/installer.py @@ -0,0 +1,474 @@ +"""A technical report for whoever installed or services the eero system. + +It is built only from measurements of the network itself: ping timing to each eero +and each network hop, the eeros' own status (how they link to each other, their +radios, Ethernet ports, reconnects) and their speed tests. It deliberately reads no +device names, people, programs or games, so it can be sent to an outside company. +""" + +from __future__ import annotations + +import html +import ipaddress +import json +import shutil +import statistics +import subprocess +import time +from collections import Counter, defaultdict +from datetime import datetime +from pathlib import Path + +from .db import HICCUP_END +from .report import _clock, _dur + +# 5 GHz channels 52-144 must vacate for radar (DFS); a wide channel touching any of +# them can be forced to move or pause without warning. +DFS_CHANNELS = set(range(52, 145, 4)) + +E = html.escape + + +def channel_block(channel: int, width: int) -> list[int]: + """The 20 MHz channels a 5 GHz channel of `width` MHz (primary `channel`) occupies.""" + if not channel or not width or width <= 20: + return [channel] if channel else [] + n = width // 20 + base = 36 if channel < 100 else (100 if channel < 149 else 149) + start = base + ((channel - base) // (4 * n)) * 4 * n + return [start + 4 * i for i in range(n)] + + +def touches_dfs(band: str, channel: int, width: int) -> bool: + """True if a 5 GHz channel's span includes radar-shared (DFS) channels.""" + return band.startswith("5") and any(c in DFS_CHANNELS for c in channel_block(channel, width)) + + +def _pct(sorted_vals: list[float], p: float) -> float | None: + if not sorted_vals: + return None + return sorted_vals[min(len(sorted_vals) - 1, int(round(p / 100 * (len(sorted_vals) - 1))))] + + +def hop_stats(db, since: float) -> dict[str, dict]: + """Per ping target: typical and 95th-percentile delay (of 10-second averages), jitter, loss.""" + by = defaultdict(lambda: {"avgs": [], "sent": 0, "lost": 0, "jit": []}) + for role, avg, sent, lost, jit in db.rows( + "SELECT role, avg_ms, sent, lost, jitter_ms FROM latency WHERE ts >= ?", (since,)): + d = by[role] + d["sent"] += sent + d["lost"] += lost + if avg is not None: + d["avgs"].append(avg) + if jit is not None: + d["jit"].append(jit) + out = {} + for role, d in by.items(): + avgs = sorted(d["avgs"]) + out[role] = {"median": statistics.median(avgs) if avgs else None, "p95": _pct(avgs, 95), + "jitter": statistics.fmean(d["jit"]) if d["jit"] else None, + "loss_pct": 100.0 * d["lost"] / d["sent"] if d["sent"] else None, "sent": d["sent"]} + return out + + +def _bar_chart(labels: list[str], values: list[float], title: str, fmt=lambda v: f"{v:g}") -> str: + """A small single-series bar chart as inline SVG (prints cleanly in a PDF).""" + w, h, left, bottom, top = 700, 190, 40, 30, 12 + vmax = max(values) if values and max(values) > 0 else 1 + step = next(s for s in (1, 2, 5, 10, 20, 25, 50, 100, 200, 500, 1000, 1e9) if vmax / s <= 5) + top_tick = step * (int(vmax / step) + (1 if vmax % step else 0)) + plot_w, plot_h = w - left - 8, h - top - bottom + slot = plot_w / max(len(values), 1) + bar = min(24, slot * 0.7) + y = lambda v: top + plot_h - (v / top_tick) * plot_h # noqa: E731 + parts = [f''] + t = 0.0 + while t <= top_tick + 1e-9: + parts.append(f'' + f'{fmt(t)}') + t += step + every = max(1, int(len(labels) / 14 + 0.999)) + for i, (lab, v) in enumerate(zip(labels, values)): + x = left + i * slot + (slot - bar) / 2 + if v > 0: + parts.append(f'' + f'{E(lab)}: {E(fmt(v))}') + if i % every == 0: + parts.append(f'{E(lab)}') + parts.append("") + return "".join(parts) + + +def _route_label(where_text: str) -> str: + """'...eeros, 5 GHz' -> 'direct over 5 GHz'; '...(Upstairs → Den → Main)' -> 'via Upstairs → Den → Main'.""" + if "(" in where_text and where_text.endswith(")"): + return "via " + where_text[where_text.index("(") + 1:-1] + if "cable" in where_text: + return "direct by cable" + if where_text.rsplit(", ", 1)[-1].endswith("GHz"): + return "direct over " + where_text.rsplit(", ", 1)[-1] + return "direct" + + +def _ms(v) -> str: + return "–" if v is None else ("<1 ms" if v < 1 else f"{v:.0f} ms") + + +def build_installer_report(db, days: float = 7, note: str = "", now: float | None = None) -> str: + """The full report as one self-contained HTML page (print it, or see save_pdf).""" + now = now or time.time() + since = now - days * 86400 + path = json.loads(db.get_meta("eero_path") or "null") or {} + targets = json.loads(db.get_meta("targets") or "null") or {} + node, gw = path.get("node"), path.get("gateway") or "main" + + # Coverage: the PC isn't always on, so rates are per monitored hour, not per calendar hour. + covered = [r[0] for r in db.rows( + "SELECT ts FROM latency WHERE role = 'internet' AND ts >= ? ORDER BY ts", (since,))] + monitored_h = len(covered) * 10 / 3600 + first_ts = covered[0] if covered else since + hours_by_day: Counter = Counter() + hours_by_hod: Counter = Counter() + for ts in covered: + d = datetime.fromtimestamp(ts) + hours_by_day[d.date()] += 10 / 3600 + hours_by_hod[d.hour] += 10 / 3600 + + hics = db.query(f"SELECT start_ts, {HICCUP_END} AS end_ts, where_, where_text, lost, worst_ms FROM hiccup " + "WHERE start_ts >= ? ORDER BY start_ts", (since,)) + # A dropout can only be placed on the eero link if the main eero was being pinged at + # the time; if it wasn't (e.g. HogWatch started before the network was up), say so + # rather than letting it read as an internet-side problem. + lan_buckets = {r[0] for r in db.rows("SELECT ts FROM latency WHERE role = 'lan' AND ts >= ?", (since - 20,))} + for h in hics: + b = int(h["start_ts"]) // 10 * 10 + if h["where_"] != "eero_link" and not ({b, b - 10, b + 10} & lan_buckets): + h["where_"], h["where_text"] = "unknown", "location not measured (the main eero wasn't being pinged then)" + link = [h for h in hics if h["where_"] == "eero_link"] + beyond = [h for h in hics if h["where_"] in ("internet", "att")] + events = db.query("SELECT ts, unit FROM eero_event WHERE ts >= ? ORDER BY ts", (int(since),)) + samples = db.query("SELECT * FROM unit_sample WHERE ts >= ? ORDER BY ts", (int(since),)) + links_pc = db.query("SELECT ts, what, detail FROM link_event WHERE ts >= ? ORDER BY ts", (int(since),)) + tests = db.query("SELECT date, ts, down, up FROM speedtest WHERE ts >= ? ORDER BY ts", (int(since),)) + if not tests and db.get_meta("eero_speed"): + s = json.loads(db.get_meta("eero_speed")) + tests = [{"date": s.get("date"), "ts": None, "down": s.get("down"), "up": s.get("up")}] + hops = hop_stats(db, since) + + latest: dict[str, dict] = {} + for s in samples: + latest[s["unit"]] = s + units_info = {u["name"]: u for u in path.get("units") or []} + + # ---- evidence derived from the eero snapshots + port_hist: dict[tuple, dict] = {} + radio_hist: dict[tuple, dict] = {} + channel_changes, backhaul_changes = [], [] + prev_chan: dict[tuple, tuple] = {} + prev_bh: dict[str, tuple] = {} + for s in samples: + for p in json.loads(s["ports"] or "[]"): + k = (s["unit"], p["port"]) + ph = port_hist.setdefault(k, {"checks": 0, "linked": 0, "wan": p.get("wan"), "speeds": Counter()}) + ph["checks"] += 1 + if p.get("carrier"): + ph["linked"] += 1 + ph["speeds"][p.get("speed")] += 1 + for band, b in json.loads(s["bands"] or "{}").items(): + k = (s["unit"], band) + rh = radio_hist.setdefault(k, {"util": [], "clients": []}) + if b.get("utilization") is not None: + rh["util"].append(b["utilization"]) + if b.get("clients") is not None: + rh["clients"].append(b["clients"]) + cw = (b.get("channel"), b.get("width")) + if k in prev_chan and prev_chan[k] != cw: + channel_changes.append((s["ts"], s["unit"], band, prev_chan[k], cw)) + prev_chan[k] = cw + bh = (bool(s["wired"]), s["radio"], s["upstream"]) + if s["unit"] in prev_bh and prev_bh[s["unit"]] != bh: + backhaul_changes.append((s["ts"], s["unit"], prev_bh[s["unit"]], bh)) + prev_bh[s["unit"]] = bh + + reconnects = Counter(e["unit"] for e in events) + relays = [n for n, u in units_info.items() if u.get("upstream") == node] if node else [] + node_now = latest.get(node) if node else None + node_bands = json.loads(node_now["bands"]) if node_now else {} + bh_band = (path.get("radio") or "").replace(" ", "") # e.g. "5GHz" + bh_stats = next((b for name, b in node_bands.items() if name.replace(" ", "").startswith(bh_band)), None) if bh_band else None + isp_ip = targets.get("isp") or db.get_meta("isp_seen") + double_nat = bool(isp_ip) and ipaddress.ip_address(isp_ip).is_private + watch_since = db.get_meta("link_watch_since") + jitter_since = (db.rows("SELECT MIN(ts) FROM latency WHERE jitter_ms IS NOT NULL AND ts >= ?", (since,)) or [[None]])[0][0] + dead_ports = [(u, p, ph) for (u, p), ph in port_hist.items() + if u in (node, gw) and not ph["wan"] and ph["linked"] == 0 and ph["checks"] >= 3] + + # ---- findings (plain sentences, numbers first) + findings: list[str] = [] + span_days = max(monitored_h / 24, 1e-9) + if node and not path.get("wired") and link: + longest = max(h["end_ts"] - h["start_ts"] for h in link) + mins = sum(h["end_ts"] - h["start_ts"] for h in link) / 60 + findings.append( + f"The wireless link between the {E(node)} and {E(gw)} eeros keeps dropping. " + f"{len(link)} dropouts in {monitored_h:.0f} monitored hours (about {len(link) / span_days:.0f} per day), " + f"{mins:.1f} minutes offline in total, the longest {_dur(longest)}. During every one, the PC's own " + f"{E(node)} eero kept answering over its cable while the {E(gw)} eero and everything beyond it went " + f"silent, so the failure is on that eero-to-eero link, not the PC, the cabling at the PC, or AT&T.") + # The route each link dropout was on (recorded at the time): a mesh that keeps + # re-routing its satellite is a sign the direct link is marginal. + routes = Counter(_route_label(h["where_text"] or "") for h in link) + if len(routes) > 1: + listed = ", ".join(f"{E(r)} ({n})" for r, n in routes.most_common()) + findings.append(f"The {E(node)} eero's route to the {E(gw)} eero kept changing, so eero itself is " + f"struggling to find a stable link. Routes in use when dropouts happened: {listed}. A healthy " + "mesh keeps one stable route; switching like this happens when the direct link is marginal.") + if reconnects: + parts = ", ".join(f"{E(u)} {n}×" for u, n in reconnects.most_common()) + findings.append(f"eero units repeatedly lost their connection and had to reconnect ({parts} in this period). " + + (f"The {E(', '.join(relays))} eero{'s' if len(relays) != 1 else ''} relay" + f"{'' if len(relays) != 1 else 's'} through the {E(node)} eero, so {'they drop' if len(relays) != 1 else 'it drops'} with it." + if relays else "")) + if dead_ports: + lines = "; ".join(f"{E(u)} eero port {E(p)}: no link in {ph['checks']} of {ph['checks']} checks" for u, p, ph in dead_ports) + findings.append(f"The Ethernet backhaul between the eeros is not working. {lines}. A cable plugged in at " + "both ends that never shows a link means the run is open or damaged, wired to the wrong " + "pins, or is a different cable than assumed. With a working cable, eero would switch the " + f"{E(node or 'satellite')} eero to a wired link automatically.") + if node and not path.get("wired"): + radio = path.get("radio") or "wireless" + txt = f"The {E(node)}–{E(gw)} hop runs on {E(radio)}" + if bh_stats: + txt += f", channel {bh_stats.get('channel')} at {bh_stats.get('width')} MHz" + txt += ". " + if "5" in radio and any(b.startswith("6") for b in node_bands): + txt += ("Both eeros have 6 GHz radios, but eero isn't using 6 GHz for this hop, which usually means " + "the distance or the floors between them are too much for 6 GHz. ") + if bh_stats and touches_dfs("5 GHz", bh_stats.get("channel"), bh_stats.get("width")): + blk = channel_block(bh_stats["channel"], bh_stats["width"]) + txt += (f"That {bh_stats['width']} MHz channel spans channels {blk[0]}–{blk[-1]}, which include radar-shared " + "(DFS) channels: when radar is detected there, the radio has to stop and move, which drops the " + "link for several seconds or more. ") + if relays: + txt += f"The {E(', '.join(relays))} eero also relays through this link, adding its traffic to it." + findings.append(txt) + nh, lh = hops.get("node"), hops.get("lan") + if nh and lh and nh["sent"]: + findings.append(f"Measured from the wired PC: the {E(node)} eero answered {100 - (nh['loss_pct'] or 0):.2f}% " + f"of pings (typical {_ms(nh['median'])}, jitter {_ms(nh['jitter'])}); the {E(gw)} eero, one " + f"hop further, answered {100 - (lh['loss_pct'] or 0):.2f}% (typical {_ms(lh['median'])}, " + f"jitter {_ms(lh['jitter'])}).") + if tests: + t = tests[-1] + findings.append(f"The internet line itself is healthy: the eero's speed test measured " + f"{t['down']:.0f} Mbps down / {t['up']:.0f} Mbps up. {len(beyond)} of {len(hics)} dropouts " + "happened past the eeros, on the AT&T side.") + if double_nat: + findings.append(f"Two routers in a row (double NAT). The first hop past the eeros is {E(isp_ip)}, a private " + "address: the AT&T gateway is still routing in front of the eero. AT&T gateways are normally set to " + "IP Passthrough for an eero system; this also removes a second place for connections to stall.") + if watch_since and not links_pc: + findings.append("The PC's own connection was solid: its network cable to the " + f"{E(node or gw)} eero hasn't gone down since {E(_clock(max(float(watch_since), first_ts)))}.") + + work = [] + if node and not path.get("wired"): + work.append(f"Repair the Ethernet run between the {E(gw)} and the {E(node)}. Test it end to end with a cable " + f"tester or certifier; confirm the cable at the {E(gw)} eero is the one that reaches the {E(node)} " + "jack; re-terminate both ends to the same standard (T568B) or replace the run. Connect it to a free port on each " + f"eero. Done when the eero app shows the {E(node)} eero as Wired.") + work.append(f"If the run can't be repaired: move the main eero to a more central floor so the {E(node)} " + "hop is shorter (and can use 6 GHz), or provide a wired path another way (MoCA over coax, checking " + "compatibility with any satellite TV on that coax, or powerline adapters).") + work.append("Review the wireless channel if any wireless hop remains: avoid a 160 MHz channel that overlaps " + "radar-shared (DFS) channels on a link that must not drop.") + if double_nat: + work.append("Set the AT&T gateway to IP Passthrough for the eero, so there is only one router.") + if relays: + work.append(f"Re-check the mesh layout afterwards: the {E(', '.join(relays))} eero should link directly to a " + f"wired eero rather than relaying through the {E(node)} eero.") + + # ---- tables + def row(*cells, head=False) -> str: + tag = "th" if head else "td" + return "" + "".join(f"<{tag}>{c}" for c in cells) + "" + + hop_rows = [] + labels = [("node", f"{E(node)} eero (PC's cable)" if node else None), ("lan", f"{E(gw)} eero (main)")] + labels += [(f"unit:{n}", f"{E(n)} eero") for n in sorted(units_info) if f"unit:{n}" in hops] + labels += [("isp", f"AT&T gateway{f' ({E(isp_ip)})' if isp_ip else ''}"), ("internet", "Internet (1.1.1.1 / 8.8.8.8)")] + for role, lab in labels: + st = hops.get(role) + if not lab or not st or not st["sent"]: + continue + loss = "n/a (replies rate-limited)" if role == "isp" else f"{st['loss_pct']:.2f}%" + hop_rows.append(row(lab, _ms(st["median"]), _ms(st["p95"]), _ms(st["jitter"]), loss, f"{st['sent']:,}")) + + unit_rows = [] + is_gateway = lambda n: bool((latest.get(n) or units_info.get(n) or {}).get("gateway")) # noqa: E731 + for name in sorted(set(latest) | set(units_info), key=lambda n: (not is_gateway(n), n)): + s = latest.get(name) + u = units_info.get(name, {}) + if s: + if s["gateway"]: + how = "Main eero, cable to AT&T" + elif s["wired"]: + how = "Cable" + else: + how = f"Wireless{f' ({E(s['radio'])})' if s['radio'] else ''} to {E(s['upstream'] or gw)}" + unit_rows.append(row(E(name), E(u.get("model") or ""), how, + f"{s['bars'] if s['bars'] is not None else '–'}/5", + f"{s['wired_clients'] or 0} wired, {s['wireless_clients'] or 0} wireless", + E(s["firmware"] or "–"), E(u.get("ip") or "–"), f"{reconnects.get(name, 0)}")) + else: + unit_rows.append(row(E(name), E(u.get("model") or ""), "Wireless" if not u.get("wired") else "Cable", + "–", "–", "–", E(u.get("ip") or "–"), f"{reconnects.get(name, 0)}")) + + port_rows = [row(E(u), E(p), "AT&T (internet)" if ph["wan"] else "LAN", + f"{ph['linked']} of {ph['checks']}" + (" — never linked" if ph["linked"] == 0 and not ph["wan"] else ""), + ", ".join(f"{s} Mbps" for s in ph["speeds"] if s) or "–") + for (u, p), ph in sorted(port_hist.items())] + + radio_rows = [] + for name, s in sorted(latest.items()): + for band, b in json.loads(s["bands"] or "{}").items(): + rh = radio_hist.get((name, band), {"util": [], "clients": []}) + dfs = " (includes DFS)" if touches_dfs(band, b.get("channel"), b.get("width")) else "" + radio_rows.append(row(E(name), E(band), f"{b.get('channel')}", f"{b.get('width')} MHz{dfs}", + f"{statistics.fmean(rh['util']):.0f}% / {max(rh['util'])}%" if rh["util"] else "–", + f"{b.get('tx_power') if b.get('tx_power') is not None else '–'}")) + + # ---- charts: dropouts on the eero link per day and by hour of day (per monitored hour) + days_sorted = sorted(hours_by_day) + per_day = Counter(datetime.fromtimestamp(h["start_ts"]).date() for h in link) + day_chart = _bar_chart([d.strftime("%a %d") for d in days_sorted], [per_day.get(d, 0) for d in days_sorted], + "Dropouts on the eero link per day") + per_hod = Counter(datetime.fromtimestamp(h["start_ts"]).hour for h in link) + hod_vals = [per_hod.get(h, 0) / hours_by_hod[h] if hours_by_hod.get(h, 0) >= 0.5 else 0 for h in range(24)] + hod_chart = _bar_chart([f"{(h % 12) or 12}{'a' if h < 12 else 'p'}" for h in range(24)], hod_vals, + "Eero-link dropouts per monitored hour, by time of day", fmt=lambda v: f"{v:.1f}") + day_rows = [row(d.strftime("%a %b %d"), f"{hours_by_day[d]:.1f} h", f"{per_day.get(d, 0)}", + f"{sum(1 for h in beyond if datetime.fromtimestamp(h['start_ts']).date() == d)}") for d in days_sorted] + where_counts = Counter(h["where_text"] for h in hics) + longest_rows = [row(E(_clock(h["start_ts"])), E(_dur(h["end_ts"] - h["start_ts"])), f"{h['lost']}", E(h["where_text"] or "")) + for h in sorted(hics, key=lambda h: h["end_ts"] - h["start_ts"], reverse=True)[:10]] + all_rows = [row(E(_clock(h["start_ts"])), E(_dur(h["end_ts"] - h["start_ts"])), f"{h['lost']}", + _ms(h["worst_ms"]) if h["worst_ms"] else "–", E(h["where_text"] or "")) for h in hics] + + chain = " → ".join(["PC", *[f"{n} eero" for n in path.get("chain") or []], "AT&T gateway", "internet"]) + period = f"{_clock(first_ts)} to {_clock(now)}" + note_html = E(note).replace("\n", "
") if note else "" + + def table(head: list[str], rows: list[str]) -> str: + return f'{row(*head, head=True)}{"".join(rows)}
' if rows else '

No data yet.

' + + return f""" + +Network diagnostic report: eero mesh + +
+ Period:3 days7 days14 days
+

Network diagnostic report: eero mesh

+

{E(period)} · {monitored_h:.0f} hours of monitoring · generated {E(_clock(now))} by HogWatch

+ +

Summary

+
    {"".join(f"
  • {f}
  • " for f in findings) or "
  • No problems recorded in this period.
  • "}
+ +

Notes from the homeowner

+
{note_html}
+ +

Requested work

+{"
    " + "".join(f"
  1. {w}
  2. " for w in work) + "
" if work else '

None: no wiring or layout problems were found.

'} + +

Network layout

+

{E(chain)}

+{table(["eero", "Model", "Link to the network", "Signal", "Devices now", "Firmware", "IP", "Reconnects"], unit_rows)} + +

Measurements from the wired PC

+

One small ping per second to each point on the path. Typical and 95th percentile are taken over 10-second +averages; jitter is the average change between consecutive pings (games and voice calls need it low){ +f", recorded since {E(_clock(jitter_since))}" if jitter_since and jitter_since > first_ts + 3600 else ""}.

+{table(["Ping target", "Typical", "95th pct", "Jitter", "Lost", "Pings"], hop_rows)} + +

Dropouts

+

A dropout is any stretch where the internet stops answering or slows sharply. Its location is the first hop +on the path that stopped answering.

+{table(["Where", "Count"], [row(E(w), f"{n}") for w, n in where_counts.most_common()])} +

Dropouts on the eero link, per day

{day_chart} +{table(["Day", "Monitored", "On the eero link", "Past the eeros"], day_rows)} +

By time of day (dropouts per monitored hour)

{hod_chart} +

Longest dropouts

+{table(["When", "Length", "Pings lost", "Where"], longest_rows)} + +

eero reconnects

+

Each eero reports when it last (re)connected to eero's servers; a new time means its link dropped, even if +that happened between pings.

+{table(["When", "eero"], [row(E(_clock(e["ts"])), E(e["unit"])) for e in events[-40:]])} +{f'

Showing the latest 40 of {len(events)}.

' if len(events) > 40 else ""} + +

Ethernet ports

+

Each eero's ports, checked every 2 minutes{f" since {E(_clock(samples[0]['ts']))}" if samples else ""}.

+{table(["eero", "Port", "Use", "Checks with a link", "Link speed"], port_rows)} + +

Radios and channels

+{table(["eero", "Band", "Channel", "Width", "Busy (avg / max)", "Tx power"], radio_rows)} +{"

Channel changes

" + table(["When", "eero", "Band", "From", "To"], [row(E(_clock(t)), E(u), E(b), f"ch {a[0]} / {a[1]} MHz", f"ch {c[0]} / {c[1]} MHz") for t, u, b, a, c in channel_changes]) if channel_changes else ""} +{"

Backhaul changes

" + table(["When", "eero", "From", "To"], [row(E(_clock(t)), E(u), "cable" if a[0] else f"{E(a[1] or 'wireless')} to {E(a[2] or '?')}", "cable" if c[0] else f"{E(c[1] or 'wireless')} to {E(c[2] or '?')}") for t, u, a, c in backhaul_changes]) if backhaul_changes else ""} + +

Internet line

+{table(["eero speed test", "Download", "Upload"], [row(E(t["date"] or "–"), f"{t['down']:.0f} Mbps", f"{t['up']:.0f} Mbps") for t in tests[-8:]])} +{"" if not links_pc else "

PC network-card events

" + table(["When", "Event"], [row(E(_clock(l["ts"])), E(l["what"])) for l in links_pc])} + +

How this was measured

+

HogWatch runs on a PC wired to the {E(node or gw)} eero. It sends one small ping per second to each eero, +the AT&T gateway and two internet servers, and reads each eero's own status from eero's cloud service (the same data the +eero app shows) every 2 minutes. It records timing and link status only: no traffic contents, browsing or personal +information are included in this report.

+ +

Appendix: every dropout in this period ({len(hics)})

+{table(["When", "Length", "Pings lost", "Worst ping", "Where"], all_rows[-400:])}
+ +""" + + +def find_browser() -> str | None: + """Edge or Chrome, for printing the report to PDF without opening a window.""" + for p in (r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", + r"C:\Program Files\Microsoft\Edge\Application\msedge.exe", + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe"): + if Path(p).exists(): + return p + return shutil.which("msedge") or shutil.which("chrome") + + +def save_pdf(html_path: Path, pdf_path: Path, profile_dir: Path) -> bool: + """Print the HTML report to PDF with a headless browser. Returns False if none is available.""" + browser = find_browser() + if not browser: + return False + pdf_path.unlink(missing_ok=True) + subprocess.run([browser, "--headless=new", "--disable-gpu", "--no-first-run", "--no-pdf-header-footer", + "--print-to-pdf-no-header", f"--user-data-dir={profile_dir}", f"--print-to-pdf={pdf_path}", + html_path.resolve().as_uri()], timeout=120, capture_output=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + return pdf_path.exists() and pdf_path.stat().st_size > 0 diff --git a/hogwatch/report.py b/hogwatch/report.py index 606413d..6888ffb 100644 --- a/hogwatch/report.py +++ b/hogwatch/report.py @@ -24,6 +24,7 @@ from pathlib import Path from . import secret +from .db import HICCUP_END from .incidents import fmt_mbps log = logging.getLogger(__name__) @@ -157,7 +158,8 @@ def _advice(where: str, path: dict | None) -> str | None: 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)) + hiccups = db.query(f"SELECT start_ts, {HICCUP_END} AS end_ts, where_, where_text, lost, worst_ms, busy " + "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)) @@ -170,7 +172,7 @@ def build_report(db, since: float, until: float) -> dict: 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"]) + # Games played are deliberately left out: this report may be forwarded to others. down_secs = sum(h["end_ts"] - h["start_ts"] for h in hiccups) advice = None if hiccups: @@ -211,8 +213,7 @@ def how_bad(h) -> str: 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 "")) + lines.append(f"Dropouts (lag spikes): {nd}, about {_dur(down_secs)} offline in total") for where, n in where_counts.most_common(): lines.append(f" {n} {where_text[where]} ({_dur(where_secs[where])})") lines.append(f"Slowdowns: {ns}") @@ -222,7 +223,7 @@ def how_bad(h) -> str: 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)}") + f". Busy: {busy_text(h)}") if incidents: lines += ["", "SLOWDOWNS"] for i in incidents: @@ -245,7 +246,7 @@ def how_bad(h) -> str: parts.append('

✔ No slowdowns or dropouts. Everything ran normally.

') else: parts.append(f'

{nd} dropout{"s" if nd != 1 else ""} ' - f'(about {e(_dur(down_secs))} offline{f", {in_game} during a game" if in_game else ""}) and ' + f'(about {e(_dur(down_secs))} offline) and ' f'{ns} slowdown{"s" if ns != 1 else ""}.

    ') for where, n in where_counts.most_common(): parts.append(f"
  • {n} {e(where_text[where])} ({e(_dur(where_secs[where]))})
  • ") @@ -256,11 +257,11 @@ def how_bad(h) -> str: if hiccups: parts.append(f'

    Dropouts

    ' f"" - f"") + f"") for h in hiccups: parts.append(f"" f"" - f"") + f"") parts.append("
    WhenLengthHow badWhereGameBusy on the network
    Busy on the network
    {e(_clock(h['start_ts']))}{e(_dur(h['end_ts'] - h['start_ts']))}{e(how_bad(h))}{e(h['where_text'] or '')}{e(h['game'] or '–')}{e(busy_text(h))}
    {e(busy_text(h))}
    ") if incidents: parts.append(f'

    Slowdowns

    ' diff --git a/hogwatch/static/index.html b/hogwatch/static/index.html index 4cce94b..b3985c3 100644 --- a/hogwatch/static/index.html +++ b/hogwatch/static/index.html @@ -137,6 +137,18 @@

    Daily email report

    +
    +

    Report for your installer

    +

    A technical report on why the eeros are dropping: the eero layout, how each link performs, + Ethernet port status, bands and channels, and every dropout, with a list of requested work. It leaves out + device names, people, programs and games, so it's safe to send to an outside company.

    + +

    In the report, click "Notes from the homeowner" to add anything you know, then use "Print or save as PDF".

    +
    +

    Why one person can slow down everyone

    When one device fills the connection (a big download, a 4K stream, or a heavy diff --git a/hogwatch/static/style.css b/hogwatch/static/style.css index 76def6c..c0aae3a 100644 --- a/hogwatch/static/style.css +++ b/hogwatch/static/style.css @@ -236,6 +236,9 @@ td .sub { color: var(--muted); font-size: 12px; display: block; overflow-wrap: a .actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } .btn { border: 1px solid var(--border); background: var(--surface-2); padding: 8px 14px; border-radius: 8px; cursor: pointer; font-weight: 600; } .btn.primary { background: var(--ink); color: var(--surface); border-color: transparent; } +a.btn { display: inline-block; text-decoration: none; color: var(--ink); } +a.btn.primary { color: var(--surface); } +.hint { font-size: 13px; color: var(--ink-2); margin-top: 8px; } .btn:disabled { opacity: .6; cursor: default; } #email-status { font-size: 14px; min-height: 1.5em; } #email-status.error { color: var(--critical-ink); } diff --git a/hogwatch/web.py b/hogwatch/web.py index 497ccb8..616b970 100644 --- a/hogwatch/web.py +++ b/hogwatch/web.py @@ -14,6 +14,8 @@ from pathlib import Path from urllib.parse import parse_qs, urlparse +from .db import HICCUP_END +from .installer import build_installer_report from .report import ReportError, build_report log = logging.getLogger(__name__) @@ -119,13 +121,15 @@ def usage(db, hours: float) -> dict: def hiccups(db, hours: float) -> dict: """Short lag spikes in the window: counts by where they happened, the list, eero reconnects, and the mesh path.""" start = time.time() - hours * 3600 - items = db.query("SELECT * FROM hiccup WHERE start_ts >= ? ORDER BY start_ts DESC LIMIT 300", (start,)) + items = db.query(f"SELECT id, start_ts, {HICCUP_END} AS end_ts, where_, where_text, rounds, lost, worst_ms, " + "worst_lan_ms, worst_node_ms, game, busy FROM hiccup WHERE start_ts >= ? " + "ORDER BY start_ts DESC LIMIT 300", (start,)) for h in items: h["busy"] = json.loads(h["busy"]) if h["busy"] else [] summary = db.query( # Grouped by the text too, so spikes from before and after a change (e.g. the # wireless link being replaced by a cable) are counted separately. - "SELECT where_, where_text, COUNT(*) AS n, SUM(end_ts - start_ts) AS secs, " + f"SELECT where_, where_text, COUNT(*) AS n, SUM({HICCUP_END} - start_ts) AS secs, " "SUM(CASE WHEN game IS NOT NULL THEN 1 ELSE 0 END) AS in_game " "FROM hiccup WHERE start_ts >= ? GROUP BY where_, where_text ORDER BY n DESC", (start,)) events = db.query("SELECT ts, unit, kind, detail FROM eero_event WHERE ts >= ? ORDER BY ts DESC", (int(start),)) @@ -189,6 +193,9 @@ def do_GET(self): # noqa: N802 (http.server naming) # Settings never include the password itself, only whether one is saved. return self._json({"settings": rep.settings.load(), "status": rep.status()} if rep else {"error": "starting"}) + if url.path == "/installer-report": + page = build_installer_report(db, days=min(max(float(q.get("days", 7)), 1), 14)) + return self._send(200, page.encode("utf-8"), "text/html; charset=utf-8") if url.path == "/api/email/preview": now = time.time() body = build_report(db, now - min(float(q.get("hours", 24)), 24 * 7) * 3600, now)["html"] diff --git a/tests/test_installer.py b/tests/test_installer.py new file mode 100644 index 0000000..3be7d7d --- /dev/null +++ b/tests/test_installer.py @@ -0,0 +1,199 @@ +"""Tests for the installer report and the extra monitoring behind it. + +Run with: .venv\\Scripts\\python.exe -m unittest discover -s tests +""" + +import gzip +import json +import sqlite3 +import tempfile +import time +import unittest +from pathlib import Path + +from hogwatch.db import DB +from hogwatch.eero import _body, parse_unit +from hogwatch.incidents import HiccupTracker +from hogwatch.installer import _route_label, build_installer_report, channel_block, touches_dfs + +NOW = time.time() +LINK5 = "on the wireless link between the Upstairs and Main eeros, 5 GHz" +LINK6 = "on the wireless link between the Upstairs and Main eeros, 6 GHz" +VIA = "on the links between the eeros (Upstairs → Den → Main)" + + +def unit_raw(name, gateway, wired, upstream=None, port2_carrier=False, ip="192.168.4.2"): + """A raw /eeros record as the eero API returns it.""" + return { + "location": name, "gateway": gateway, "wired": wired, "connection_type": "WIRED" if wired else "WIRELESS", + "ip_address": ip, "model": "eero Pro 6E", "os_version": "v7.16.2-80", "mesh_quality_bars": 5, + "status": "green", "connected_wired_clients_count": 1, "connected_wireless_clients_count": 6, + "wireless_upstream_node": {"name": upstream, "primary_mesh_radio": "5GHz"} if upstream else None, + "uptime": {"since_cloud_connection_s": 600, "since_last_reboot_s": 86400}, + "radio_channel_stats": { + "band_2_4GHz": {"channel": 11, "channel_width": 20, "channel_utilization": 49, "client_count": 2, "tx_power": 29}, + "band_5GHz_full": {"channel": 36, "channel_width": 160, "channel_utilization": 30, "client_count": 4, "tx_power": 24}, + "band_6GHz": {"channel": 37, "channel_width": 160, "channel_utilization": 5, "client_count": 0, "tx_power": 18}, + }, + "ethernet_status": {"statuses": [ + {"port_name": 1, "hasCarrier": gateway, "speed": "P2500" if gateway else "P10", "isWanPort": gateway}, + {"port_name": 2, "hasCarrier": port2_carrier, "speed": "P1000" if port2_carrier else "P10", "isWanPort": False}, + ]}, + } + + +class ChannelTests(unittest.TestCase): + def test_channel_blocks(self): + self.assertEqual(channel_block(36, 160), [36, 40, 44, 48, 52, 56, 60, 64]) + self.assertEqual(channel_block(52, 80), [52, 56, 60, 64]) + self.assertEqual(channel_block(149, 80), [149, 153, 157, 161]) + self.assertEqual(channel_block(44, 40), [44, 48]) + self.assertEqual(channel_block(36, 20), [36]) + + def test_dfs(self): + self.assertTrue(touches_dfs("5 GHz", 36, 160)) # 36-64 includes 52-64 + self.assertFalse(touches_dfs("5 GHz", 36, 80)) # 36-48 only + self.assertTrue(touches_dfs("5 GHz", 100, 80)) + self.assertFalse(touches_dfs("5 GHz", 149, 80)) + self.assertFalse(touches_dfs("6 GHz", 37, 160)) # DFS is a 5 GHz thing + self.assertFalse(touches_dfs("2.4 GHz", 11, 20)) + + def test_route_labels(self): + self.assertEqual(_route_label(LINK5), "direct over 5 GHz") + self.assertEqual(_route_label(VIA), "via Upstairs → Den → Main") + self.assertEqual(_route_label("on the cable between the Upstairs and Main eeros"), "direct by cable") + + +class UnitParsingTests(unittest.TestCase): + def test_health_fields(self): + u = parse_unit(unit_raw("Upstairs", False, False, "Main"), now=1_000_000) + self.assertEqual(u["bands"]["5 GHz"], {"channel": 36, "width": 160, "utilization": 30, "clients": 4, "tx_power": 24}) + self.assertEqual(u["ports"], [{"port": "1", "carrier": False, "speed": None, "wan": False}, + {"port": "2", "carrier": False, "speed": None, "wan": False}]) + self.assertEqual((u["bars"], u["firmware"], u["wireless_clients"]), (5, "v7.16.2-80", 6)) + self.assertEqual(u["rebooted_ts"], 1_000_000 - 86400) + gw = parse_unit(unit_raw("Main", True, True), now=1_000_000) + self.assertEqual(gw["ports"][0], {"port": "1", "carrier": True, "speed": 2500, "wan": True}) + + def test_gzip_responses_are_decoded(self): + class Resp: + headers = {"Content-Encoding": "gzip"} + + def read(self): + return gzip.compress(b'{"data": 1}') + self.assertEqual(_body(Resp()), b'{"data": 1}') + + +class SleepGapTests(unittest.TestCase): + def test_flush_ends_dropout_at_last_bad_ping(self): + t = HiccupTracker(interval_s=1.0) + for i in range(4): + t.feed(1000 + i, 1.0, True, None, True, None, None, 100) + h = t.flush() # the PC went to sleep here + self.assertEqual((h["start_ts"], h["end_ts"], h["where"]), (1000, 1004, "eero_link")) + self.assertIsNone(t.flush()) + + +class MigrationTests(unittest.TestCase): + def test_old_database_gains_jitter_column(self): + with tempfile.TemporaryDirectory() as tmp: + p = Path(tmp) / "old.db" + c = sqlite3.connect(p) + c.execute("CREATE TABLE latency (ts INTEGER NOT NULL, role TEXT NOT NULL, sent INTEGER NOT NULL, " + "lost INTEGER NOT NULL, avg_ms REAL, max_ms REAL)") + c.execute("INSERT INTO latency VALUES (1, 'lan', 10, 0, 5.0, 9.0)") + c.commit() + c.close() + db = DB(p) + cols = {r["name"] for r in db.query("PRAGMA table_info(latency)")} + self.assertIn("jitter_ms", cols) + self.assertEqual(db.query("SELECT COUNT(*) n FROM latency")[0]["n"], 1) # data kept + db.conn.close() + + +class InstallerReportTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + db = self.db = DB(Path(self.tmp.name) / "t.db") + units = [parse_unit(unit_raw("Main", True, True, ip="192.168.4.1"), NOW), + parse_unit(unit_raw("Upstairs", False, False, "Main"), NOW), + parse_unit(unit_raw("Den", False, False, "Upstairs", ip="192.168.4.3"), NOW)] + path = {"node": "Upstairs", "gateway": "Main", "chain": ["Upstairs", "Main"], "radio": "5 GHz", "wired": False, + "on_link_units": ["Upstairs", "Den"], + "units": [{k: u[k] for k in ("name", "gateway", "wired", "upstream", "radio", "model", "ip")} for u in units]} + db.set_meta("eero_path", json.dumps(path)) + db.set_meta("targets", json.dumps({"lan": "192.168.4.1", "isp": "192.168.1.254", "node": "192.168.4.2"})) + db.set_meta("link_watch_since", str(int(NOW - 3 * 86400))) + db.execute("INSERT INTO speedtest VALUES ('2026-10-01T09:00:00Z', ?, 968.0, 630.0)", (int(NOW - 86400),)) + for k in range(5): # unit snapshots every 2 minutes + db.executemany("INSERT INTO unit_sample(ts, unit, gateway, wired, upstream, radio, bars, status, firmware, " + "wired_clients, wireless_clients, bands, ports) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", + [(int(NOW - 600 + k * 120), u["name"], int(u["gateway"]), int(u["wired"]), u["upstream"], + u["radio"], u["bars"], u["status"], u["firmware"], u["wired_clients"], + u["wireless_clients"], json.dumps(u["bands"]), json.dumps(u["ports"])) for u in units]) + # Two days of 10-second ping buckets for every hop. + rows = [] + for i in range(0, 2 * 86400, 10): + ts = int(NOW - 2 * 86400 + i) + rows += [(ts, "internet", 10, 0, 16.0, 30.0, 2.0), (ts, "node", 10, 0, 0.4, 1.0, 0.1), + (ts, "lan", 10, 0, 6.0, 25.0, 4.0), (ts, "isp", 10, 3, 7.0, 12.0, 1.0)] + db.executemany("INSERT INTO latency(ts, role, sent, lost, avg_ms, max_ms, jitter_ms) VALUES(?,?,?,?,?,?,?)", rows) + # Dropouts on the eero link over three routes, one past the eeros, and one that + # (before the sleep fix) stretched across 38 hours of the PC sleeping. + hic = [] + for i, text in enumerate([LINK5] * 5 + [LINK6] * 3 + [VIA] * 2): + s = NOW - 86400 + i * 3600 + hic.append((s, s + 8, "eero_link", text, 8, 8, None, None, 0.4, "League of Legends", '[{"name": "JAKE-DESKTOP"}]')) + hic.append((NOW - 7200, NOW - 7198, "internet", "past your eeros (AT&T's line or the wider internet)", 2, 1, 300.0, None, 0.4, None, "[]")) + hic.append((NOW - 40 * 3600, NOW - 40 * 3600 + 136_522, "pc_link", "between this PC and the Upstairs eero", 11, 11, None, None, None, None, "[]")) + db.executemany("INSERT INTO hiccup(start_ts, end_ts, where_, where_text, rounds, lost, worst_ms, worst_lan_ms, " + "worst_node_ms, game, busy) VALUES(?,?,?,?,?,?,?,?,?,?,?)", hic) + db.executemany("INSERT INTO eero_event(ts, unit, kind, detail) VALUES(?,?,'reconnected','{}')", + [(int(NOW - 3000 * k), "Upstairs") for k in range(1, 6)] + [(int(NOW - 5000), "Den")]) + # Personal data that must never reach the report. + db.execute("INSERT INTO eero_device VALUES('aa:bb', 'Sam''s iPhone', 'Sam', '192.168.4.50', 'Apple', 'phone', 'wifi', 'Den', ?)", (int(NOW),)) + db.execute("INSERT INTO pc_app VALUES(?, 'Discord', 1000000, 1000000)", (int(NOW - 60),)) + db.execute("INSERT INTO incident(start_ts, end_ts, kind, headline, details) VALUES(?, ?, 'congested', " + "'Biggest user: JAKE-DESKTOP (Jake), uploading', '{}')", (NOW - 5000, NOW - 4900)) + self.html = build_installer_report(db, days=7, note="Both ends of the cable are plugged in.", now=NOW) + + def tearDown(self): + self.db.conn.close() + self.tmp.cleanup() + + def test_no_personal_information(self): + for private in ("JAKE-DESKTOP", "Jake", "Sam", "iPhone", "Discord", "League of Legends", "Apple", "aa:bb"): + self.assertNotIn(private, self.html) + + def test_findings(self): + h = self.html + self.assertIn("The wireless link between the Upstairs and Main eeros keeps dropping", h) + self.assertIn("10 dropouts", h) + self.assertIn("never linked", h) # port 2 on both eeros: 0 of 5 checks + self.assertIn("radar-shared (DFS)", h) # 36/160 backhaul + self.assertIn("eero isn't using 6 GHz for this hop", h) + self.assertIn("route to the Main eero kept changing", h) + self.assertIn("direct over 5 GHz (5)", h) + self.assertIn("via Upstairs → Den → Main (2)", h) + self.assertIn("IP Passthrough", h) # 192.168.1.254 is private: double NAT + self.assertIn("968 Mbps down", h) + self.assertIn("Den eero relays through the Upstairs eero", h) + self.assertIn("hasn't gone down since", h) + + def test_requested_work_and_note(self): + self.assertIn("Repair the Ethernet run between the Main and the Upstairs", self.html) + self.assertIn("Both ends of the <b>cable</b> are plugged in.", self.html) # escaped + + def test_sleep_stretched_dropout_is_capped(self): + self.assertNotIn("2275 min", self.html) + self.assertNotIn(" hr", self.html.split("Longest dropouts")[1].split("

    ")[0]) + + def test_unmeasured_location_is_labelled(self): + # Remove the main-eero pings around the internet-side dropout: it must not be blamed on AT&T. + self.db.execute("DELETE FROM latency WHERE role = 'lan' AND ts BETWEEN ? AND ?", (NOW - 7300, NOW - 7100)) + html = build_installer_report(self.db, days=7, now=NOW) + self.assertIn("location not measured", html) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_report.py b/tests/test_report.py index bea967b..208cb88 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -98,8 +98,10 @@ def test_counts_reasons_and_advice(self): self.assertEqual(r["counts"], {"dropouts": 5, "slowdowns": 1, "reconnects": 1}) self.assertTrue(r["subject"].startswith("HogWatch: 5 dropouts and 1 slowdown since")) self.assertIn(f"4 {LINK}", r["text"]) - self.assertIn("1 during a game", r["text"]) - self.assertIn("Game: League of Legends", r["text"]) + # Games are left out of reports: they may be forwarded to other people. + for body in (r["text"], r["html"]): + self.assertNotIn("League of Legends", body) + self.assertNotIn("game", body.lower()) self.assertIn("The connection dropped out between your eeros.", r["text"]) self.assertIn("Ethernet cable between those two eeros", r["text"]) # 4 of 5 on one link -> advice self.assertIn("Upstairs eero reconnected", r["text"]) From 08018c1658750603fc961d4c03a91aa5f7c5738f Mon Sep 17 00:00:00 2001 From: Michael Saba Date: Fri, 2 Oct 2026 20:51:06 -0400 Subject: [PATCH 2/2] docs: describe the installer report and what HogWatch monitors Co-Authored-By: Claude Opus 5.5 --- README.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7888eb7..2935066 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,34 @@ whoever owns the eero, or to AT&T. - **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. +## Report for your installer + +**Report for your installer** on the dashboard (or `python -m hogwatch installer-report`, which also +saves a PDF) builds a technical report for whoever installed or services the eeros: + +- **Summary of findings with numbers.** Which link is dropping and how often, eero reconnects, + whether the Ethernet run between eeros ever links, the band and channel width on each hop + (and whether it overlaps radar-shared DFS channels), mesh route changes, double NAT behind the + ISP gateway, and whether the internet line itself is healthy. +- **Requested work**, generated from those findings. +- **Evidence:** the eero layout, ping delay, jitter and loss to every hop, dropouts by day and time + of day, the longest dropouts, eero reconnects, Ethernet port link checks, radio and channel history, + speed tests, and an appendix listing every dropout. + +The report reads only measurements of the network itself. It never includes device names, people, +programs or games, so it's safe to send to an outside company. Add your own notes in the report +before printing it. + +### What's monitored (all non-invasive) + +- One small ping per second to this PC's eero, the main eero, every other eero, the ISP gateway + and two internet servers, with delay, loss and jitter stored per 10 seconds. +- Each eero's status every 2 minutes, as eero's app reports it: backhaul (cable or wireless band, + and which eero it relays through), signal bars, channel, width, how busy each radio is, connected + device counts, firmware, and every Ethernet port's link state and speed. +- eero reconnects, eero speed tests, and this PC's own network-card link going down or up. +- Nothing about the content of anyone's traffic is captured. + ## Reading the results - **"Internet crawled"**: the line was full. Look at "Biggest user". An **upload** @@ -123,5 +151,5 @@ Settings › eero Labs › **Optimize for Conferencing and Gaming**. - The dashboard has no external scripts, so it keeps working while the internet is down. - 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` +- Command line: `.venv\Scripts\python.exe -m hogwatch run | eero-login | eero-check | selftest | send-report | installer-report` - Tests: `.venv\Scripts\python.exe -m unittest discover -s tests`