feat: server-side bandwidth via data_usage endpoint (#123)#124
Conversation
Integrates eero data_usage API to fix bandwidth undercount. Adds: - DataUsageCollector polling eero data_usage endpoint hourly - Migration 010: hourly_bandwidth table + source column on daily_bandwidth - EeroClient.get_data_usage() method - Cleanup/retention for hourly_bandwidth data - Health analytics for data usage collection stats Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Merlin 🪄 <noreply@anthropic.com>
Co-Authored-By: Merlin 🪄 <noreply@anthropic.com>
Code ReviewThis PR replaces instantaneous-rate bandwidth accumulation with server-computed hourly totals from eero's `/data_usage` endpoint. The overall approach is sound and the new `DataUsageCollector` is well-structured, but there are three confirmed bugs and two plausible issues worth addressing before merge. 🔴 Confirmed bugs1. self.scheduler.add_job(
func=self._run_data_usage_collector,
trigger=IntervalTrigger(seconds=device_interval), # 30 s default
...
)The Fix: Schedule with 2. if bandwidth_record.source == "data_usage":
bandwidth_record.last_collection_time = timestamp
return # rate accumulation permanently skippedOnce a Fix: Either (a) reset 3. if hourly_records:
# populate from HourlyBandwidth only
else:
# fallback to rate accumulationThe fallback triggers only when Fix: Fall back on a per-hour basis: if a specific hour is absent from 🟡 Plausible issues4. offset_hours = int(offset_seconds / 3600) # truncates +05:30 → 5, not 5.5
local_hour = (rec.hour_start.hour + offset_hours) % 24Users in IST (UTC+5:30), NPT (UTC+5:45), IRST (UTC+3:30), or Australian half-hour zones will have UTC records assigned to the wrong local hour bucket. This truncation existed in the old rate-accumulation path; the new primary path copies it unchanged. Not a regression, but now affects more users (those with 5.
Minor notes
|
Code Review — feat: server-side bandwidth via data_usage endpointThis PR makes a solid architectural improvement: replacing rate-integrated polling with eero's server-computed hourly totals eliminates the systematic undercounting problem and is clearly the right direction. The collector structure, migration, fallback path, and cleanup wiring are all well thought out. The findings below are edge cases and efficiency concerns rather than fundamental design issues. Findings (most severe first)1. When # fix: add a rollback in the inner except
except Exception as e:
logger.error(f"Error collecting data usage for '{network_name}': {e}", exc_info=True)
errors += 1
self.db.rollback() # discard staged work for this network2. today_end = today_start + timedelta(days=1) - timedelta(seconds=1) # 23:59:59
3. offset_hours = int(offset_seconds / 3600) # IST +5:30 → 5, not 5.5For users in IST (+5:30), Nepal (+5:45), Iran (+3:30), or any other fractional-hour timezone, the 30–45 minute residual is discarded. If the eero API returns local-hour-aligned timestamps in UTC (e.g., for rec in hourly_records:
hour_start_aware = rec.hour_start.replace(tzinfo=timezone.utc)
local_hour = hour_start_aware.astimezone(local_tz).hourThe same truncation exists in the fallback path (~line 1059) and is carried over unchanged from the old code — worth fixing both. 4. Each call does its own 5. trigger=IntervalTrigger(seconds=device_interval), # same 30 s default as device collectorEvery run issues at least 2 eero API calls and up to 48+ DB upserts. Since the hourly bucket values only advance once per hour, running at 30 s is ~120× more frequent than necessary. A separate 6. One 7. device = self.db.query(Device).filter(...mac_address == mac).first()One query per device entry from the API response. Pre-loading all devices for the network into a 8. The method is a copy of Minor / Non-blocking
Overall this is a well-motivated change and the core approach is sound. The partial-commit bug (#1) and the midnight-crossing issue (#4) are the two I'd fix before merging; the rest can be addressed in follow-ups. 🤖 Generated with Claude Code |
The data_usage endpoint is now the sole source of bandwidth data. Removes the old rate × time_delta accumulation that caused #123. - Strip _update_bandwidth_accumulation() and all callers from DeviceCollector - Remove DailyBandwidth.source and .last_collection_time fields - Remove DeviceConnection-based fallback in /network/bandwidth-hourly - Drop source column from migration 010 - Remove source writes from DataUsageCollector (now the only writer) - Delete test_bandwidth_accumulation.py and related test cases Co-Authored-By: Merlin 🪄 <noreply@anthropic.com>
Track last-fetch state to compute real-time throughput deltas between polls. Handles hour boundaries, day rollovers, and gap recovery. Co-Authored-By: Merlin 🪄 <noreply@anthropic.com>
Code Review — PR #124: server-side bandwidth via
|
Code ReviewThis PR replaces the previous rate-based bandwidth accumulation (polling instantaneous Mbps snapshots and integrating over time) with a new Findings (most severe first)🔴
|
- Schedule DataUsageCollector hourly (was 30s, wasting ~119 API calls/hr) - Compute today_window once in collect(), pass to both helpers (midnight race fix) - Use exclusive-midnight boundary (timedelta(days=1) not -1s) - Rollback staged DB work on per-network errors (partial commit fix) - Pre-fetch devices per network to eliminate N+1 queries - Fix fractional timezone offset truncation in analytics hourly endpoint (IST +5:30 etc. now uses proper datetime conversion) - Fix double-counting in get_network_bandwidth_total (filter device_id=NULL) - Persist DataUsageCollector instance across scheduler ticks (throughput delta fix) - Consolidate three near-identical client methods into _request_data_usage helper - Remove unused get_data_usage_eeros method (dead code) - Fix contradictory GAP_WARNING/GAP_RECOVERY log messages (use elif) - Fix test_data_usage.py AttributeError on removed last_collection_time column - Fix _update_daily_from_server type hint (object → date) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Review Feedback ResolvedAll findings from the code reviews have been addressed in commit High severity — fixed
Medium severity — fixed
Low severity — fixed
Noted but not addressed in this commit
Authored by Merlin 🪄 |
The API now queries DailyBandwidth records where device_id IS NULL (network-wide totals) to avoid double-counting, but the test was seeding a per-device record that the query would never match. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Code Review — feat: server-side bandwidth via data_usage endpoint (#123)This PR replaces rate-based bandwidth accumulation (polling instantaneous Mbps snapshots) with 🔴 Critical — Multi-network rollback silently discards successful data
for network in networks:
try:
self._collect_network_usage(...)
self._collect_device_usage(...)
networks_processed += 1
except Exception as e:
...
self.db.rollback() # ← rolls back ALL pending changes, not just network B's
self.db.commit() # ← commits nothing if any network failedThe session is created with Fix: Wrap each network in a try:
with self.db.begin_nested():
self._collect_network_usage(...)
self._collect_device_usage(...)
networks_processed += 1
except Exception as e:
logger.error(...)
errors += 1🔴 High —
|
…rly via cron The eero data_usage endpoint updates hourly, not in real-time. The ThroughputSnapshot / delta-tracking logic produced stale or zero values when polled sub-hourly and is removed. The collector now simply stores server-computed hourly breakdowns and daily totals on each run. Scheduler changed from IntervalTrigger(3600s) to CronTrigger(minute=5) so collection runs at :05 past each hour, after eero finalizes the previous hour's bucket. Instance caching removed since no cross-run state is needed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Code Review — PR #124: feat: server-side bandwidth via data_usage endpointThis PR replaces the old rate-accumulation approach in 🔴 Critical
The PR removes
The session is a single flat SQLAlchemy transaction (no savepoints). When network B's collection fails and 🟠 High
🟡 Medium
During the autumn DST transition, two distinct UTC hours (e.g., 06:00 UTC and 07:00 UTC) both map to 🔵 Low / Cleanup
One
This file calls
Both files independently define Summary
The approach is right — server-computed totals are strictly better than rate accumulation. The critical items above (GET body, missing migration, session rollback) need to be resolved before this can ship safely. 🤖 Review generated with Claude Code |
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Code ReviewThis PR introduces a 🔴 Critical
Fix: switch to
except Exception as e:
self.db.rollback() # rolls back the entire session — including network A's adds
errors += 1
self.db.commit() # commits nothing (session was just rolled back)
Fix: use a savepoint per network, or commit after each network succeeds: self._collect_network_usage(network_name, today_window)
self._collect_device_usage(network_name, today_window)
self.db.commit() # commit this network's data before moving on🟠 High
CREATE UNIQUE INDEX uix_network_device_hour ON hourly_bandwidth (network_name, device_id, hour_start)Per the SQL standard (which SQLite follows), Fix: SQLite 3.37+ supports 🟡 Medium
local_hour = hour_start_utc.astimezone(tz).hour
hourly_data[local_hour] = { ... } # plain dict assignment, not accumulationDuring the one-hour DST fall-back, two consecutive UTC hours map to the same local hour integer. The second assignment silently overwrites the first. The affected hour (e.g., Cleanup
Review generated with Claude Code |
Summary
DataUsageCollectorthat polls eero's/data_usageAPI endpoint, which returns server-computed hourly bandwidth totals updated in near-real-time — eliminating undercount from polling gaps entirely.DataUsageCollector,HourlyBandwidthmodel, Alembic migration 010DeviceCollector(falls back to rate-based when data_usage unavailable), analytics API (serves hourly bandwidth data), scheduler (hourly collection job)Test plan
/data_usageendpoint returns real-time hourly totals from eero APICloses #123
Authored by Merlin 🪄