diff --git a/ap/50-van-wan.yaml b/ap/50-van-wan.yaml
index 5bfc238..1fbecd0 100644
--- a/ap/50-van-wan.yaml
+++ b/ap/50-van-wan.yaml
@@ -1,7 +1,8 @@
# /etc/netplan/50-van-wan.yaml — replaces cloud-init's 50-cloud-init.yaml.
-# Both onboard interfaces are WANs handed to NetworkManager so van-failover can
-# steer them (mirrors wayback where NM owns all WANs): wlan0 = wifi uplink
-# (Wapana at home / campsite wifi), eth0 = ethernet uplink. The AP radios and
+# Both onboard interfaces plus the Starlink USB NIC are WANs handed to
+# NetworkManager so van-failover can steer them (mirrors wayback where NM owns
+# all WANs): wlan0 = wifi uplink (Wapana at home / campsite wifi), eth0 =
+# ethernet uplink, enxd8ec5eeb3512 = Starlink. The AP radios and
# the wired LAN port (USB dongles, wlx*/enx* MAC-named) are deliberately absent:
# systemd-networkd/hostapd own them, and van-ap-unmanaged.conf hides them from NM.
# Apply once by hand: netplan generate && netplan apply (flaps both uplinks).
@@ -12,6 +13,17 @@ network:
renderer: NetworkManager
optional: true
dhcp4: true
+ # Starlink dish uplink (RTL8153 USB NIC, MAC-named — travels with the
+ # adapter). The /32 link route keeps the dish's management address reachable
+ # no matter which WAN holds the default route: the dish answers on
+ # 192.168.100.1 (gRPC :9200) even while the uplink sits behind CGNAT.
+ enxd8ec5eeb3512:
+ renderer: NetworkManager
+ optional: true
+ dhcp4: true
+ routes:
+ - to: 192.168.100.1/32
+ scope: link
wifis:
wlan0:
renderer: NetworkManager
diff --git a/cockpit/vanrouter/index.html b/cockpit/vanrouter/index.html
index b1bb014..a87aae0 100644
--- a/cockpit/vanrouter/index.html
+++ b/cockpit/vanrouter/index.html
@@ -35,6 +35,11 @@
+
+
WAN / Uplinks
diff --git a/cockpit/vanrouter/vanrouter.js b/cockpit/vanrouter/vanrouter.js
index dfd589d..6cd5122 100644
--- a/cockpit/vanrouter/vanrouter.js
+++ b/cockpit/vanrouter/vanrouter.js
@@ -12,6 +12,9 @@ const APS = [
{ iface: "wlxd8ec5e2faa8c", unit: "hostapd-2g", band: "2.4GHz" }, // RTL8822BU
];
const PREFER_FILE = "/run/van-failover/prefer"; // van-failover reads this to pick the preferred WAN
+// Starlink dish: gRPC status API on the fixed management IP (reached via the /32
+// link route the netplan profile installs on the RTL8153 uplink).
+const STARLINK = { iface: "enxd8ec5eeb3512", dish: "192.168.100.1:9200" };
function run(args, opts) {
return cockpit.spawn(args, Object.assign({ err: "message" }, opts || {}));
@@ -43,6 +46,13 @@ const STATUS_SCRIPT = (() => {
add("neigh", "ip -j neigh show dev br0");
add("leases", "cat /var/lib/misc/dnsmasq.leases");
add("thermal", "cat /run/van-thermal/state.json");
+ // Sentinel words (ABSENT/NOGRPCURL/UNREACHABLE) let the renderer tell the
+ // three failure modes apart; anything starting with '{' is dish status JSON.
+ add("starlink",
+ `if ! ip link show ${STARLINK.iface} >/dev/null 2>&1; then echo ABSENT; ` +
+ `elif ! command -v grpcurl >/dev/null 2>&1; then echo NOGRPCURL; ` +
+ `else timeout 4 grpcurl -plaintext -max-time 3 -d '{"get_status":{}}' ` +
+ `${STARLINK.dish} SpaceX.API.Device.Device/Handle || echo UNREACHABLE; fi`);
add("battery", "cat /run/van-battery/state.json");
add("failover", "cat /run/van-failover/state.json");
add("devices", "nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device status");
@@ -169,6 +179,64 @@ function renderThermal(th) {
el.innerHTML = html;
}
+/* ---------- Starlink (dish gRPC get_status via grpcurl) ---------- */
+
+function fmtMbps(bps) {
+ return bps == null ? "—" : (bps / 1e6).toFixed(1) + " Mbps";
+}
+
+function fmtUptime(s) {
+ s = parseInt(s, 10);
+ if (isNaN(s)) return "—";
+ const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60);
+ return (d ? `${d}d ` : "") + (d || h ? `${h}h ` : "") + `${m}m`;
+}
+
+function renderStarlink(raw) {
+ const el = document.getElementById("starlink");
+ const t = (raw || "").trim();
+ if (!t || t === "ABSENT") {
+ el.innerHTML = `Starlink adapter (${esc(STARLINK.iface)}) not plugged in.
`;
+ return;
+ }
+ if (t === "NOGRPCURL") {
+ el.innerHTML = `grpcurl not installed — the dish status API is gRPC. ` +
+ `arm64 binary: github.com/fullstorydev/grpcurl/releases
`;
+ return;
+ }
+ if (t === "UNREACHABLE" || t[0] !== "{") {
+ el.innerHTML = `dish unreachable ` +
+ `adapter present but 192.168.100.1 not answering ` +
+ `(dish booting / unpowered / route missing?)
`;
+ return;
+ }
+
+ const st = (parseJSON(t, {}) || {}).dishGetStatus || {};
+ const obs = st.obstructionStats || {};
+ const alerts = Object.keys(st.alerts || {}).filter(k => st.alerts[k]);
+
+ let state;
+ if (st.outage)
+ state = `${esc(st.outage.cause || "OUTAGE")}`;
+ else if (obs.currentlyObstructed)
+ state = `obstructed`;
+ else
+ state = `online`;
+
+ let html = `${state}` +
+ (alerts.length ? ` alerts: ${esc(alerts.join(", "))}` : "") +
+ ` uptime ${esc(fmtUptime((st.deviceState || {}).uptimeS))}` +
+ ` · sw ${esc((st.deviceInfo || {}).softwareVersion || "—")}
`;
+ html += `| Latency (PoP) | Down | Up | Obstructed |
`;
+ html += `| ${st.popPingLatencyMs == null || st.popPingLatencyMs < 0 ? "—" : esc(st.popPingLatencyMs.toFixed(0)) + " ms"} | ` +
+ `${esc(fmtMbps(st.downlinkThroughputBps))} | ` +
+ `${esc(fmtMbps(st.uplinkThroughputBps))} | ` +
+ `${obs.fractionObstructed == null ? "—" : esc((obs.fractionObstructed * 100).toFixed(1)) + " %"} |
`;
+ html += `
`;
+ html += `Dish web UI: http://192.168.100.1 (from the van LAN)
`;
+ el.innerHTML = html;
+}
+
/* ---------- Battery / power source (van-battery daemon state) ---------- */
function renderBattery(b) {
@@ -320,6 +388,7 @@ async function refresh() {
const secs = parseSections(await sh(STATUS_SCRIPT));
renderAPs(APS.map((a, i) => parseAP(a, i, secs)), parseClientDir(secs));
renderThermal(parseJSON(secs.thermal, null));
+ renderStarlink(secs.starlink);
renderBattery(parseJSON(secs.battery, null));
renderFailover(parseJSON(secs.failover, null));
renderWAN(parseWAN(secs));
diff --git a/deploy.sh b/deploy.sh
index 0455f0a..2adae6e 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -42,6 +42,9 @@ install -d /usr/share/cockpit/vanrouter
install -m0644 cockpit/vanrouter/* /usr/share/cockpit/vanrouter/
# Bridge fd headroom (Python bridge frees spawn pipes only at GC; 1024 is too tight)
install -D -m0644 cockpit/cockpit-session-nofile.conf /etc/systemd/system/cockpit-session@.service.d/nofile.conf
+# The Starlink card queries the dish's gRPC API; grpcurl isn't packaged in apt.
+command -v grpcurl >/dev/null 2>&1 \
+ || echo " -> grpcurl missing (Starlink card will say so): install linux_arm64 binary from github.com/fullstorydev/grpcurl/releases"
echo "== thermal monitor =="
install -D -m0755 power/van-thermal /usr/local/sbin/van-thermal