使用 systemd + NetworkManager 保持 USB Wi-Fi 网卡连接到固定 BSSID

分享

目标

系统使用 NetworkManager,并以 iwd 作为 Wi-Fi 后端。

对于一张可热插拔 USB Wi-Fi 网卡,需要保证:

  • USB 网卡插入后自动恢复连接;
  • NetworkManager 始终管理该接口;
  • 固定连接指定 SSID;
  • 固定连接指定 BSSID,避免连接到同 SSID 的其他 AP;
  • 连接断开后能够自动恢复;
  • 不干扰系统中的其他 Wi-Fi 接口;
  • 不直接操作 iwd,而由 NetworkManager 统一管理连接状态。

本文配置如下:

Interface:  wlxe0e1a9101c1c
MAC:        xx:xx:xx:xx:xx:xx
Driver:     mt76x2u

Connection: qdzx
UUID:       ca45b79a-1ea5-4658-b72d-2f23d0ef4612

SSID:       qdzx
BSSID:      4e:77:66:98:4f:49

1. 固定 NetworkManager Connection Profile

首先将已有的 NetworkManager profile 绑定到指定接口、MAC、SSID 和 BSSID:

sudo nmcli connection modify uuid ca45b79a-1ea5-4658-b72d-2f23d0ef4612 \
    connection.interface-name wlxe0e1a9101c1c \
    connection.autoconnect yes \
    connection.autoconnect-retries 0 \
    connection.autoconnect-priority 100 \
    802-11-wireless.ssid qdzx \
    802-11-wireless.bssid 4e:77:66:98:4f:49 \
    802-11-wireless.mac-address xx:xx:xx:xx:xx:xx\
    802-11-wireless.powersave 2

其中:

connection.autoconnect=yes

允许 NetworkManager 自动激活该 profile。

connection.autoconnect-retries=0

表示自动连接失败后持续重试,不限制尝试次数。

802-11-wireless.bssid=4e:77:66:98:4f:49

将 connection profile 锁定到指定 AP。即使存在其他相同 SSID 的 AP,该 profile 也不会切换过去。

802-11-wireless.mac-address=xx:xx:xx:xx:xx:xx

限制该 profile 只能由指定 USB 网卡使用。

802-11-wireless.powersave=2

关闭该连接的 Wi-Fi power saving,避免 USB 无线网卡因节能机制进入不期望的低功耗状态。

可以使用以下命令检查结果:

nmcli -f \
connection.id,\
connection.uuid,\
connection.interface-name,\
connection.autoconnect,\
connection.autoconnect-retries,\
802-11-wireless.ssid,\
802-11-wireless.bssid,\
802-11-wireless.mac-address,\
802-11-wireless.powersave \
connection show uuid ca45b79a-1ea5-4658-b72d-2f23d0ef4612

2. Wi-Fi Keeper

创建:

/usr/local/sbin/usb-wifi-lock

内容如下:

#!/bin/bash

set -u

IFACE="wlxe0e1a9101c1c"
UUID="ca45b79a-1ea5-4658-b72d-2f23d0ef4612"
SSID="qdzx"
BSSID="4e:77:66:98:4f:49"
MAC="xx:xx:xx:xx:xx:xx"

last_state=""

log()
{
    echo "usb-wifi-lock: $*"
}

set_state()
{
    if [[ "$last_state" != "$1" ]]; then
        log "$2"
        last_state="$1"
    fi
}

while true; do

    #
    # USB 网卡不存在。
    #
    if [[ ! -e "/sys/class/net/$IFACE" ]]; then
        set_state "absent" \
            "$IFACE absent; waiting for USB adapter"

        sleep 2
        continue
    fi

    #
    # 确认接口对应目标物理网卡。
    #
    CURRENT_MAC="$(
        cat "/sys/class/net/$IFACE/address" 2>/dev/null || true
    )"

    if [[ "${CURRENT_MAC,,}" != "${MAC,,}" ]]; then
        set_state "mac-error" \
            "MAC mismatch: expected $MAC, got ${CURRENT_MAC:-unknown}"

        sleep 5
        continue
    fi

    #
    # 查询 NetworkManager 对设备的实际状态。
    #
    NM_STATE_RAW="$(
        LC_ALL=C nmcli -g GENERAL.STATE \
            device show "$IFACE" 2>/dev/null |
            head -n1 || true
    )"

    NM_STATE="${NM_STATE_RAW%% *}"

    #
    # sysfs 中已经出现接口,但 NetworkManager 尚未识别。
    #
    if [[ -z "$NM_STATE" ]]; then
        set_state "waiting-nm" \
            "$IFACE exists; waiting for NetworkManager"

        sleep 2
        continue
    fi

    case "$NM_STATE" in

        #
        # NM_DEVICE_STATE_UNMANAGED
        #
        10)
            set_state "unmanaged" \
                "$IFACE is unmanaged; waiting for NetworkManager"

            sleep 2
            continue
            ;;

        #
        # NM_DEVICE_STATE_UNAVAILABLE
        #
        # 设备虽然已经由 NetworkManager 管理,
        # 但 Wi-Fi backend/driver 初始化尚未结束。
        #
        # 此阶段不得执行 nmcli connection up。
        #
        20)
            set_state "unavailable" \
                "$IFACE is unavailable; waiting for iwd/NM initialization"

            sleep 2
            continue
            ;;

        #
        # NM_DEVICE_STATE_DISCONNECTED
        #
        # 设备已经完成初始化,可以进行连接。
        #
        30)
            set_state "disconnected" \
                "$IFACE ready/disconnected; activating $SSID"

            LC_ALL=C nmcli --wait 20 connection up \
                uuid "$UUID" \
                ifname "$IFACE" || true

            sleep 2
            continue
            ;;

        #
        # NetworkManager 正在执行连接流程。
        #
        # 40 = prepare
        # 50 = config
        # 60 = need-auth
        # 70 = ip-config
        # 80 = ip-check
        # 90 = secondaries
        #
        40|50|60|70|80|90)
            set_state "activating-$NM_STATE" \
                "$IFACE NetworkManager activation in progress (state=$NM_STATE)"

            sleep 2
            continue
            ;;

        #
        # NM_DEVICE_STATE_ACTIVATED
        #
        100)
            ACTIVE_UUID="$(
                nmcli -g GENERAL.CON-UUID \
                    device show "$IFACE" 2>/dev/null |
                    head -n1 || true
            )"

            CURRENT_BSSID="$(
                iw dev "$IFACE" link 2>/dev/null |
                awk '/^Connected to / {print $3; exit}'
            )"

            CURRENT_SSID="$(
                iw dev "$IFACE" link 2>/dev/null |
                sed -n 's/^[[:space:]]*SSID: //p' |
                head -n1
            )"

            IPV4="$(
                nmcli -g IP4.ADDRESS \
                    device show "$IFACE" 2>/dev/null |
                    head -n1 || true
            )"

            #
            # 必须同时满足:
            #
            #   NetworkManager == activated
            #   Connection UUID 正确
            #   SSID 正确
            #   BSSID 正确
            #   IPv4 已获取
            #
            # 才认为链路真正可用。
            #
            if [[ "$ACTIVE_UUID" == "$UUID" ]] &&
               [[ "$CURRENT_SSID" == "$SSID" ]] &&
               [[ "${CURRENT_BSSID,,}" == "${BSSID,,}" ]] &&
               [[ -n "$IPV4" ]]; then

                set_state "connected" \
                    "OK: $IFACE -> $SSID / $BSSID / $IPV4"

                sleep 5
                continue
            fi

            #
            # NetworkManager 已经认为接口 activated,
            # 但实际连接并非指定 profile/AP。
            #
            log "activated but target mismatch:"
            log "  UUID=${ACTIVE_UUID:-none}"
            log "  SSID=${CURRENT_SSID:-none}"
            log "  BSSID=${CURRENT_BSSID:-none}"
            log "  IPv4=${IPV4:-none}"
            log "switching to fixed profile"

            last_state="switching"

            LC_ALL=C nmcli --wait 20 connection up \
                uuid "$UUID" \
                ifname "$IFACE" || true

            sleep 3
            continue
            ;;

        #
        # NM_DEVICE_STATE_DEACTIVATING
        #
        110)
            set_state "deactivating" \
                "$IFACE is deactivating; waiting"

            sleep 2
            continue
            ;;

        #
        # NM_DEVICE_STATE_FAILED
        #
        # NetworkManager 通常会自行转换回 disconnected,
        # 因此这里只等待,不立即重复操作。
        #
        120)
            set_state "failed" \
                "$IFACE activation failed; waiting for NM recovery"

            sleep 3
            continue
            ;;

        *)
            set_state "unknown-$NM_STATE" \
                "$IFACE unknown NetworkManager state: $NM_STATE_RAW"

            sleep 3
            continue
            ;;
    esac

done

赋予执行权限:

sudo chmod 755 /usr/local/sbin/usb-wifi-lock

3. systemd Service

创建:

/etc/systemd/system/usb-wifi-lock.service

内容:

[Unit]
Description=Keep MediaTek USB Wi-Fi connected to fixed AP
Requires=NetworkManager.service
After=NetworkManager.service
StartLimitIntervalSec=0

[Service]
Type=simple
ExecStart=/usr/local/sbin/usb-wifi-lock
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target

加载并启动:

sudo systemctl daemon-reload
sudo systemctl enable --now usb-wifi-lock.service

检查状态:

systemctl status usb-wifi-lock.service

正常状态应为:

Active: active (running)

查看运行日志:

journalctl -fu usb-wifi-lock.service

连接完成后会出现:

usb-wifi-lock: OK: wlxe0e1a9101c1c -> qdzx / 4e:77:66:98:4f:49 / 192.168.0.x/24

4. 不使用 udev 启动 Service

该方案不需要通过 udev 在 USB 插入事件上启动 systemd unit。

如果存在此前创建的规则,可以删除:

sudo rm -f /etc/udev/rules.d/80-usb-wifi-lock.rules
sudo rm -f /etc/systemd/system/usb-wifi-lock@.service

sudo udevadm control --reload-rules
sudo systemctl daemon-reload

usb-wifi-lock.service 本身是常驻服务。

USB 网卡不存在时,它只进入等待状态:

wlxe0e1a9101c1c absent; waiting for USB adapter

USB 网卡重新插入后,Linux、udev、iwd 和 NetworkManager 完成设备枚举,keeper 再根据 NetworkManager 的设备状态决定是否执行连接。

这样可以避免两个问题:

  1. USB 无线网卡刚创建时,其临时接口名可能是 wlanN,随后才按照 persistent naming policy 重命名为 wlxe0e1a9101c1c
  2. 接口已经出现在 sysfs 中,并不意味着 NetworkManager/iwd 已经完成初始化。

因此,不应把“网卡出现在 /sys/class/net”等同于“网卡可以立即连接”。


5. NetworkManager 状态机

这个配置的关键是严格遵守 NetworkManager 的设备状态,而不是仅检查 iw link

简化后的状态转换为:

USB absent
    |
    v
interface created
    |
    v
UNMANAGED (10)
    |
    v
UNAVAILABLE (20)
    |
    v
DISCONNECTED (30)
    |
    v
PREPARE (40)
    |
    v
CONFIG (50)
    |
    v
IP-CONFIG (70)
    |
    v
IP-CHECK (80)
    |
    v
SECONDARIES (90)
    |
    v
ACTIVATED (100)

keeper 的策略为:

10  unmanaged
    -> wait

20  unavailable
    -> wait

30  disconnected
    -> nmcli connection up

40-90
    -> NetworkManager 正在连接,wait

100 activated
    -> 验证 UUID / SSID / BSSID / IPv4

110 deactivating
    -> wait

120 failed
    -> wait for NM recovery

尤其需要注意 UNAVAILABLEDISCONNECTED 的区别。

UNAVAILABLE 表示 NetworkManager 已经看到设备,但底层 Wi-Fi backend、驱动或者设备初始化尚未达到可连接状态。此时主动执行 nmcli connection up 容易与 iwd/NetworkManager 初始化产生竞态。

DISCONNECTED 才表示设备已经完成初始化,并且当前没有连接,可以安全请求 NetworkManager 激活 profile。


以下状态:

Connected to 4e:77:66:98:4f:49
SSID: qdzx

IEEE 802.11 层已经完成 association。

它不能证明:

  • NetworkManager connection 已成功激活;
  • DHCP 已完成;
  • IPv4 地址已经获得;
  • 路由已经建立;
  • DNS 已安装;
  • 当前 connection UUID 正确。

因此 keeper 只有在以下条件全部成立时才报告 OK

NetworkManager state == 100
Connection UUID       == target UUID
SSID                  == qdzx
BSSID                 == 4e:77:66:98:4f:49
IPv4 address          != empty

也就是说,判断标准不是“无线已经关联”,而是“NetworkManager 管理下的网络连接已经完整建立”。


7. 验证

NetworkManager

nmcli -f \
GENERAL.STATE,\
GENERAL.CONNECTION,\
GENERAL.CON-UUID,\
IP4.ADDRESS \
device show wlxe0e1a9101c1c

正常应满足:

GENERAL.STATE:       100
GENERAL.CONNECTION:  qdzx
GENERAL.CON-UUID:    ca45b79a-1ea5-4658-b72d-2f23d0ef4612
IP4.ADDRESS:         192.168.0.x/24

无线链路

iw dev wlxe0e1a9101c1c link

应包含:

Connected to 4e:77:66:98:4f:49
SSID: qdzx

IPv4 路由

ip route

应存在类似:

192.168.0.0/24 dev wlxe0e1a9101c1c ...

如果 AP 提供默认网关,还会存在:

default via 192.168.0.1 dev wlxe0e1a9101c1c ...

8. 多网卡环境中的默认路由

如果机器同时连接多个 Wi-Fi,NetworkManager 会根据 route metric 选择默认路由。

例如:

default via 192.168.1.1 dev wlan0 metric 600
default via 192.168.0.1 dev wlxe0e1a9101c1c metric 601

表示:

  • wlan0 是首选默认出口;
  • USB Wi-Fi 仍然正常在线;
  • wlxe0e1a9101c1c 可以正常访问其 192.168.0.0/24 网络;
  • 默认流量不会优先走 USB 网卡。

因此,“保持 USB Wi-Fi 在线”和“让 USB Wi-Fi 成为默认出口”是两个不同的问题。

本文配置只解决前者,不修改系统默认路由优先级。


结论

在 NetworkManager + iwd 环境中,维护 USB Wi-Fi 热插拔连接时,合理的职责划分是:

kernel / driver
    -> 创建设备

udev
    -> 设备命名

iwd
    -> Wi-Fi backend

NetworkManager
    -> association / authentication / DHCP / route / DNS

usb-wifi-lock
    -> 观察 NetworkManager 状态并确保目标 profile 最终处于 activated

守护程序只在 NetworkManager 明确处于可操作状态时发起连接,其余阶段保持等待。

这样既能处理 USB 网卡拔插,也能避免 systemd 脚本、NetworkManager 和 iwd 同时操作无线设备所产生的竞态。

阅读更多

蓝牙功放连接与音量异常排查记录

环境 笔记本运行 Ubuntu,桌面为 KDE Plasma Wayland,蓝牙栈为 BlueZ 加 PipeWire 1.6.2 加 WirePlumber,音频设备为一台 TVCTO 蓝牙功放,通过 A2DP 播放。 现象 三个问题同时存在。 第一,功放与电脑连接不稳定,表现为 Connection refused 111 与 Connection timed out 110 交替出现,重试若干次后可以连上。 第二,蓝牙日志中持续出现 Hands-Free Voice gateway 相关报错,A2DP 媒体流频繁断开重连。 第三,连接恢复后,系统音量调节异常。音量键只在

By bakamake

躺姿笔记本看视频的硬件与系统调优

问题背景 床头挡板与墙之间存在约三指宽的缝隙,笔记本竖直卡入其中,依靠开合角支撑。躺于床尾观看视频时,屏幕法线朝向下前方,与躺姿视线方向不一致。开合角已达机械上限,微调垫片只能改变数度,属于机构层面的约束,无法通过摆放优化解决。 一、视角问题的方案评估 方案:整机倒挂配合屏幕旋转。若开合角支持一百八十度,将键盘朝上放置,屏幕反向展开,屏幕平面朝向天花板,与躺姿视线一致。系统内将内屏旋转一百八十度即可正常显示。零硬件成本,前置条件为开合角达到一百八十度,且需接受键盘与触控板镜像。 二、KWin Wayland 下的屏幕旋转 图形界面路径为系统设置,显示与监视器,选中内屏,方向选翻转。配置由 KScreen 持久化。 命令行使用 kscreen-doctor: kscreen-doctor -o kscreen-doctor output.eDP-1.rotation.inverted kscreen-doctor

By bakamake

ros 工具怪癖

rqt 启动之后要关闭D符号debug,才能看到数据 2d pose estimate not working head frame 需要是map 才能接收2d pose estimate ,但是这部分是amcl处理数据来收敛的, ros2 wtf ,刚刚发现这个东西还挺好用的 要看到蓝→红渐变,必须订阅带 inflation layer 的 costmap topic,例如: * /local_costmap/costmap * /global_costmap/costmap /map 节点切换到 costmap 也只能看到洋红色的障碍物,因为/map里100是障碍物,以及0和-1,costmap接受的范围是0~255

By bakamake