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:
co-authored by
Claude Sonnet 5
parent
e7a82126f2
commit
a1bad186b1
@@ -143,6 +143,10 @@ echo "== cellular modem (GSM/LTE) =="
|
|||||||
# need an explicit gsm.apn set by hand regardless.
|
# need an explicit gsm.apn set by hand regardless.
|
||||||
dpkg -s mobile-broadband-provider-info >/dev/null 2>&1 \
|
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"
|
|| 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 =="
|
echo "== cockpit plugin =="
|
||||||
install -d /usr/share/cockpit/vanrouter
|
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.
|
# 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 mask systemd-networkd-wait-online.service >/dev/null 2>&1 || true
|
||||||
systemctl unmask hostapd >/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
|
# bluetooth: host BlueZ serves the onboard hci0 to the HA container over D-Bus
|
||||||
systemctl enable --now bluetooth >/dev/null 2>&1 || true
|
systemctl enable --now bluetooth >/dev/null 2>&1 || true
|
||||||
systemctl start homeassistant || warn "homeassistant failed to start (podman/quadlet — check journalctl -u homeassistant)"
|
systemctl start homeassistant || warn "homeassistant failed to start (podman/quadlet — check journalctl -u homeassistant)"
|
||||||
|
|||||||
Executable
+119
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user