From a1bad186b139e2028655a99b2087029f4657229b Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Tue, 4 Aug 2026 10:17:36 -0400 Subject: [PATCH] =?UTF-8?q?failover:=20add=20van-modem-usb-kick=20?= =?UTF-8?q?=E2=80=94=20recover=20EC25=20modem=20when=20it=20never=20enumer?= =?UTF-8?q?ates=20at=20boot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seen after moving the modem onto the new powered UGreen hub: the port sometimes never signals a connect during boot (no descriptor-read attempt at all in dmesg), so the hub never rescans it. Unbind/rebinding the inner hub chip reproduces the same connect edge a physical unplug/replug does. Co-Authored-By: Claude Sonnet 5 --- deploy.sh | 6 +- failover/van-modem-usb-kick | 119 ++++++++++++++++++++++++++++ failover/van-modem-usb-kick.service | 11 +++ 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100755 failover/van-modem-usb-kick create mode 100644 failover/van-modem-usb-kick.service diff --git a/deploy.sh b/deploy.sh index a548691..8c805af 100755 --- a/deploy.sh +++ b/deploy.sh @@ -143,6 +143,10 @@ echo "== cellular modem (GSM/LTE) ==" # need an explicit gsm.apn set by hand regardless. dpkg -s mobile-broadband-provider-info >/dev/null 2>&1 \ || warn "mobile-broadband-provider-info missing (apt install mobile-broadband-provider-info) — GSM APN auto-config will fail" +# Backstop for the modem sometimes never enumerating at boot on the new +# powered hub (see the script's docstring). +install -D -m0755 failover/van-modem-usb-kick /usr/local/sbin/van-modem-usb-kick +install_rendered failover/van-modem-usb-kick.service /etc/systemd/system/van-modem-usb-kick.service echo "== cockpit plugin ==" install -d /usr/share/cockpit/vanrouter @@ -226,7 +230,7 @@ systemctl restart systemd-resolved # its wait-online would just stall network-online.target. NM-wait-online covers WANs. systemctl mask systemd-networkd-wait-online.service >/dev/null 2>&1 || true systemctl unmask hostapd >/dev/null 2>&1 || true -systemctl enable regdomain.service hostapd hostapd-2g van-ap-dnsmasq nftables systemd-networkd van-failover van-thermal van-nvme-watch van-ap-watchdog van-ap-watchdog-2g van-wlan-watchdog van-gps-owntracks >/dev/null 2>&1 || true +systemctl enable regdomain.service hostapd hostapd-2g van-ap-dnsmasq nftables systemd-networkd van-failover van-thermal van-nvme-watch van-ap-watchdog van-ap-watchdog-2g van-wlan-watchdog van-modem-usb-kick van-gps-owntracks >/dev/null 2>&1 || true # bluetooth: host BlueZ serves the onboard hci0 to the HA container over D-Bus systemctl enable --now bluetooth >/dev/null 2>&1 || true systemctl start homeassistant || warn "homeassistant failed to start (podman/quadlet — check journalctl -u homeassistant)" diff --git a/failover/van-modem-usb-kick b/failover/van-modem-usb-kick new file mode 100755 index 0000000..00aa67e --- /dev/null +++ b/failover/van-modem-usb-kick @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""van-modem-usb-kick — recover the EC25 modem when it's missing after boot. + +Seen on 2026-08-04, right after moving the modem onto the new powered UGreen +hub: the 5GHz AP (rtw89, on the hub's outer USB chip) and the hub's built-in +Ethernet (AX88179, on its inner cascaded USB2.1 hub chip) both enumerate +cleanly at boot, but the EC25 modem — plugged into a port on that same inner +hub chip — sometimes never does, even though it has power (LED on). dmesg +shows no connect attempt at all for the modem's port during boot (contrast +the Ethernet's clean enumeration, or the AP's one-shot retry after a +descriptor-read error) — the port just never signalled a connect in time, and +a hub only rescans a port on an actual connect/disconnect edge, so it stays +invisible until something produces one. Physically unplugging/replugging +either the whole hub or just the modem fixes it immediately, because both +produce that edge. This script produces the same edge in software: unbind +then rebind the inner hub chip's kernel driver, which forces it to redo +enumeration of everything on it (the modem, and briefly the Starlink +Ethernet — the AP is on the outer chip and is undisturbed). + +The inner hub is found by walking up from STARLINK_IFACE's sysfs device (it's +the Ethernet's parent hub) rather than hardcoding a bus/port path, since that +identifies "the hub carrying the modem" without assuming a specific topology. + +Bounded to a few minutes after boot, then exits — this is a boot-race +backstop, not a permanent watcher (a modem physically removed later should +just stay gone, not get fought). + +Usage: van-modem-usb-kick [modem-usb-vendor] [starlink-iface] +Defaults to 2c7c (Quectel) and enx6c1ff7d210a5 — see deploy.conf. +""" + +import sys +import time +from pathlib import Path + +MODEM_VENDOR = sys.argv[1] if len(sys.argv) > 1 else "2c7c" +STARLINK_IFACE = sys.argv[2] if len(sys.argv) > 2 else "enx6c1ff7d210a5" +INTERVAL = 20 # seconds between checks +BUDGET_S = 6 * INTERVAL # ~2 minutes of retries after boot, then give up quietly +USB_DEVICES = Path("/sys/bus/usb/devices") +HUB_DRIVER = Path("/sys/bus/usb/drivers/hub") + + +def log(msg, level="info"): + # systemd journal severity prefixes (sd-daemon), same convention as van-ap-watchdog. + pri = {"info": "<6>", "warn": "<4>", "crit": "<2>"}.get(level, "<6>") + print(pri + msg, flush=True) + + +def modem_present(vendor): + for f in USB_DEVICES.glob("*/idVendor"): + try: + if f.read_text().strip() == vendor: + return True + except OSError: + continue + return False + + +def inner_hub_name(starlink_iface): + """sysfs driver-name (e.g. '2-1.4') of the USB hub carrying starlink_iface — + that's the same hub chip the modem's port is on. None if the Ethernet + itself hasn't enumerated (nothing to walk up from).""" + dev = Path(f"/sys/class/net/{starlink_iface}/device") + if not dev.exists(): + return None + # dev resolves to ...//./.:. + # (e.g. .../2-1.4/2-1.4.2/2-1.4.2:2.0) — two levels up is the hub itself. + hub = dev.resolve().parent.parent + if not (hub / "bDeviceClass").exists(): + return None + try: + if hub.joinpath("bDeviceClass").read_text().strip() != "09": # 09 = hub + return None + except OSError: + return None + return hub.name + + +def kick(hub_name): + log(f"modem (vendor {MODEM_VENDOR}) absent — rebinding hub {hub_name} to force " + f"re-enumeration (same effect as unplug/replug; briefly bounces {STARLINK_IFACE})", + "warn") + try: + (HUB_DRIVER / "unbind").write_text(hub_name) + time.sleep(2) + (HUB_DRIVER / "bind").write_text(hub_name) + except OSError as e: + log(f"hub rebind failed: {e}", "warn") + + +def main(): + log(f"van-modem-usb-kick up: watching for USB vendor {MODEM_VENDOR} for up to " + f"{BUDGET_S}s after boot") + elapsed = 0 + kicked = False + while elapsed < BUDGET_S: + if modem_present(MODEM_VENDOR): + log("modem present — exiting" + (" (recovered)" if kicked else "")) + return + if not kicked: + hub_name = inner_hub_name(STARLINK_IFACE) + if hub_name: + kick(hub_name) + kicked = True + # else: Ethernet itself isn't up yet — give the system more time + # before assuming anything is wrong. + time.sleep(INTERVAL) + elapsed += INTERVAL + if not modem_present(MODEM_VENDOR): + log(f"modem still absent after {BUDGET_S}s — giving up " + f"(leaving it for manual unplug/replug from here on)", "warn") + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + sys.exit(0) diff --git a/failover/van-modem-usb-kick.service b/failover/van-modem-usb-kick.service new file mode 100644 index 0000000..3cf894f --- /dev/null +++ b/failover/van-modem-usb-kick.service @@ -0,0 +1,11 @@ +[Unit] +Description=EC25 modem boot-recovery backstop (kicks its USB hub if the modem never enumerated) +After=systemd-udev-settle.service +Wants=systemd-udev-settle.service + +[Service] +Type=simple +ExecStart=/usr/local/sbin/van-modem-usb-kick @MODEM_USB_VENDOR@ @STARLINK_IFACE@ + +[Install] +WantedBy=multi-user.target