#!/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)