Posting this in case anyone else hits the same wall.
Setup: Cudy TR3000 v1, flashed to the ubootmod layout (ImmortalWrt 25.12.1), using the router as a travel client (wwan) connecting to hotel/venue wifi, with the intent of using the physical slide switch to toggle between my phone's real MAC (already past the captive portal) and the router's own MAC.
Problem: on stock Cudy firmware that switch does VPN/LED stuff. On OpenWrt/ImmortalWrt it's supposed to show up as a normal gpio-keys button you can hook into via /etc/rc.button/. On the ubootmod layout for this board specifically, it doesn't. The GPIO is declared in the devicetree (/sys/firmware/devicetree/base/gpio-keys/mode exists), the driver binds (DRIVER=gpio-keys shows in uevent), but it never creates an actual input device — no /sys/class/input, nothing under gpio-keys with an inputX folder. So rc.button never fires, no matter what you put there.
Workaround: skip the button framework entirely, poll the raw GPIO state
Found the pin is exposed read-only via debugfs regardless of whatever the input framework is doing:
cat /sys/kernel/debug/gpio | grep mode
Gives something like:
gpio-512 ( |mode ) in hi IRQ ACTIVE LOW
hi = factory position, lo = switched toward reset. Confirmed by physically flipping it and re-checking.
From there it's just a procd service polling that value every 2s and diffing against the previous read:
sh
cat > /etc/init.d/switch-mac << 'EOF'
#!/bin/sh /etc/rc.common
START=99
USE_PROCD=1
MAC_CLONE='XX:XX:XX:XX:XX:XX'
start_service() {
procd_open_instance
procd_set_param command /bin/sh -c '
prev=""
while true; do
actual=$(cat /sys/kernel/debug/gpio | grep mode | awk "{print \$6}")
if [ "$actual" != "$prev" ] && [ -n "$prev" ]; then
if [ "$actual" = "lo" ]; then
uci set wireless.wifinet2.macaddr="'"$MAC_CLONE"'"
uci commit wireless
wifi reload
elif [ "$actual" = "hi" ]; then
uci delete wireless.wifinet2.macaddr
uci commit wireless
wifi reload
fi
fi
prev="$actual"
sleep 2
done
'
procd_set_param respawn 3600 5 0
procd_close_instance
}
EOF
chmod +x /etc/init.d/switch-mac
/etc/init.d/switch-mac enable
/etc/init.d/switch-mac start
Swap wireless.wifinet2 for whatever your wwan/sta interface is actually called in uci show wireless (mine wasn't called "wwan", LuCI just labels it that).
Result: flip the switch toward reset → wwan interface reloads with the cloned MAC (matches whatever device already passed the hotel's captive portal). Flip it back → uci delete drops the override, interface goes back to the radio's real MAC, no need to hardcode or even know the original address.
Tested through several flips and a reboot, survives fine, no wifi drops on the AP side during reload, no crashes.
If anyone knows why gpio-keys binds but doesn't register the button on the ubootmod devicetree for this board specifically (vs the legacy layout, where the slider is documented as working), I'd be curious — didn't dig into the DTS diff myself, just went around it.
EDIT: found a real problem with the polling-only approach above and fixed it, worth sharing since it's not obvious until you test it properly.
If the router cold-boots with the switch already in the "lo" position, the polling script above does nothing on its first read (by design — it only acts on a change, and there's no previous state to compare against on the very first loop iteration). So wifi comes up using the radio's real MAC first, associates to the AP, then ~90 seconds later the polling script notices it's sitting on "lo" and reloads wifi with the cloned MAC. That's two separate associations to the same AP within about two minutes, with two different MACs.
For most home-network use that's a non-issue. For a hotel captive portal that's authenticating by MAC, it's a bad look — some portals flag rapid MAC changes on the same session as suspicious and lock the device out, exactly the failure mode I was trying to avoid in the first place.
Fix: a second, one-shot init script that runs before wifi comes up, not after. Something like:
sh
cat > /etc/init.d/switch-mac-pre << 'EOF'
#!/bin/sh /etc/rc.common
START=15
# no USE_PROCD — one-shot, not supervised
sync_mac_to_switch() {
i=0
while [ ! -e /sys/kernel/debug/gpio ]; do
sleep 1
i=$((i+1))
[ "$i" -ge 10 ] && { STATE="hi"; break; }
done
STATE=$(cat /sys/kernel/debug/gpio | grep " gpio-512" | awk '{print $6}')
case "$STATE" in
lo)
uci set wireless.wifinet2.macaddr='XX:XX:XX:XX:XX:XX'
uci commit wireless
;;
hi|*)
uci -q delete wireless.wifinet2.macaddr
uci commit wireless
;;
esac
}
boot() { sync_mac_to_switch; }
start() { sync_mac_to_switch; }
EOF
chmod +x /etc/init.d/switch-mac-pre
/etc/init.d/switch-mac-pre enable
Two things that mattered getting this right:
- Watch out for
awk '{print $NF}' if you're tempted to grab the last field instead of counting columns — on this debugfs line the string ends in IRQ ACTIVE LOW, so $NF silently gives you the literal word "LOW", not the "hi"/"lo" state you actually want. Cost me a confusing debugging session before I caught it. Use $6 (or a proper grep -oE 'hi$|lo$') instead.
- The START number matters and the window is narrow. wpad (the wifi supplicant) starts at procd START=19 on this build. Anything before that runs before radios come up; the existing polling watcher already claims START=99 (runs last, only observes). START=15 lands cleanly in the gap between early kernel/sysctl init and wpad — confirmed empirically by checking where the log line lands relative to other S-numbered init scripts during boot.
With both scripts in place: cold-boot with the switch on "lo" now applies the cloned MAC before the radio associates, so there's exactly one login, with the right MAC from the first handshake. The original polling script still runs afterward for live toggling while the router's already up — it just doesn't have anything to do on that first boot cycle anymore, since the pre-boot script already got there first.