深夜提醒

现在是深夜,建议您注意休息,不要熬夜哦~

🏮 🏮 🏮

新年快乐

祝君万事如意心想事成!

share-image
ESC

从 NGINX-RTMP 到大疆 Action 5 Pro:一场骑行直播推流实战

前言:最近在做的 Health Sync(骑行运动 App)里接入了大疆 Osmo Action 5 Pro 的实时推流。这套链路看起来简单——相机推 RTMP,服务器收流,前端播放——但真要把“BLE 控制 + RTMP 推流 + 服务端鉴权 + 本地录制”串起来,还是踩了不少坑。这篇文章把整个实现过程记录下来,给有同样需求的同学一个参考。

一、整体链路

先上一张最终效果截图,这是 Health Sync 里“共享轨迹”页面的推流配置面板:

大疆 Action 5 Pro 推流就绪

整个数据流是这样的:

大疆 Action 5 Pro(相机)
↓ 通过 BLE 接收 Wi-Fi/RTMP 配置
连接手机热点或家庭 Wi-Fi
↓ 推 RTMP 流
SRS 服务器(rtmp.awen.me:1935
↓ http_hooks 鉴权
本地录制 FLV + 可选 HUD 烧录
↓ HLS/RTMP 分发
前端直播页 / CDN 播放

注意:手机在这里不是“中转站”,相机是直接连 Wi-Fi 推流;手机只通过 BLE 把 Wi-Fi 名、密码、RTMP 地址下发给相机。

开播后,App 端会显示“相机直播中”:

App 端显示相机直播中

网页端则能看到实时视频面板和地图轨迹:

网页端实时直播面板

二、服务端:SRS 接收 RTMP 推流

虽然标题写了 NGINX-RTMP,但我实际生产环境用的是 SRS(Simple Realtime Server)。原因很朴素:SRS 的 DVR 录制、exec_publish 回调、HTTP 鉴权钩子都更开箱即用,对单人项目更友好。如果你确实想用 NGINX-RTMP,后面会给一个等价的配置片段。

2.1 SRS 核心配置

# SRS RTMP 接收 + 本地录制(FLV),由 convert_mp4.sh 转成 MP4
# 推流地址: rtmp://rtmp.awen.me:1935/live/<stream-key>
listen 1935;
max_connections 1000;
daemon on;
srs_log_tank file;
srs_log_file /home/wenjun/srs/logs/srs.log;
pid /home/wenjun/srs/logs/srs.pid;

vhost __defaultVhost__ {
http_hooks {
enabled on;
on_publish http://127.0.0.1:8085/api/v1/publish_auth;
}

dvr {
enabled on;
dvr_plan session;
dvr_path /home/wenjun/srs-records/[stream].[timestamp].flv;
dvr_wait_keyframe on;
}
}

几个关键配置:

  • listen 1935:标准 RTMP 端口。
  • on_publish:推流前必须通过这个鉴权接口,否则拒绝推流。
  • dvr_plan session:一次推流生成一个 FLV 文件,结束后自动落盘。
  • dvr_wait_keyframe on:从关键帧开始录,避免首帧花屏。

2.2 等价的 NGINX-RTMP 配置

如果你更熟悉 NGINX-RTMP,可以这样配:

rtmp {
server {
listen 1935;
chunk_size 4096;

application live {
live on;
on_publish http://127.0.0.1:8085/api/v1/publish_auth;
record all;
record_path /home/wenjun/srs-records;
record_unique on;
record_suffix .flv;
}
}
}

功能对等:鉴权钩子、录制 FLV、唯一文件名。SRS 和 NGINX-RTMP 在这里只是实现方式不同,协议层面都是标准 RTMP。

三、推流鉴权:防止被恶意盗推

鉴权接口 http://127.0.0.1:8085/api/v1/publish_auth 是 Go 服务(blog_api)提供的。它会校验 URL 里的 auth_key 参数,符合阿里云直播 URL 鉴权 A 方式:

sstring = "/live/<streamKey>-<exp>-0-0-<PUSH_AUTH_KEY>"
md5hash = md5(sstring)
auth_key = <exp>-0-0-<md5hash>

Android 端生成带签名的推流地址:

object LiveUrlSigner {
private const val PUSH_AUTH_KEY = "Gg2pA4eWZCoMk5vW"
private const val APP = "live"

fun signedPushUrl(streamKey: String): String {
val exp = System.currentTimeMillis() / 1000
val uri = "/$APP/$streamKey"
val sstring = "$uri-$exp-0-0-$PUSH_AUTH_KEY"
val md5hash = md5Hex(sstring)
return "rtmp://rtmp.awen.me/$APP/$streamKey?auth_key=$exp-0-0-$md5hash"
}
}

这样即使有人拿到推流域名,没有密钥也无法伪造合法的 auth_key

四、Android 端:BLE 控制大疆 Action 5 Pro

这是整套链路里最“黑盒”的部分。大疆没有公开 Action 5 Pro 的直播控制协议,这部分是参考 MIT 开源项目 datagutt/node-osmo 的 BLE 协议,用 Kotlin 在 Android 上重新实现的。

4.1 发现与连接

相机开机后会以 BLE 广播,Manufacturer Data 前两个字节是 0x08AA(DJI 厂商 ID)。App 扫描到后连接 GATT,服务 UUID 是 0000fff0-...,主要操作 fff3(写命令)、fff4(读/通知)、fff5(电量通知)。

// 扫描回调里识别大疆相机
val mfg = record.getManufacturerSpecificData(DjiOsmoProtocol.DJI_MANUFACTURER_ID)
val modelName = DjiOsmoProtocol.modelNameFromManufacturerData(mfg)

4.2 配对

连接成功后,相机会通过 fff4 发一个触发包,App 回复配对请求,PIN 固定为 love(node-osmo 逆向值):

val frame = DjiOsmoProtocol.encodeMessage(
DjiOsmoProtocol.TARGET_PAIR,
DjiOsmoProtocol.ID_PAIR,
DjiOsmoProtocol.TYPE_PAIR,
DjiOsmoProtocol.pairPayload()
)

4.3 开播状态机

配对完成后并不是直接开播,而是要走一套状态机:

已配对
→ 停止推流清场(STOP_STREAM)
→ 准备直播(PREPARE_LIVE)
→ 下发 Wi-Fi 名/密码(SETUP_WIFI)
→ 配置防抖(CONFIGURE,仅 OA4/OA5)
→ 下发 RTMP 地址开播(START_STREAM)
Action 5 Pro 额外发确认帧才真正开始推流

对应代码:

fun startLiveStream(context: Context, ssid: String, password: String, rtmpUrl: String) {
pendingSsid = ssid
pendingPassword = password
pendingRtmpUrl = rtmpUrl
startFlowWatchdog()
sendStopAndClean() // 第一步:清场
}

为什么要先 STOP_STREAM?因为相机会记忆上一次的直播状态,直接开播可能冲突。先清场再重新走完整流程最稳。

4.4 RTMP 地址下发

val payload = DjiOsmoProtocol.startStreamingPayload(
rtmpUrl = url,
resolutionByte = DjiOsmoProtocol.RES_1080P,
fpsByte = DjiOsmoProtocol.FPS_30,
bitrateKbps = 6000
)
writeMessage(DjiOsmoProtocol.ID_START_STREAM, DjiOsmoProtocol.TYPE_START_STREAM, payload)

目前固定 1080P/30fps/6Mbps。Action 5 Pro 收到后并不会立刻推流,需要再发一个特殊的确认帧,把 STOP_STREAM 的 payload 最后一位改成 0x01

if (isAction5Pro) {
writeMessage(
DjiOsmoProtocol.ID_STOP_STREAM,
DjiOsmoProtocol.TYPE_STOP_STREAM,
DjiOsmoProtocol.confirmStartStreamingPayload()
)
}

这个确认帧是 OA5 特有的,少了它相机只会“假装”开播,实际不推流。

五、纯代码 RTMP 客户端(备用)

除了相机直推,App 里还有一个轻量的 RtmpPublisher,用于实时语音推流,不依赖第三方库:

class RtmpPublisher(host: String, port: Int, app: String) {
fun connect(streamKey: String): Int
fun sendFlvTag(streamId: Int, timestamp: Int, type: Int, tag: ByteArray)
}

它自己实现了 RTMP 握手、connect/createStream/publish 命令、AMF0 编解码、FLV Tag 分块发送。这个类是给语音直播用的,不是给 Action 5 Pro 视频用的,但协议实现本身是通用的。

六、HUD 实时叠加:把心率/速度/配速烧进视频

推流成功后,下一步就是在视频上叠加运动数据。我的方案是:相机推原始流到 SRS,SRS 通过 exec_publish 触发一个 Python 脚本,用 FFmpeg 拉流、drawtext 叠加、再推回 SRS 的另一个流名。

.venv/bin/python tools/live_stream_hud.py \
--input rtmp://127.0.0.1:1935/live/STREAM \
--output rtmp://127.0.0.1:1935/live/STREAM_hud \
--stream-name STREAM

live_stream_hud.py 会从 blogapi.awen.me/api/v1/gps/track 拉取实时数据,包括心率、速度、配速、距离、时长,然后用 FFmpeg drawtext 滤镜渲染到画面上。前端播放 STREAM_hud 即可看到带 HUD 的直播。

SRS 配置里加一行 exec_publish 就能自动触发:

vhost __defaultVhost__ {
exec_publish /home/wenjun/my_blog/.venv/bin/python
/home/wenjun/my_blog/tools/live_stream_hud.py
--input rtmp://127.0.0.1:1935/live/$argv
--output rtmp://127.0.0.1:1935/live/${argv}_hud
--stream-name $argv
--no-audio;
}

七、踩坑记录

  1. BLE MTU 必须协商
    大疆部分命令包超过 20 字节,默认 MTU 23 会把通知截断,导致解析失败。连接后要先 requestMtu(512)

  2. Action 5 Pro 需要确认帧
    下发 START_STREAM 后,必须再发一个 confirmStartStreamingPayload(),否则相机不真正推流。

  3. 一定要先 STOP 再 START
    相机记忆上次直播状态,直接开播大概率失败。每次开播前先清场。

  4. Wi-Fi 扫描权限坑
    Android 13+ 扫描 Wi-Fi 需要 NEARBY_WIFI_DEVICES,但触发扫描和读取结果还分别需要 CHANGE_WIFI_STATEACCESS_WIFI_STATE。三个权限都要在 Manifest 里声明。

  5. SRS 的鉴权钩子只对 RTMP 生效
    HLS 播放地址如果也要保护,需要另外做 referer 或 token 校验,不能指望同一个 on_publish

八、总结

这套方案的成本很低:一台阿里云 ECS 跑 SRS,一个自己实现的 Android BLE 控制层,就能把大疆 Action 5 Pro 变成骑行直播相机。后续还可以继续优化:

  • 把 HUD 渲染从服务端 FFmpeg 下沉到相机端(如果大疆开放接口)。
  • 用 WebRTC 替代 RTMP,进一步降低直播延迟。
  • 把本地录制的 FLV 自动转 MP4 并上传到 OSS,实现“直播结束即回放”。

目前这套链路已经能稳定跑通:App 配置 Wi-Fi → BLE 连接相机 → 下发 RTMP → 相机直推 → SRS 收流并录制 → FFmpeg 叠加 HUD → 前端播放。对一个人项目来说,够用了。

文章作者:阿文
文章链接: https://www.awen.me/post/8592fe04.html
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 阿文的博客

评论

0 条评论
😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘 🛑📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭 ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️ 🈯💹❇️ ✳️🌐 💠Ⓜ️🌀 💤🏧🚾 🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️
加载中...

留言反馈

😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘 🛑📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭 ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️ 🈯💹❇️ ✳️🌐 💠Ⓜ️🌀 💤🏧🚾 🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️