cockpit: cellular WAN row with signal + Restart button

Include the gsm device in the WAN/Uplinks table, shown as its routed
netdev with IP and metric (nmcli actions still target cdc-wdm0). New
Signal column fed by mmcli -K in the single status spawn (percent, tech,
operator). Restart = mmcli disable/enable — the EC25 MBIM plugin doesn't
support --reset — with a USB unbind/bind fallback (vendor 2c7c) when
ModemManager can't reach the modem. README: modem setup steps (Koodo
profile creation, MBIM netdev gotcha, Cockpit row).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Andreas Wrede
2026-07-14 17:28:22 -04:00
co-authored by Claude Fable 5
parent fe9e6d3b30
commit d2a2b19b85
3 changed files with 83 additions and 5 deletions
+12 -1
View File
@@ -190,9 +190,20 @@ The `homeassistant` LAN name comes from `ap/van-ap-dnsmasq.conf` (`host-record`
- **Caveat — the 10 % shutdown is one-way.** A laptop won't power itself back on when mains returns: "restore on AC loss" is a BIOS/firmware feature (not OS-controllable — `/proc/acpi/wakeup` only covers wake-from-suspend). If your BIOS exposes an "AC power-on / restore on AC loss" option, enable it so the router reboots itself once shore/solar power is back; this ZenBook likely doesn't have it, in which case a low-battery shutdown needs a manual power-on. - **Caveat — the 10 % shutdown is one-way.** A laptop won't power itself back on when mains returns: "restore on AC loss" is a BIOS/firmware feature (not OS-controllable — `/proc/acpi/wakeup` only covers wake-from-suspend). If your BIOS exposes an "AC power-on / restore on AC loss" option, enable it so the router reboots itself once shore/solar power is back; this ZenBook likely doesn't have it, in which case a low-battery shutdown needs a manual power-on.
### Adding the 4G/5G modem ### Adding the 4G/5G modem
1. Plug the USB modem in. ModemManager + the existing `Koodo` gsm NM connection (autoconnect) bring it up. 1. Plug the USB modem in. ModemManager + the `Koodo` gsm NM connection (autoconnect) bring it up.
On a fresh host create the profile once:
`nmcli con add type gsm ifname "*" con-name Koodo apn sp.koodo.com connection.autoconnect yes`
2. It auto-joins as the `cellular` WAN at metric 300 (last resort). Nothing else to configure. 2. It auto-joins as the `cellular` WAN at metric 300 (last resort). Nothing else to configure.
3. Confirm with `mmcli -L` and the Cockpit failover card (cellular flips from `absent` to `up`). 3. Confirm with `mmcli -L` and the Cockpit failover card (cellular flips from `absent` to `up`).
4. Gotcha (handled in `van-failover` since the Quectel EC25-AF landed): for MBIM/QMI modems
NM's device is the control port (`cdc-wdm0`) while IP + routes live on the wwan netdev
(e.g. `wwu1u2i4`). The daemon maps via `GENERAL.IP-IFACE` — probing or `ip route`
against `cdc-wdm0` silently sees nothing. The Cockpit WAN table does the same mapping
(shows the netdev + its IP; nmcli actions still target `cdc-wdm0`).
5. The Cockpit WAN row shows signal % / tech / operator (from `mmcli`) and has a
**Restart** button: `mmcli --disable && --enable` (the EC25 MBIM plugin doesn't
support `--reset`; autoconnect reconnects), falling back to a USB unbind/bind of the
Quectel device (vendor `2c7c`) if ModemManager can't reach the modem.
### ZeroTier managed DNS ### ZeroTier managed DNS
- `wrede.pvt` resolves over ZeroTier when off the home LAN. Mechanism: `allowDNS=1` (prereq) + `zerotier-systemd-manager` writes `99-ztuga7c2kh.network`, networkd applies it to resolved. - `wrede.pvt` resolves over ZeroTier when off the home LAN. Mechanism: `allowDNS=1` (prereq) + `zerotier-systemd-manager` writes `99-ztuga7c2kh.network`, networkd applies it to resolved.
+1 -1
View File
@@ -49,7 +49,7 @@
<h3>WAN / Uplinks</h3> <h3>WAN / Uplinks</h3>
<table> <table>
<thead> <thead>
<tr><th>Device</th><th>Type</th><th>State</th><th>IPv4</th><th>Metric</th><th>Actions</th></tr> <tr><th>Device</th><th>Type</th><th>State</th><th>IPv4</th><th>Signal</th><th>Metric</th><th>Actions</th></tr>
</thead> </thead>
<tbody id="wan"></tbody> <tbody id="wan"></tbody>
</table> </table>
+70 -3
View File
@@ -17,6 +17,8 @@ const LAN_PORTS = { "eth0": "LAN (eth0)", "enx00e04c331140": "LAN (USB)" };
// Starlink dish: gRPC status API on the fixed management IP (reached via the /32 // Starlink dish: gRPC status API on the fixed management IP (reached via the /32
// link route the netplan profile installs on the RTL8153 uplink). // link route the netplan profile installs on the RTL8153 uplink).
const STARLINK = { iface: "enxd8ec5eeb3512", dish: "192.168.100.1:9200" }; const STARLINK = { iface: "enxd8ec5eeb3512", dish: "192.168.100.1:9200" };
// Cellular modem (Quectel EC25-AF): used to find the USB device for a hard restart.
const MODEM_USB_VENDOR = "2c7c";
function run(args, opts) { function run(args, opts) {
return cockpit.spawn(args, Object.assign({ err: "message" }, opts || {})); return cockpit.spawn(args, Object.assign({ err: "message" }, opts || {}));
@@ -58,6 +60,7 @@ const STATUS_SCRIPT = (() => {
`${STARLINK.dish} SpaceX.API.Device.Device/Handle || echo UNREACHABLE; fi`); `${STARLINK.dish} SpaceX.API.Device.Device/Handle || echo UNREACHABLE; fi`);
add("battery", "cat /run/van-battery/state.json"); add("battery", "cat /run/van-battery/state.json");
add("failover", "cat /run/van-failover/state.json"); add("failover", "cat /run/van-failover/state.json");
add("modem", "mmcli -m any -K");
add("devices", "nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device status"); add("devices", "nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device status");
add("routes", "ip -j route show default"); add("routes", "ip -j route show default");
add("addrs", "ip -j -4 addr"); add("addrs", "ip -j -4 addr");
@@ -332,11 +335,40 @@ function renderFailover(fo) {
/* ---------- WAN / uplinks ---------- */ /* ---------- WAN / uplinks ---------- */
// mmcli -K key-values -> { netdev, signal, tech, operator } (null if no modem).
// NM's gsm device is the control port (cdc-wdm0); IP/routes live on the wwan netdev.
function parseModem(secs) {
const kv = {};
(secs.modem || "").trim().split("\n").forEach(l => {
const i = l.indexOf(":");
if (i > 0) kv[l.slice(0, i).trim()] = l.slice(i + 1).trim();
});
if (!kv["modem.generic.state"]) return null;
let netdev = null;
const techs = [];
Object.keys(kv).forEach(k => {
if (k.startsWith("modem.generic.ports.value")) {
const m = kv[k].match(/^(\S+) \(net\)$/);
if (m) netdev = m[1];
}
if (k.startsWith("modem.generic.access-technologies.value"))
techs.push(kv[k]);
});
const op = kv["modem.3gpp.operator-name"];
return {
netdev,
signal: kv["modem.generic.signal-quality.value"],
tech: techs.join("/"),
operator: op === "--" ? null : op,
};
}
function parseWAN(secs) { function parseWAN(secs) {
const modem = parseModem(secs);
const devs = (secs.devices || "").trim().split("\n").filter(Boolean).map(line => { const devs = (secs.devices || "").trim().split("\n").filter(Boolean).map(line => {
const [device, type, state, ...rest] = line.split(":"); const [device, type, state, ...rest] = line.split(":");
return { device, type, state, connection: rest.join(":") }; return { device, type, state, connection: rest.join(":"), nmdev: device };
}).filter(d => (d.type === "ethernet" || d.type === "wifi") && }).filter(d => (d.type === "ethernet" || d.type === "wifi" || d.type === "gsm") &&
d.state !== "unmanaged" && // networkd-owned LAN ports (eth0, enx* dongle) d.state !== "unmanaged" && // networkd-owned LAN ports (eth0, enx* dongle)
!APS.some(a => a.iface === d.device)); !APS.some(a => a.iface === d.device));
@@ -354,6 +386,15 @@ function parseWAN(secs) {
}); });
devs.forEach(d => { devs.forEach(d => {
if (d.type === "gsm" && modem) {
// show the routed netdev; keep nmdev (cdc-wdm0) for nmcli actions
if (modem.netdev) d.device = modem.netdev;
if (modem.signal != null)
d.signal = `${esc(modem.signal)}%` +
(modem.tech || modem.operator
? ` <span class="muted">${esc([modem.tech, modem.operator].filter(Boolean).join(" · "))}</span>`
: "");
}
d.metric = metricByDev[d.device]; d.metric = metricByDev[d.device];
d.ip = ipByDev[d.device]; d.ip = ipByDev[d.device];
d.active = d.device === activeDev; d.active = d.device === activeDev;
@@ -372,6 +413,7 @@ function renderWAN(devs) {
`<td>${esc(d.type)}</td>` + `<td>${esc(d.type)}</td>` +
`<td>${esc(d.state)}</td>` + `<td>${esc(d.state)}</td>` +
`<td>${esc(d.ip || "—")}</td>` + `<td>${esc(d.ip || "—")}</td>` +
`<td>${d.signal || "—"}</td>` +
`<td>${d.metric != null ? esc(d.metric) : "—"}</td>` + `<td>${d.metric != null ? esc(d.metric) : "—"}</td>` +
`<td class="acts"></td>`; `<td class="acts"></td>`;
const acts = tr.querySelector(".acts"); const acts = tr.querySelector(".acts");
@@ -390,6 +432,14 @@ function renderWAN(devs) {
tog.onclick = () => toggleWAN(d, isUp); tog.onclick = () => toggleWAN(d, isUp);
acts.appendChild(tog); acts.appendChild(tog);
if (d.type === "gsm") {
const rst = document.createElement("button");
rst.className = "btn";
rst.textContent = "Restart";
rst.onclick = () => restartModem();
acts.appendChild(rst);
}
tb.appendChild(tr); tb.appendChild(tr);
}); });
} }
@@ -409,11 +459,28 @@ async function preferWAN(chosen) {
async function toggleWAN(d, isUp) { async function toggleWAN(d, isUp) {
try { try {
const verb = isUp ? "disconnect" : "connect"; const verb = isUp ? "disconnect" : "connect";
await run(["nmcli", "device", verb, d.device], { superuser: "require" }); await run(["nmcli", "device", verb, d.nmdev], { superuser: "require" });
} catch (e) { window.alert("Toggle failed: " + e.message); } } catch (e) { window.alert("Toggle failed: " + e.message); }
refresh(); refresh();
} }
async function restartModem() {
// Radio bounce via ModemManager (the EC25 MBIM plugin doesn't support --reset);
// the autoconnect Koodo profile reconnects on enable (verified). If MM can't
// talk to the modem at all, fall back to a USB unbind/bind (found by Quectel
// vendor id) so the whole driver stack + MM re-probe the device.
const script =
`if ! { mmcli -m any --disable && mmcli -m any --enable; }; then ` +
`dev=""; for f in /sys/bus/usb/devices/*/idVendor; do ` +
`[ "$(cat "$f")" = ${esc_sh(MODEM_USB_VENDOR)} ] && { dev=$(basename "$(dirname "$f")"); break; }; done; ` +
`[ -n "$dev" ] || { echo "no modem USB device found" >&2; exit 1; }; ` +
`echo "$dev" > /sys/bus/usb/drivers/usb/unbind; sleep 3; ` +
`echo "$dev" > /sys/bus/usb/drivers/usb/bind; fi`;
try { await sh(script, { superuser: "require" }); }
catch (e) { window.alert("Modem restart failed: " + e.message); }
setTimeout(refresh, 8000);
}
async function restartAP(unit) { async function restartAP(unit) {
try { await run(["systemctl", "restart", unit], { superuser: "require" }); } try { await run(["systemctl", "restart", unit], { superuser: "require" }); }
catch (e) { window.alert("Restart failed: " + e.message); } catch (e) { window.alert("Restart failed: " + e.message); }