failover: add van-modem-usb-kick — recover EC25 modem when it never enumerates at boot

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 <noreply@anthropic.com>
This commit is contained in:
Andreas Wrede
2026-08-04 10:17:36 -04:00
co-authored by Claude Sonnet 5
parent e7a82126f2
commit a1bad186b1
3 changed files with 135 additions and 1 deletions
+119
View File
@@ -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 .../<hub>/<hub>.<port>/<hub>.<port>:<config>.<iface>
# (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)