一、最终效果

小米手机原来的投放方式保持不变;iPhone 和 Mac 通过 AirPlay 投放到音箱本机的 xiaoai-airplay。默认优先 AirPlay 2,AirPlay 1 作为兼容和降级路径。

iPhone / Mac → AirPlay 2 优先 → xiaoai-airplay → ALSA default → OH2P 扬声器
                                              └→ vis / ledd → 音乐灯条
  • AirPlay 播放经过小爱原生 default 音频链路,保留音乐灯条呼吸。
  • 喊“小爱同学”时自动压低 AirPlay 音频,语音结束后恢复。
  • 新设备开始播放时停止旧音频任务,并真正关闭旧设备的 RTSP 控制连接。
  • AirPlay 服务独立运行,不覆盖原有 Agent、语音唤醒和原生启动功能。

二、使用的项目

刷机和获取固件使用 stevenjoezhang/xiaoai-agent,其中固件制作目录是 deploy/client-patch/,macOS 刷机工具是 deploy/flash-tool/,本次设备为 OH2P,实测固件为 1.62.2。

AirPlay 接收端源码使用 hitushen/xiaoai-airplay。刷机时必须同时保留原始和补丁 rootfs:

root.squashfs          原始 rootfs,用于恢复和对照
root-patched.squashfs 补丁 rootfs,用于刷入

三、群晖 AirConnect 为什么没有直接解决

最初群晖上能看到 airupnp 监听 49154,但 iPhone 和 Mac 找不到音箱。小米手机投放可能走小米私有投放或 MiNA 链路,并不代表 OH2P 提供标准 DLNA MediaRenderer。

AirPlay 发送端 → AirConnect → 标准 DLNA/UPnP 播放器

由于 OH2P 对标准 SSDP/DLNA 发现没有稳定响应,最后改为在音箱本机运行 AirPlay 接收端:

iPhone / Mac → xiaoai-airplay → ALSA default → OH2P

四、获取 OTA、制作补丁固件

git clone https://github.com/stevenjoezhang/xiaoai-agent.git
cd xiaoai-agent/deploy/client-patch
cp .env.example .env

.env 只在本地保存,不要提交账号、密码、Token 或 SSH 密码:

MI_USER=<小米数字账号ID>
MI_PASS=<小米账号密码>
MI_DID=<小爱音箱名称或DID>
SSH_PASSWORD=<自己设置的SSH密码>

macOS Apple Silicon 推荐使用 amd64 Docker 环境:

docker build --platform linux/amd64 -t xiaoai-agent-client-patch .
docker run -it --rm --platform linux/amd64 \
  --env-file "$(pwd)/.env" \
  -v "$(pwd)/assets:/app/assets" \
  -v "$(pwd)/patches:/app/patches" \
  xiaoai-agent-client-patch

构建流程是:获取匹配 OTA,保存原始 root.squashfs,写入 Dropbear SSH 和 /data/init.sh 补丁,再生成 root-patched.squashfs。

五、备份、刷机和 SSH

刷机前保存原始 OTA、两个 rootfs、设备型号和固件版本,以及 /data 中的重要文件。按项目针对 OH2P 1.62.2 的说明刷入,不要断电或拔 USB。启动后从 Mac 登录:

ssh root@<speaker-ip>

uname -a
ls -la /data/open-xiaoai
ps | grep '[d]ropbear'
netstat -lntup | grep ':22'

若补丁系统无法启动,按项目说明切回原系统启动分区,例如 boot1。

六、部署 Agent

Agent 和 AirPlay 分开部署,Agent 文件放在 /data/open-xiaoai/。Dropbear 不一定提供 SFTP,上传时使用旧 SCP 协议:

scp -O target/armv7-unknown-linux-gnueabihf/release/xiaoai-agent \
  root@<speaker-ip>:/data/open-xiaoai/xiaoai-agent
scp -O agent.yaml root@<speaker-ip>:/data/open-xiaoai/agent.yaml
scp -O start-agent.sh root@<speaker-ip>:/data/open-xiaoai/start-agent.sh

先手动确认唤醒、录音、ASR、模型回复和 TTS 正常,再配置开机启动。

七、AirPlay 接收端源码

应用使用本地 vendored shairplay,并启用 AP2:

shairplay = { path = "vendor/shairplay", default-features = false, features = ["ap2"] }

配置使用:

device: default
mode: ap2

default 会经过小爱原生的 vis → ledd 音频链路,不能为了直接出声改成 Playback,否则可能绕过灯效。

协议选择代码:

builder = builder.mode(match config.mode.to_ascii_lowercase().as_str() {
    "ap1" => AirPlayMode::AirPlay1,
    _ => AirPlayMode::AirPlay2,
});

默认优先 AirPlay 2;只支持传统 RAOP 的设备可以把配置改成 mode: ap1 后重启。AP1 是兼容/降级路径,默认配置仍然是 AP2。

八、多设备接管:真正关闭旧连接

旧版本只停止音频任务时,旧设备会显示“仍连接但没有声音”,因为 RTSP TCP 控制连接还在服务端。现在分两层处理:停止旧音频输出任务,并关闭旧发送端的全部 RTSP TCP 控制连接。

net/server.rs 为每条连接增加 watch 关闭通知:

let (shutdown_tx, shutdown_rx) = watch::channel(false);
process_connection(stream, handler, shutdown_rx).await;

连接循环同时等待 TCP 数据和关闭通知:

tokio::select! {
    result = stream.read(&mut buffer) => {
        // 正常处理 RTSP 请求
    }
    changed = shutdown_rx.changed() => {
        if changed.is_ok() && *shutdown_rx.borrow() {
            break;
        }
    }
}

raop/connection.rs 按发送端 IP 登记连接。新设备完成音频协商后,只踢掉其他 IP:

pub(crate) fn takeover_sender(&self, keep_ip: IpAddr) {
    self.kick_other_clients(keep_ip);
}

同一个发送端 IP 的并行连接会保留,避免 iPhone 在 AP2 协商期间被误踢。AP1 在 RTP SETUP 成功后接管,AP2 在 realtime 或 buffered audio 建立后接管并停止上一个音频任务。

九、灯条、语音压低和开机自启

正常播放:PCM × 1.0
语音活动:PCM × 0.2
语音结束:PCM × 1.0

程序监听 SpeechWakeup、DuplexRecognizeStarted、Dialog.Finish 和 ExitContinuousDialog,只压低 AirPlay PCM,不改变原生 TTS 音量。音频继续走 default → vis → ledd,所以音乐灯条可以保持呼吸。

开机自启不要覆盖原来的 /data/init.sh。先备份,再只增加独立启动命令:

cp -p /data/init.sh /data/init.sh.bak.$(date +%Y%m%d-%H%M%S)
[ -x /data/open-xiaoai/start-airplay.sh ] && \
  sh /data/open-xiaoai/start-airplay.sh start > /dev/null 2>&1 &

十、OH2P ARMv7 构建和部署

OH2P 运行时 glibc 较旧,使用 cargo-zigbuild 限制最高 glibc 版本:

RUSTC=~/.rustup/toolchains/1.99.0-aarch64-apple-darwin/bin/rustc \
  ~/.rustup/toolchains/1.99.0-aarch64-apple-darwin/bin/cargo zigbuild \
  --release --target armv7-unknown-linux-gnueabihf.2.25

strings target/armv7-unknown-linux-gnueabihf/release/xiaoai-airplay \
  | grep -E '^GLIBC_[0-9]' | sort -u

确认没有高于 GLIBC_2.25 的依赖,再上传并先备份线上二进制:

cd /data/open-xiaoai
cp -p xiaoai-airplay xiaoai-airplay.bak.$(date +%Y%m%d-%H%M%S)
./start-airplay.sh stop
wget -q -O xiaoai-airplay.new http://<mac-ip>:8765/xiaoai-airplay
sha256sum xiaoai-airplay.new
chmod +x xiaoai-airplay.new
mv xiaoai-airplay.new xiaoai-airplay
./start-airplay.sh start
./start-airplay.sh status

十一、验收和回滚

/data/open-xiaoai/start-airplay.sh status
ps w | grep '[x]iaoai-airplay'
netstat -lntup | grep 5000

Mac 发现服务:

dns-sd -B _raop._tcp local
dns-sd -B _airplay._tcp local

真机验收:播放并确认有声、灯条呼吸、语音自动压低和结束后恢复,再用第二台设备接管,确认第一台真正断开。回滚时先停止 AirPlay,恢复带时间戳的 xiaoai-airplay.bak-* 和配置;刷机相关的原始 OTA、rootfs 和 /data 备份不要删除。

十二、总结

xiaoai-agent → 获取 OTA、制作补丁固件、开启 SSH
        ↓
SSH → 部署自定义 Agent
        ↓
xiaoai-airplay → AirPlay 2 优先,AirPlay 1 兼容
        ↓
关闭旧 RTSP 连接 + 停止旧音频任务
        ↓
ALSA default → 保留 vis / ledd 灯效
        ↓
语音活动时自动压低 AirPlay

小米手机原来的投放方式仍然可以使用;iPhone 和 Mac 可以通过 AirPlay 投到音箱。

免责声明

本文是个人设备上的技术折腾记录。刷机、修改 rootfs、禁用自动更新都可能导致变砖、失去保修、服务异常或数据丢失。请只在自己拥有或获得授权的设备上操作,先确认型号和版本,并妥善保留原始 OTA、rootfs、设备数据和回滚文件。不要公开小米账号、密码、Token、SSH 密码或私钥。