ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Scrypted Amcrest 门铃事件流解析:从 AD410 真实抓包到源码级事件处理

Scrypted Amcrest 门铃事件流解析:从 AD410 真实抓包到源码级事件处理
  • 音视频
  • 后端
  • 物联网
  • 计算机视觉

【免费下载链接】scrypted

Scrypted is a high performance video integration and automation platform

项目地址:https://gitcode.com/gh_mirrors/sc/scrypted
点击查看免费下载

本文以 Scrypted 仓库中 Amcrest 插件的 AD410 门铃真实事件抓包(plugins/amcrest/ad410.md)为核心素材,逐事件解读 Amcrest 门铃在"按键呼叫"过程中推送的 multipart 事件流,并结合插件源码(amcrest-api.ts、main.ts)说明这些事件如何被解析、匹配,最终驱动 Scrypted 的BinarySensor(门铃状态)与MotionSensor(移动侦测)接口,帮助你在调试 Amcrest 门铃、对接自定义事件或排查门铃不触发问题时具备完整的协议级认知。

AD410 门铃的真实事件流抓包

plugins/amcrest/ad410.md 记录了 Amcrest AD410 门铃在一次完整"按键呼叫"(访客按下门铃按钮、无人接听、随后挂断)过程中通过 HTTP 长连接推送的原始事件流。这是理解整个 Amcrest 事件协议最直接的样本:

event --myboundary Content-Type: text/plain Content-Length:41 Code=_CallRemoveMask;action=Start;index=0 event --myboundary Content-Type: text/plain Content-Length:36 Code=AlarmLocal;action=Start;index=0 event --myboundary Content-Type: text/plain Content-Length:148 Code=_DoTalkAction_;action=Pulse;index=0;data={ "Action" : "Invite", "CallID" : "20220406135211@095697@192.168.10.20", "CallSrcMask" : 4 } event --myboundary Content-Type: text/plain Content-Length:68 Code=CallNoAnswered;action=Start;index=0;data={ "CallID" : "4" } event --myboundary Content-Type: text/plain Content-Length:41 Code=PhoneCallDetect;action=Start;index=0 event --myboundary Content-Type: text/plain Content-Length:41 Code=VideoMotionInfo;action=State;index=0 event --myboundary Content-Type: text/plain Content-Length:185 Code=_DoTalkAction_;action=Pulse;index=0;data={ "Action" : "Hangup", "CallID" : "20220406135211@095697@192.168.10.20", "CallSrcMask" : 4, "HangupReason" : "HangupByPhone" } event --myboundary Content-Type: text/plain Content-Length:35 Code=AlarmLocal;action=Stop;index=0 event --myboundary Content-Type: text/plain Content-Length:39 Code=CallNoAnswered;action=Stop;index=0 event --myboundary Content-Type: text/plain Content-Length:95 Code=VideoMotion;action=Stop;index=0;data={ "Id" : [ 0 ], "RegionName" : [ "Region1" ] } event --myboundary Content-Type: text/plain Content-Length:40 Code=PhoneCallDetect;action=Stop;index=0 event --myboundary Content-Type: text/plain Content-Length:40 Code=_CallRemoveMask;action=Stop;index=0

分帧格式:multipart 边界 + 头 + 定长 body

抓包中每一帧(part)的构成非常清晰:

  • --myboundary:multipart 流的边界行,用于分隔相邻两个事件 part。抓包中边界行前的event前缀是捕获工具/控制台打印的日志前缀,不属于流内容本身;
  • Content-Type: text/plain:该 part 的 MIME 类型,声明 body 是纯文本;
  • Content-Length:N:该 part body 的精确字节数,例如Code=_CallRemoveMask;action=Start;index=0恰好是 41 字节;
  • 空行:头与 body 的分隔;
  • body:Code=...;action=...;index=...;data={...}形式的事件负载,其中data字段是可选的 JSON。

这个分帧方式与 amcrest-api.ts 中的解析逻辑一一对应:插件从 HTTP 响应的content-type头中解析出 boundary(源码注释明确指出 Amcrest 可能发送--myboundary或带空格的-- myboundary两种形式),再对每个 part 读取头行、按Content-Length读取定长 body。

逐事件解读:一次完整门铃呼叫的生命周期

把 12 个事件按时间序展开,就是一次 AD410 门铃呼叫的完整状态机:

顺序事件语义
1Code=_CallRemoveMask;action=Start;index=0呼叫开始,解除掩码(门铃内部状态,插件忽略)
2Code=AlarmLocal;action=Start;index=0本地报警/响铃开始(插件忽略)
3Code=_DoTalkAction_;action=Pulse;index=0;data={"Action":"Invite",...}访客按下门铃按钮,发起双向对讲邀请(关键事件)
4Code=CallNoAnswered;action=Start;index=0;data={"CallID":"4"}呼叫开始被标记为"未应答"
5Code=PhoneCallDetect;action=Start;index=0检测到电话呼叫(门铃被按)
6Code=VideoMotionInfo;action=State;index=0移动侦测状态脉冲(访客靠近镜头触发)
7Code=_DoTalkAction_;action=Pulse;index=0;data={"Action":"Hangup",...,"HangupReason":"HangupByPhone"}对讲结束,由电话侧挂断(关键事件)
8Code=AlarmLocal;action=Stop;index=0本地报警停止
9Code=CallNoAnswered;action=Stop;index=0"未应答"状态结束
10Code=VideoMotion;action=Stop;index=0;data={"Id":[0],"RegionName":["Region1"]}移动侦测区域停止,RegionName指出触发区域
11Code=PhoneCallDetect;action=Stop;index=0电话呼叫检测结束
12Code=_CallRemoveMask;action=Stop;index=0呼叫结束,掩码恢复

几个值得注意的细节:

  • CallID的构成:"20220406135211@095697@192.168.10.20"遵循"时间戳@序号@来源IP"的格式,Invite 与 Hangup 两个脉冲携带相同CallID,可用于配对一次完整呼叫;CallSrcMask: 4表示呼叫来源掩码。
  • 动作语义:action取值为Start(进入某状态)、Stop(离开某状态)、Pulse(瞬时脉冲,常带 JSONdata)、State(状态上报)。
  • index=0:通道索引(0 基)。插件会把index + 1与设备配置的通道号比较,不匹配则丢弃(见后文)。

对照:AD110 的门铃事件流

仓库中另有 AD110 抓包 可以对照阅读。AD110 的呼叫事件序列更精简:NTPAdjustTime;action=Pulse(NTP 校时脉冲)、_DoTalkAction_;action=Pulse(Invite)、CallNoAnswered;action=Start、CallNoAnswered;action=Stop、_DoTalkAction_;action=Pulse(Hangup)。

两者的对比揭示了 Amcrest 不同型号门铃的事件协议差异:

  • AD410 会额外推送PhoneCallDetect、AlarmLocal、_CallRemoveMask、VideoMotion/VideoMotionInfo等事件,形成完整的呼叫 + 移动侦测生命周期;AD110 的抓包中则没有这些条目(或其固件版本未推送);
  • AD110 抓包中还混入了[HomeKit]: snapshot requested for reason: 1日志行——这是 Scrypted 控制台输出与原始事件流同一捕获通道产生的混排结果,并非 multipart 事件流的一部分,阅读此类抓包时需要区分;
  • 关键的双向对讲事件(_DoTalkAction_的 Invite/Hangup 脉冲、CallNoAnswered)在两个型号上完全一致,这正是插件能够用统一逻辑处理多种门铃的基础。

源码级解析:listenEvents 如何消费事件流

订阅端点与边界解析

AmcrestCameraClient.listenEvents() 是整个事件链的入口,它向设备发起一个 HTTP 长连接请求:

http://<ip>/cgi-bin/eventManager.cgi?action=attach&codes=[All]

codes=[All]表示订阅设备的全部事件码。拿到可读流后,解析流程与上面抓包的分帧格式严格对应:

  1. 逐行读取边界(common/src/read-stream.ts 的readUntil/readLine按换行切分);遇到终止边界--myboundary--跳过;
  2. 读头:readAmcrestMessage 持续读取行直到空行。源码注释专门处理了一个 Dahua(Amcrest 同源固件)的畸形情况——header 与 body 之间缺少换行分隔(Content-Length:39后紧跟 body 行),此时把不含:的行unshift回流中返回,避免误判;
  3. 读 body:common/src/rtsp-server.ts 的readBody按解析出的Content-Length精确读取定长数据,这保证了data={...}中跨多行的 JSON(如抓包里 Invite 脉冲的 148 字节)能被完整取出;
  4. 容错兼容:解析循环还处理了 Dahua 设备偶发在流中插入完整HTTP/1.1 200 OK响应消息的 bug(amcrest-api.ts#L271-L281),将其整段跳过并记录日志。

事件码表:AmcrestEvent 枚举与匹配

AmcrestEvent 枚举 定义了插件关心的全部事件码,匹配方式是子串包含(data.indexOf(event) !== -1)。对照 AD410 抓包可以精确看出哪些事件被消费、哪些被静默忽略:

export enum AmcrestEvent { MotionStart = "Code=VideoMotion;action=Start", MotionStop = "Code=VideoMotion;action=Stop", MotionInfo = "Code=VideoMotionInfo;action=State", AudioStart = "Code=AudioMutation;action=Start", AudioStop = "Code=AudioMutation;action=Stop", TalkInvite = "Code=_DoTalkAction_;action=Invite", TalkHangup = "Code=_DoTalkAction_;action=Hangup", TalkPulse = "Code=_DoTalkAction_;action=Pulse", AlarmIPCStart = "Code=AlarmIPC;action=Start", AlarmIPCStop = "Code=AlarmIPC;action=Stop", PhoneCallDetectStart = "Code=PhoneCallDetect;action=Start", PhoneCallDetectStop = "Code=PhoneCallDetect;action=Stop", DahuaTalkInvite = "Code=CallNoAnswered;action=Start", DahuaTalkHangup = "Code=PassiveHungup;action=Start", DahuaCallDeny = "Code=HungupPhone;action=Pulse", DahuaTalkPulse = "Code=_CallNoAnswer_;action=Pulse", FaceDetection = "Code=FaceDetection;action=Start", SmartMotionHuman = "Code=SmartMotionHuman;action=Start", SmartMotionVehicle = "Code=Vehicle;action=Start", CrossLineDetection = "Code=CrossLineDetection;action=Start", CrossRegionDetection = "Code=CrossRegionDetection;action=Start", }

以 AD410 抓包为参照:

  • 被消费的:PhoneCallDetect;action=Start/Stop、_DoTalkAction_;action=Pulse(Invite/Hangup 靠 payload 区分)、CallNoAnswered;action=Start、VideoMotionInfo;action=State、VideoMotion;action=Stop;
  • 被忽略的:_CallRemoveMask、AlarmLocal、CallNoAnswered;action=Stop——它们不在枚举中,解析循环对它们不做任何状态变更。这意味着事件协议中相当一部分"内部簿记"事件对上层是透明的。

每个事件 emit 时还会携带从 body 中解析出的index(通道索引)与data(JSON 负载):解析器把 body 按;切分,提取index=与data=两个片段,data会被JSON.parse成对象(amcrest-api.ts#L297-L319)。

此外,智能检测事件(SmartMotionHuman、Vehicle、FaceDetection、CrossLine/CrossRegionDetection)会被进一步映射为person/car/face类别并额外 emitsmart事件,供ObjectDetector接口输出;仓库中 plugins/amcrest/dumps/amcrest-face-detected.json 与 plugins/amcrest/dumps/amcrest-human-detected.json 保留了这类事件的真实 JSON 样本,包含BoundingBox、ObjectType、Confidence等字段。

事件流如何变成 Scrypted 的传感器状态

门铃按键 → BinarySensor

AmcrestCamera.listenEvents() 订阅事件流后,把事件码翻译为设备状态。处理 AD410 门铃按键的关键分支(main.ts#L260-L286):

else if (event === AmcrestEvent.TalkInvite || event === AmcrestEvent.PhoneCallDetectStart || event === AmcrestEvent.AlarmIPCStart || event === AmcrestEvent.DahuaTalkInvite) { ... this.binaryState = true; } else if (event === AmcrestEvent.TalkHangup || event === AmcrestEvent.PhoneCallDetectStop || ... ) { this.binaryState = false; } else if (event === AmcrestEvent.TalkPulse && doorbellType === AMCREST_DOORBELL_TYPE) { if (payload.includes('Invite')) { this.binaryState = true; } else if (payload.includes('Hangup')) { this.binaryState = false; } }

把 AD410 抓包代入这段逻辑,binaryState的翻转轨迹是:

  1. _DoTalkAction_Invite 脉冲 →binaryState = true(访客按下按钮,门铃"响");
  2. CallNoAnswered;action=Start、PhoneCallDetect;action=Start→ 再次置true(冗余确认);
  3. _DoTalkAction_Hangup 脉冲(HangupReason: "HangupByPhone")→binaryState = false;
  4. PhoneCallDetect;action=Stop→ 置false。

值得注意的是,TalkPulse分支对payload(完整 body 字符串)做includes('Invite')/includes('Hangup')判断——这正是为什么 Invite/Hangup 两个脉冲必须携带 JSONdata字段;如果某固件的脉冲不带data,该分支不会翻转状态,只能依赖PhoneCallDetect的 Start/Stop 兜底。

而事件负载中的index会先经过通道过滤(main.ts#L231-L236):index + 1必须等于设备配置的Channel Number Override(rtspChannel设置项),多通道 NVR 场景下避免串通道。

接口暴露上,updateDevice() 在设备Doorbell Type设为Amcrest Doorbell时把设备类型置为Doorbell,并追加BinarySensor与Intercom接口——这解释了为什么 README 中门铃配置的第一步是"SelectTypeisDoorbell":只有如此,门铃事件才会被映射到二进制传感器而非仅日志。

移动侦测:20 秒超时而非 Stop 事件

AD410 抓包中的VideoMotionInfo;action=State(事件 6)与VideoMotion;action=Stop(事件 10)的处理体现了插件的防抖设计(main.ts#L211-L253):

  • MotionStart/SmartMotionHuman/SmartMotionVehicle/CrossLine/RegionDetection→ 立即motionDetected = true并重置一个20 秒的motionTimeout;
  • MotionInfo(即VideoMotionInfo;action=State脉冲)→ 若尚未检测到则补置true,同样重置 20 秒计时;
  • MotionStop→有意不立即清除状态,注释写明"use resetMotionTimeout",即依赖 20 秒无新事件后自动回落为false。

这种"事件置位 + 超时回落"的模式避免了固件偶发漏推Start/Stop配对事件时传感器卡死的问题——这也与抓包中VideoMotion;action=Stop携带"RegionName": ["Region1"](对应 Amcrest 配置界面第一号运动侦测区域)相互印证:即使 Stop 到达,状态清理仍统一由超时完成。

监听器保活:listenLoop 的重连与空闲超时

事件长连接的健壮性由基类 RtspSmartCamera.listenLoop() 保证:

  • 监听器创建失败、连接close或error时自动销毁并延时重建(rtsp.ts#L183-L191);
  • 连接后启动 5 分钟空闲看门狗,期间没有任何data事件(即设备长时间无活动)则销毁监听并重建,防止半死连接;
  • 每个事件到达都会刷新空闲计时,因此在门铃频繁触发的场景下连接会持续保活。

listenEvents内部还会stream.socket.setKeepAlive(true)维持 TCP 保活(amcrest-api.ts#L250)。调试时可在设备设置中开启debug,每个原始事件帧会打印到控制台(rtsp.ts#L211-L215)——ad410.md 与 ad110.md 这两份文档本身就是这类调试输出的归档样本。

实战配置与 AD410 已知怪癖

门铃设备的基本配置

按 Amcrest 插件 README 的门铃配置要求(plugins/amcrest/README.md):

  • Type选择Doorbell(设备名称下方);
  • Username为admin(设备初始化时设置的本地管理员账号,不是 Amcrest Smart Home 云账号);
  • Doorbell Type选择Amcrest Doorbell(对应源码中的 AMCREST_DOORBELL_TYPE,可选值还有Dahua Doorbell);
  • 若双向对讲开始时门铃重启/崩溃,用 Amcrest Smart Home 应用在 Device Information 中关闭Record Audio选项(代价是 SD 卡不再录音)。

双向对讲的音频通路

当事件把binaryState置真后,可通过Intercom接口发起对讲。startIntercom() 对 Amcrest 门铃(非 Dahua)的路径是:把媒体转成 FFmpeg 输入,用 FFmpeg 重编码为AAC(adts 封装)后,以 1024 字节分块 POST 到:

http://<ip>/cgi-bin/audio.cgi?action=postAudio&httptype=singlepart&channel=<通道号>

Content-Type为Audio/AAC;Dahua 门铃则走pcm_alaw/Audio/G.711A编码。另外 main.ts#L757 的注释提示:ONVIF 双向音频在大多数机型上表现更好,但AD110 例外(添加设备时插件会自动探测devAudioOutput.cgi并默认选择ONVIF,见 checkTwoWayAudio())。

AD410 特有的 caps 怪癖

configureCodecs() 中有一段针对 AD410 的特殊处理,源码注释明确写道:"ad410 doesnt return a camera number if accessed directly"——当直连 AD410(不经过 NVR)查询编码能力时,encode.cgi?action=getConfigCaps返回的键不带通道编号前缀。解析器因此在caps[0].MainFormat[0]找不到值时回退到caps.MainFormat[0]。如果你在为 AD410 配置码率/分辨率时遇到能力查询异常,这是一个已知的设备侧差异而非配置错误。

设备信息获取方面,插件通过 probe.ts 的magicBox.cgi?action=getSystemInfo解析deviceType/serialNumber等设备参数来校验凭据与型号;main.ts 的 updateDeviceInfo() 还会逐一查询getVendor/getSerialNo/getDeviceType/getSoftwareVersion,失败仅记录错误而不阻塞设备使用。

小结

  • AD410 的一次门铃呼叫会产生 12 个 multipart 事件帧,核心信号是_DoTalkAction_的 Invite/Hangup 脉冲与PhoneCallDetect的 Start/Stop,其余(_CallRemoveMask、AlarmLocal、CallNoAnswered;action=Stop)为插件忽略的内部簿记事件;
  • 事件流由eventManager.cgi?action=attach&codes=[All]长连接推送,分帧为"边界 + Content-Type/Content-Length 头 + 定长 body",data字段携带跨行 JSON;
  • 插件通过 AmcrestEvent 枚举 做子串匹配,把事件翻译成BinarySensor(门铃)与MotionSensor(20 秒超时回落)状态,并由 listenLoop 提供重连与空闲看门狗保活;
  • 配置门铃时需将设备Type设为Doorbell、Doorbell Type设为Amcrest Doorbell,并使用设备本地admin凭据;调试原始事件帧可开启设备debug设置,ad410.md 与 ad110.md 是两份可对照的真实抓包样本。
  • 音视频
  • 后端
  • 物联网
  • 计算机视觉

【免费下载链接】scrypted

Scrypted is a high performance video integration and automation platform

项目地址:https://gitcode.com/gh_mirrors/sc/scrypted
点击查看免费下载
上一篇:解锁创意工坊:WorkshopDL跨平台工具全解析
下一篇:LangChain:为什么它是构建AI代理的首选框架?3个关键优势解析

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进