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

- +
DeviceTypeStateIPv4MetricActions
DeviceTypeStateIPv4SignalMetricActions
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); }