| Product | Starwatch — Starlink dish monitoring + management for OpenWrt routers, with WAN-link health and failover assistance |
| Platforms | OpenWrt 23.05+ (LuCI) and GL.iNet SDK4 firmware (native Applications panel entry). First target: GL-X3000 Spitz AX (aarch64_cortex-a53); arch is a build knob |
| Status | Released v0.1.1 — 2026-07-18 |
| Components | starwatchd (Go daemon) · luci-app-starwatch · gl-app-starwatch · opkg feed |
| Sources | Starlink local gRPC API (community-documented: sparky8512/starlink-grpc-tools, clarkzjw/starlink-grpc-golang protos) · Speedify OpenWrt integration (support.speedify.com articles 918/922/235/241/1066, sagar.se teardown) · starbar.app feature set · peakdo/router (wattline) packaging + GL-panel learnings, verified on a GL-X3000 |
Grounding rule used throughout: every dish telemetry field, control, and behavior in this spec cites the local gRPC API surface documented and exercised by the starlink-grpc-tools community. UI structure decisions cite the Speedify dashboard model or Starbar's card inventory. Packaging decisions cite what was live-verified on-target in the wattline project. Where a decision is ours alone, the spec says Assumption: or Decision: explicitly.
Starwatch turns an OpenWrt router into a full-time Starlink monitor and control panel. A small Go daemon (starwatchd) polls the dish's local gRPC API (192.168.100.1:9200, plaintext, no account needed), keeps long-horizon history the dish itself cannot (the dish buffers only ~15 minutes), watches the Starlink WAN path with its own probes, raises alerts over webhook/ntfy, and serves a single polished web dashboard. That one dashboard is embedded into LuCI (menu entry) and into the GL.iNet admin panel (Applications entry) — the Speedify model: one SPA, hosted by the daemon, surfaced natively in whatever admin UI the platform has.
No Starlink account, no cloud API, no telemetry leaves the router. Everything works on a router sitting behind a dish in bypass mode — the normal OpenWrt + Starlink setup.
- Everything Starbar shows, on the router. Latency, ping success, throughput, power draw, obstruction stats + sky map, alignment, hardware/firmware info, events/outages — all from the local gRPC API, all rendered honestly ("unavailable" beats a guess).
- Everything the local API can control, that Starbar can't. Stow/unstow, reboot, snow-melt mode, sleep schedule, GPS toggle, clear obstruction map, firmware update, dish speed test — with destructive actions gated behind confirmation.
- History that outlives the dish's 15-minute buffer. 30 days of graphs and a persistent outage log, stored flash-gently (RAM ring + downsampled sqlite flush).
- WAN-link truth. Independent latency/loss probes out the Starlink interface so "dish healthy, path bad" and "dish outage" are distinguishable; merged outage timeline; optional mwan3 awareness and a one-click Starlink-primary/cellular-backup assist on GL.iNet.
- Speedify-grade looks. Icon-rail dashboard with live scrolling graphs pushed over WebSocket at 1 Hz — not a LuCI form page.
- Alerting that wakes you up. Outages, obstruction growth, thermal events, water detection, stuck motors → webhook + ntfy, with in-UI alert history.
- No Starlink cloud/account API. Billing, service plans, data quotas, remote (off-LAN) dishes, fleet management — out of scope permanently for this tool.
- No channel bonding / VPN. We borrow Speedify's UX, not its product. Starwatch never routes traffic.
- No general automatic routing changes. The one narrow exception is the self-healing, VPN-bypassing
192.168.100.1/32dish-management host route. Starwatch may maintain only that exact route (and users may disable it withmanage_dish_route=0); it never rewrites default/general routing or firewall config. The mwan3 assist remains an explicit user action. - No factory reset button. The API offers it; we deliberately don't expose it.
- No general rules engine. Fixed alert catalog with thresholds (§7). Condition→action rules (wattline-style) are a future-release candidate (§14).
- Curated Starlink-router Wi-Fi management only. Topology B may expose ordinary Wi-Fi settings and client controls through guarded, audited, readback-confirmed writes. Factory, regulatory, mesh-trust, bypass/routing, firewall, DHCP, aviation, calibration, and debug surfaces remain excluded; see
API.md.
| Persona | Situation | What Starwatch gives them |
|---|---|---|
| GL-X3000 / dual-WAN nomad | Starlink primary, cellular backup, RV/boat | Merged outage timeline, failover assist, obstruction map to aim the dish at a new campsite, power-draw card for battery budgeting |
| Off-grid homestead | Starlink + solar; router is always-on anyway | 30-day history, snow-melt control from the couch, ntfy alerts when the dish thermally throttles or an outage starts |
| Homelab / remote worker | Starlink as primary ISP, OpenWrt box, uptime matters | Persistent outage log with causes for ISP disputes, drop-rate alerts before a video call dies, speed tests on demand |
| Tinkerer with scripts | Wants dish data in Grafana/HA | Token-authed local REST + WS API (§9); everything the UI shows is fetchable as JSON |
| Official Starlink app | Starbar (macOS) | Starwatch | |
|---|---|---|---|
| Runs on | Phone | Mac menu bar | The router itself — always on |
| History | Limited, cloud | Dish's ~15 min buffer | 30 days on-router (§8) |
| Controls | Full (incl. account) | None (read-only) | All local-API controls (§5) |
| Alerts | Push (limited) | None | Webhook/ntfy catalog (§7) |
| WAN path view | No | No | Independent probes + mwan3 (§6) |
| Needs account | Yes | No | No |
| Where it shows up | — | — | LuCI + GL.iNet Applications |
- Local-only. The daemon's only network peers: the dish, the LAN, and user-configured alert endpoints (webhook/ntfy). Nothing else, ever.
- Honest telemetry. Field availability varies by dish model/firmware (confirmed by Starbar's docs and starlink-grpc-tools issues). Unavailable fields render as "—/unavailable", never as zero or an estimate.
- Read loudly, write carefully. Polling is relentless; controls are explicit, confirmed, and logged (§5.3).
- Degrade gracefully. No dish reachable → explicit Starlink-disconnected UI with no current-data cards. No mwan3 → hide failover card. Unknown dish model → show what parses, flag what doesn't (§11).
┌──────────────────────── OpenWrt router ────────────────────────┐
│ │
Starlink dish │ ┌──────────────────────── starwatchd ─────────────────────┐ │
192.168.100.1 ◄─┼──┤ dish client (gRPC poller + backfill) │ │
:9200 │ │ WAN monitor (probes, counters, mwan3 reader) │ │
│ │ history store (RAM rings → sqlite downsample flush) │ │
│ │ alert engine (catalog §7 → webhook/ntfy) │ │
│ │ HTTP server :9633 (REST + WS + SPA static) [token auth]│ │
│ └─────────────────────────────────────────────────────────┘ │
│ ▲ ▲ │
│ │ iframe + token │ iframe + token │
│ ┌─────┴──────┐ ┌──────┴────────┐ │
│ │ LuCI view │ │ GL oui view │ │
│ │ (menu.d + │ │ (menu.d + │ │
│ │ rpcd ACL) │ │ compiled js) │ │
│ └────────────┘ └───────────────┘ │
└────────────────────────────────────────────────────────────────┘
- Language/runtime: Go, plain cross-compile (
GOOS=linux GOARCH=arm64), static binary, no cgo — sqlite via pure-Gomodernc.org/sqlite. Same no-SDK pipeline as wattline (proven on GL-X3000). - Service: procd init script,
respawn, started at boot. UCI config in/etc/config/starwatch; auci-defaultsscript generates a random API token on first install (wattline pattern, verified). - Dish protos: vendored from
clarkzjw/starlink-grpc-golang(current, full request oneof). SingleHandleRPC with request oneof; plaintext gRPC; no auth for reads. - Process budget: Assumption: < 25 MB RSS steady-state (rings are float32 and bounded, §8.1); one goroutine per poller cadence.
config starwatch 'main'
option listen '0.0.0.0' # all-address bind; firewall limits trusted LAN access
option port '9633'
option token '<generated>' # uci-defaults fills if empty
option dish_addr '192.168.100.1:9200'
option poll_status '1' # seconds
option poll_map '900' # obstruction map refresh, seconds
option wan_iface '' # autodetect (§6.1); override here
option probe_hosts '1.1.1.1 8.8.8.8'
option probe_interval '2'
config history
option ram_hours '3'
option minute_days '7'
option quarter_days '30'
option db_path '/etc/starwatch/history.db'
option flush_secs '300'
config alerts
option webhook_url ''
option ntfy_url '' # e.g. https://ntfy.sh/mytopic
# per-alert thresholds/enables, see §7
Decision: history DB lives under /etc/starwatch/ so it survives sysupgrade with "keep settings" — but see flash-wear budget §8.3 and open question §13.3.
| Topology | Dish reachability | Behavior | |
|---|---|---|---|
| A (primary) | Dish in bypass mode → OpenWrt WAN gets CGNAT/public IP | Needs a host route: 192.168.100.1/32 out the WAN interface |
Full functionality. Package postinst offers the route via a UCI static-route entry if missing (well-known community requirement; documented in starlink-grpc-tools setup guidance) |
| B | OpenWrt behind the Starlink router (double NAT) | The host route must use the Starlink WAN gateway: 192.168.100.1/32 via <wan-gateway>; an interface-only route can select the wrong uplink on multi-WAN routers |
Full dish telemetry; additionally the Starlink router's own gRPC on port 9000 is polled for clients, ping health, configuration, radios, diagnostics, and interfaces. Curated writes follow the guarded API.md contract: client blocking is schedule-based, transmit power uses real TX_POWER_LEVEL_* enums, and whole-network writes remain gated on credential readback. The package derives the wan gateway and emits it in the UCI route; link-scope is reserved for bypass/direct-DHCP setups with no gateway |
| C | No dish reachable (probe fails) | — | Dashboard shows one explicit Starlink disconnected state and no current-data cards or WAN substitute telemetry. Settings and historical Events remain available. Daemon retries discovery every 60 s |
Detection is automatic at startup and on failure: try get_device_info at dish_addr; classify topology; expose it in /api/status and the UI header.
All calls are request-oneof members of SpaceX.API.Device.Device/Handle.
| UI element | gRPC request | Fields used | Cadence |
|---|---|---|---|
| Status header, latency, ping success, throughput, instant power, outage flag | get_status |
pop_ping_latency_ms, pop_ping_drop_rate, downlink_throughput_bps, uplink_throughput_bps, outage, alerts — instant power prefers a positive upsu_stats.dish_power; when absent or zero, falls back to the newest nonzero get_history.power_in sample (refreshed at 60 s cadence) and reports power_source: history |
1 s |
| Obstruction card (numbers) | get_status |
obstruction_stats (fraction obstructed, valid-s, time obstructed) |
1 s (same call) |
| Alignment card | get_status |
boresight_azimuth_deg, boresight_elevation_deg, alignment_stats, tilt_angle |
1 s (same call) |
| Hardware card | get_device_info |
id, hardware_version, software_version, country code, + mobility_class, class_of_service from status |
60 s |
| History backfill on daemon start | get_history |
per-second rings: latency, drop rate, up/down bps, power_in[], outages[] (cause + duration), event_log |
startup + hourly reconciliation |
| Obstruction sky map | dish_get_obstruction_map |
num_rows × num_cols SNR grid, min_elevation_deg, map_reference_frame |
15 min + on-demand |
| Temperatures / radio | get_radio_stats, get_diagnostics |
temps, bands (availability varies by model — render honestly) | 60 s |
| Outage log seed | get_history.outages[] |
cause enum, start, duration | startup + hourly |
| GPS position (opt-in) | get_location |
lat/lon/alt — only works if the user enabled "allow access on local network" in the official app; UI explains this when the call is unauthorized | 60 s when enabled |
| Config readback | dish_get_config |
snow-melt mode, sleep schedule, level mode, location mode, swupdate prefs | 60 s + after every write |
Some requests return errors or not authorized on some hardware/firmware (documented across starlink-grpc-tools; signed_request calls are SpaceX-reserved). Every card in the SPA has an "unavailable" render state; the daemon marks a field unavailable after 3 consecutive failed/absent reads and clears the mark on first success.
| Control | gRPC request | Confirmation | Notes |
|---|---|---|---|
| Reboot dish | reboot |
Typed confirm ("dish will be offline 2–5 min") | |
| Stow / Unstow | dish_stow / dish_stow{unstow:true} |
Confirm | Hidden for models without motors (e.g. Mini/HP flat) once detected via hardware version |
| Snow-melt mode | dish_set_config{snow_melt_mode} |
None (safe) | AUTO / ALWAYS_ON / ALWAYS_OFF; paired apply_* bool set |
| Sleep schedule | dish_set_config{power_save_*} |
None | start/duration in minutes past midnight UTC — UI converts to router-local time and says so |
| GPS enable/disable | dish_inhibit_gps |
Confirm | |
| Clear obstruction map | dish_clear_obstruction_map |
Confirm ("map rebuilds over ~12 h") | |
| Firmware update | software_update |
Confirm (reboot implied) | The released firmware-update-check and firmware-update-apply aliases call this same mutating RPC; "check" is not a read-only check |
| Dish speed test | start_speedtest → poll get_speedtest_status |
None | Availability varies by firmware; button shows "unsupported on this dish" when the RPC errors (§13.2) |
dish_factory_reset / factory_reset / reset_button (destructive, recoverable only physically), dish_set_config.location_request_mode writes (privacy setting belongs to the official app), all signed_request/sensitive_request calls (SpaceX-reserved, would fail anyway).
Every control action is appended to the events table (§8.2) with its time, action, parameters, and result — visible in the Events card and /api/events.
Find the WAN interface carrying the dish: an explicit UCI wan_iface override always wins; otherwise autodetect — the interface holding the route to dish_addr, else the mwan3/netifd interface named wan. Discovery re-runs periodically (60 s) so interface renames are picked up without a restart. Cellular/backup interfaces are discovered from mwan3 config when present.
- ICMP (fallback UDP) probes to
probe_hostsbound to the Starlink WAN interface everyprobe_interval(default 2 s), tracking RTT + loss over 30 s / 5 min windows. - If mwan3 exists, its tracking status is read (
mwan3 interfaces/ ubus) and shown alongside — but Starwatch's own probes are the primary signal, because they run per-interface even without mwan3. - Byte counters from
/sys/class/net/<dev>/statisticsat 1 Hz → per-WAN throughput series (this is router-side throughput; the dish reports its own — both are graphed, labeled distinctly).
One timeline merging three sources, each entry tagged with its source and cause:
- Dish-reported outages (
get_history.outages[]— cause enum, e.g. obstruction / no downlink / no schedule). - Dish unreachable (gRPC failures — usually dish power loss or cabling).
- Path outages (probes failing while the dish claims connectivity — upstream/CGNAT problems the dish can't see).
This three-way distinction is the tool's sharpest diagnostic and is rendered as one bar-chart timeline (§10.4).
- Read-only always: if mwan3 is installed, show interface states, active policy, last failover events.
- One-click assist (explicit action): "Set up Starlink-primary / cellular-backup" writes a minimal, named mwan3 config (members, policy, default rule) after showing the exact UCI diff and getting confirmation. Never runs automatically; never touches existing custom mwan3 config (button disabled with explanation if non-Starwatch mwan3 config exists).
- Decision: GL.iNet's own multi-WAN UI coexists — the assist is skipped (hidden, with a note) when GL's
mwanfeature is managing failover, to avoid two writers.
Fixed set, each individually enable/disable-able with thresholds in UCI + Settings UI. All alerts have hysteresis/dedup: an alert fires once on entry, once on clear (*_resolved), never repeats while active.
| Alert | Trigger (default) | Clears when | Severity |
|---|---|---|---|
outage_started |
Any merged-timeline outage ≥ 30 s | Connectivity restored (also sends duration) | critical |
dish_unreachable |
gRPC down ≥ 60 s | First successful poll | critical |
path_degraded |
Probe loss > 20 % or RTT > 300 ms over 5 min | Below threshold 5 min | warning |
obstruction_high |
fraction obstructed > 2 % (24 h avg) | Below threshold | warning |
thermal_throttle |
alerts.thermal_throttle true |
Flag clears | warning |
thermal_shutdown |
alerts.thermal_shutdown true |
Flag clears | critical |
motors_stuck |
alerts.motors_stuck true |
Flag clears | warning |
water_detected |
alerts.dish_water_detected true |
Flag clears | warning |
mast_not_vertical |
alerts.mast_not_near_vertical true |
Flag clears | info |
slow_ethernet |
alerts.slow_ethernet_speeds true |
Flag clears | info |
firmware_pending |
software update state indicates pending install | Update applied | info |
failover_event |
mwan3 switched active interface | — (event, no clear) | warning |
Decision: the remaining get_status.alerts booleans (~20 total) that aren't cataloged above are surfaced in the UI's Alerts card as raw flags but don't push notifications in the initial release.
- Webhook: POST JSON
{"alert": "outage_started", "severity": "critical", "state": "firing|resolved", "at": <unix>, "detail": {...}, "device": "<dish id>"}with 3 retries, exponential backoff. - ntfy: POST to configured URL with
Title/Priority/Tagsheaders mapped from severity. - Every fire/clear also lands in the events table → Alerts card history.
Float32 ring buffers at 1 s resolution for ram_hours (default 3 h) covering: down/up bps (dish + router-side), latency, drop rate, power W, obstruction fraction, per-WAN probe RTT/loss. ≈ 11 k samples × ~12 series × 4 B ≈ < 1 MB. Live graphs and the WS feed read from here.
| Table | Row | Retention |
|---|---|---|
minute |
per-minute min/avg/max of each series | 7 days |
quarter |
per-15-min aggregates | 30 days |
outages |
merged-timeline entries (source, cause, start, duration) | 10 000 rows |
events |
alerts fired/cleared, control actions (§5.3), daemon lifecycle | 10 000 rows |
speedtests |
time-keyed results | 500 rows |
Backfill from get_history on startup fills gaps ≤ 15 min, so daemon restarts don't hole the graphs.
Single transaction flush every flush_secs (default 300 s): ~5 minute-rows × ~12 series plus any events ≈ a few KB per flush, ~1–2 MB/day worst case before sqlite page reuse. SQLite uses WAL journaling, synchronous=FULL, and a 5 s busy timeout so an interrupted transaction has an on-disk recovery journal. The current in-memory interval can still lose up to one flush period; dish history backfill recovers overlapping telemetry after restart. Acceptable on eMMC/NOR at this volume.
Bearer token (Authorization: Bearer <token>) on every REST endpoint. Only
/api/ws also accepts ?token= because the browser WebSocket constructor
cannot set Authorization headers. An iframe/page bootstrap token is removed
from the URL and used as a Bearer token by the SPA. Listens on listen:port
(default 0.0.0.0:9633, relying on the OpenWrt firewall to restrict access to
trusted LANs). CORS is disabled; the SPA and WebSocket remain same-origin.
| Endpoint | Method | Returns |
|---|---|---|
/api/status |
GET | Full current snapshot: dish status, config readback, WAN states, topology, field-availability map |
| `/api/history?series=...&span=3h | 7d | 30d` |
/api/obstruction-map |
GET | JSON grid + rendered PNG variant (Accept: image/png) |
/api/outages?span= |
GET | Merged outage timeline |
/api/events?span= |
GET | Alerts + control audit log |
/api/control/<action> |
POST | §5 controls; JSON body for parameters; 202 + follow-up event |
/api/speedtest |
POST/GET | Trigger / poll dish speed test |
/api/wan |
GET | Interfaces, probe stats, mwan3 state |
/api/wan/failover-assist |
GET/POST | GET: proposed UCI diff; POST: apply (§6.4) |
/api/config |
GET/PUT | Daemon settings (writes go through UCI + reload) |
/api/ws |
WS | 1 Hz status frames {t, topology, dish_reachable, dish, wan} + async {event:...} messages; dish:null when unreachable prevents retained samples from appearing live |
The expanded contracts in API.md shipped in risk order, with each phase
covered by an in-process fake gRPC server:
- Diagnostics plus status GPS/PNT and disablement fields.
- Battery configuration and derived runtime.
- The read-only
/api/routermodel. - Client rename through the targeted
WifiSetClientGivenNameRPC carryingClientConfig. - Client block/unblock through a Starwatch-owned
WeeklyBlockScheduleon the same targeted RPC. - Wi-Fi network and radio writes. Each network edit is rejected when any
sibling PSK credential is redacted, so
ApplyNetworkscannot erase an unseen password. Scalar writes use exactly one apply flag; channel writes remain withheld when firmware does not advertise a non-DFS allowed set. - Dashboard navigation and personalization: an icon rail groups the existing cards into seven sections, while Overview visibility and density stay local to each browser.
- Distribution: an architecture-checked one-line installer selects the GL.iNet or LuCI integration and registers the verified GitHub Pages opkg feed without changing unrelated feeds, disabling signature checks, or forcing package versions.
- Self-contained bundle (Preact + htm + uPlot for charts, all vendored — zero external/CDN requests; the router may be offline). Static ES modules are embedded in the
starwatchdbinary and served directly by the daemon; no frontend build or CDN is required. - Live data over
/api/ws; graphs scroll at 1 Hz like Speedify's. - Dark theme default matching admin-panel surroundings; light theme via
prefers-color-scheme. Fully responsive: single column ≤ 720 px (Speedify's responsive behavior).
- LuCI (
luci-app-starwatch):menu.dentry ("Services → Starwatch" + top-level "Starwatch" tab),rpcdACL exposing one call —starwatch.token(shell rpcd script reading UCI) — and a JS view that fetches the token and renders a full-height iframe ofhttp://<router>:9633/?token=…. LuCI session = Starwatch access; token never typed by the user. - GL.iNet (
gl-app-starwatch):ouimenu.dJSON entry under Applications + compiled view bundle (gl-sdk4-ui-starwatch.common.js) that reads the token via anoui-httpdRPC and iframes the same URL — the exact mechanism verified for wattline's GL panel app. - Direct browser access (
http://router:9633) shows a token prompt — for users who bookmark it.
The dashboard uses seven hash-routed sections behind an expandable icon rail. Overview card visibility and compact density are browser-local preferences; cards still auto-hide when their data source is absent.
Status header — dish state dot + word (Online / Obstructed / Outage / Searching / STARLINK DISCONNECTED), uptime, current down/up rate, and latency sourced only from dish gRPC. When disconnected, the metric strip is absent and the stream badge reads WAITING FOR DISH. This is the "big Speedify toggle" position, minus the toggle (nothing to toggle — monitoring is always on).
| Card | Contents | Source |
|---|---|---|
| Live graphs | Tabbed like Speedify (Throughput / Latency / Loss / Power), uPlot scrolling, span picker 15 m · 3 h · 24 h · 7 d · 30 d; strictly dish-gRPC throughput, latency, loss, and power series with no router/WAN fallback | §4.1, §8 |
| Obstruction | Fraction obstructed, time obstructed, sky-map render (SNR grid as polar plot), "clear map" action | obstruction_stats, dish_get_obstruction_map |
| Outage timeline | Merged three-source timeline bars with cause tooltips + table of recent outages with durations | §6.3 |
| Alignment | Compass-style azimuth/elevation/tilt readout | boresight fields |
| Power | Instant/min/mean/max W + 24 h area chart + kWh/day derived; optional user-configured battery capacity/SOC/reserve/efficiency yields the clearly labeled runtime estimates and SOC-staleness behavior defined in API.md |
power_in + local battery settings |
| WAN health | Per-WAN cards (Starlink, cellular, …): probe RTT/loss sparkline, state, byte counters; mwan3 status + failover-assist button when applicable | §6 |
| Controls | Snow melt (3-state), sleep schedule editor (local-time UI, UTC stored), GPS toggle, stow/unstow, reboot, firmware update — confirmation-gated per §5.1 | §5 |
| Speed test | Run button, latest + history table | §5.1, speedtests |
| Alerts | Active raw dish flags + Starwatch alert history; link to settings | §7 |
| Hardware | Model (friendly name mapped from hardware_version), firmware, dish id, country, mobility class, temperatures when available |
§4.1 |
| Starlink router (topology B only) | Clients with full MAC/IP/signal/SNR/rates, ping health, radios, temperatures, Ethernet/bridge/interfaces, plus the guarded API.md write surface: targeted client rename, schedule-based block/unblock, real transmit-power enums, and credential-readback-gated network edits |
router gRPC :9000 |
| Settings | Token display/regenerate, dish address, probe hosts, alert thresholds + webhook/ntfy config with "send test", history retention | §2.1 |
| Situation | Behavior |
|---|---|
| Dish unreachable at startup or WAN is not Starlink | Topology C: show STARLINK DISCONNECTED and WAITING FOR DISH, hide all current-data cards and header metrics, keep Settings and historical Events reachable, and retry every 60 s; never append retained dish or WAN values as live telemetry |
| Dish rebooting (user-initiated) | Expected-outage window: dish_unreachable alert suppressed for 5 min after our own reboot command |
| Unknown/new dish model | Parse what's present; unavailable-field machinery (§4.2) handles the rest; hardware card shows raw hardware_version string |
| Router clock wrong at boot (common: no RTC) | History writes deferred until time is sane (year ≥ 2025 or NTP synced); dish get_history backfill re-anchors sample times |
| sqlite corruption | Detect on open → move aside, recreate, event logged; RAM tier unaffected |
| Token leaked / regenerate | Settings action regenerates UCI token; LuCI/GL embeds pick it up on next load |
| opkg same-version reinstall no-op | Documented: bump VERSION or --force-reinstall (wattline-verified) |
| GL firmware upgrade wipes packages | Documented recovery: re-install from feed; /etc/starwatch/ (config + history) survives with "keep settings" |
All wattline-verified mechanics carry over unchanged:
.ipkformat: outer gzipped tar (neverar— opkg segfaults), members ustar (--format ustar; BSD tar pax headers rejected). Built bypackage/Makefile, no OpenWrt SDK.- Packages:
starwatchd_<v>_aarch64_cortex-a53.ipk— daemon, procd init, UCI defaults + token generator, SPA assets, postinst enables + restarts service (upgrade picks up new binary).luci-app-starwatch_<v>_all.ipk— menu.d + rpcd ACL + iframe view.gl-app-starwatch_<v>_all.ipk— oui menu.d + compiled view bundle.
- Feed:
make -C package VERSION=x.y.z feed-artifactstages exactly the current three IPKs,Packages,Packages.gz, and the bootstrap installer for local inspection. GitHub Actions signsPackageswith a dedicated usign key and publishes the artifact, signature, and public key athttps://keithah.github.io/openwrt-starwatch/on version tags or an explicit manual deployment. The installer pins that public key without disabling global opkg signature checks, accepts onlyaarch64_cortex-a53for the 0.1.x releases, selectsgl-app-starwatchfor GL.iNet SDK4 orluci-app-starwatchotherwise, and maintains only the namedstarwatchentry in/etc/opkg/customfeeds.conf. Upgrades use normal opkg version ordering; version metadata, filenames, and the feed index advance together. - Dev install: pipe over ssh (
cat > /tmp/…) since dropbear lacks scp — documented one-liner. - Dependencies: none beyond OpenWrt base (static Go binary; no bluez-equivalent this time). mwan3 optional.
- §10.2 iframe vs. served-through-LuCI: iframing
:9633fails if the browser can't reach that port (strict firewall zones, remote access via VPN with port restrictions). Fallback option: uhttpd reverse-proxy stanza. Ship iframe-first, revisit if reports come in. - §5.1 dish speed test reliability:
start_speedtest/get_speedtest_statusavailability varies by firmware; if too flaky in practice, add a daemon-run HTTP throughput test (against user-configured endpoint) as a future fallback. - §2.1 history DB location:
/etc/starwatch/survives sysupgrade but sits on the config partition; if 30-day DBs prove larger than expected on small-flash devices, move default to/overlaydata dir with a settings toggle.
Card reordering with persisted layout · rules engine (condition→action,
wattline-style) · Prometheus /metrics exporter + MQTT publish (Home
Assistant) · obstruction-map time-lapse · multi-dish support · daemon-run speed
test fallback · CSV/JSON export buttons · public REST docs page.
End of spec.