diff --git a/README.md b/README.md
index 6ca7f24..826f39d 100644
--- a/README.md
+++ b/README.md
@@ -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.
### 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.
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
- `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.
diff --git a/cockpit/vanrouter/index.html b/cockpit/vanrouter/index.html
index a9f10d1..363256e 100644
--- a/cockpit/vanrouter/index.html
+++ b/cockpit/vanrouter/index.html
@@ -49,7 +49,7 @@
WAN / Uplinks
- | Device | Type | State | IPv4 | Metric | Actions |
+ | Device | Type | State | IPv4 | Signal | Metric | Actions |
diff --git a/cockpit/vanrouter/vanrouter.js b/cockpit/vanrouter/vanrouter.js
index 9d41d7c..49dd9c3 100644
--- a/cockpit/vanrouter/vanrouter.js
+++ b/cockpit/vanrouter/vanrouter.js
@@ -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
// link route the netplan profile installs on the RTL8153 uplink).
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) {
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`);
add("battery", "cat /run/van-battery/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("routes", "ip -j route show default");
add("addrs", "ip -j -4 addr");
@@ -332,11 +335,40 @@ function renderFailover(fo) {
/* ---------- 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) {
+ const modem = parseModem(secs);
const devs = (secs.devices || "").trim().split("\n").filter(Boolean).map(line => {
const [device, type, state, ...rest] = line.split(":");
- return { device, type, state, connection: rest.join(":") };
- }).filter(d => (d.type === "ethernet" || d.type === "wifi") &&
+ return { device, type, state, connection: rest.join(":"), nmdev: device };
+ }).filter(d => (d.type === "ethernet" || d.type === "wifi" || d.type === "gsm") &&
d.state !== "unmanaged" && // networkd-owned LAN ports (eth0, enx* dongle)
!APS.some(a => a.iface === d.device));
@@ -354,6 +386,15 @@ function parseWAN(secs) {
});
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
+ ? ` ${esc([modem.tech, modem.operator].filter(Boolean).join(" · "))}`
+ : "");
+ }
d.metric = metricByDev[d.device];
d.ip = ipByDev[d.device];
d.active = d.device === activeDev;
@@ -372,6 +413,7 @@ function renderWAN(devs) {
`${esc(d.type)} | ` +
`${esc(d.state)} | ` +
`${esc(d.ip || "—")} | ` +
+ `${d.signal || "—"} | ` +
`${d.metric != null ? esc(d.metric) : "—"} | ` +
` | `;
const acts = tr.querySelector(".acts");
@@ -390,6 +432,14 @@ function renderWAN(devs) {
tog.onclick = () => toggleWAN(d, isUp);
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);
});
}
@@ -409,11 +459,28 @@ async function preferWAN(chosen) {
async function toggleWAN(d, isUp) {
try {
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); }
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) {
try { await run(["systemctl", "restart", unit], { superuser: "require" }); }
catch (e) { window.alert("Restart failed: " + e.message); }