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:
co-authored by
Claude Fable 5
parent
fe9e6d3b30
commit
d2a2b19b85
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user