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
@@ -49,7 +49,7 @@
|
||||
<h3>WAN / Uplinks</h3>
|
||||
<table>
|
||||
<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>
|
||||
<tbody id="wan"></tbody>
|
||||
</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
|
||||
// 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
|
||||
? ` <span class="muted">${esc([modem.tech, modem.operator].filter(Boolean).join(" · "))}</span>`
|
||||
: "");
|
||||
}
|
||||
d.metric = metricByDev[d.device];
|
||||
d.ip = ipByDev[d.device];
|
||||
d.active = d.device === activeDev;
|
||||
@@ -372,6 +413,7 @@ function renderWAN(devs) {
|
||||
`<td>${esc(d.type)}</td>` +
|
||||
`<td>${esc(d.state)}</td>` +
|
||||
`<td>${esc(d.ip || "—")}</td>` +
|
||||
`<td>${d.signal || "—"}</td>` +
|
||||
`<td>${d.metric != null ? esc(d.metric) : "—"}</td>` +
|
||||
`<td class="acts"></td>`;
|
||||
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); }
|
||||
|
||||
Reference in New Issue
Block a user