📹 第七章 ONVIF协议原理解析
📄 一句话概括

想知道「有没有人经过」,笨办法是每秒查一次画面,聪明办法是让设备告诉你。 ONVIF 的事件机制就是后者:订阅一次,然后不停拉取——而订阅会过期。

📝 本节和前面几节的关系

上一节拿到了视频流,这一节讲「除了画面,设备还能告诉你什么」。 事件是 07.02 里最容易写错的一节,因为它有两套模式、一个会过期的订阅、 还有两个必须带的参数。

一、两种模式,各有各的代价

模式 怎么做 问题
PullPoint 订阅后主动 PullMessages 拉 要客户端起循环
BaseNotification 设备主动 POST 到你的地址 要公网可达

第一行是绝大多数客户端的做法: 设备不主动推,客户端定时拉——这样客户端不需要公网地址,穿 NAT 也没问题。 代价是「延迟 = 拉取间隔」,间隔 5 秒就最多延迟 5 秒才知道有人经过。 第二行是「真推送」:延迟低, 但要求设备能访问到你的 HTTP 服务——在局域网里可行,在公网上基本不可行(NAT 后面)。 所以「为什么大家都用 PullPoint」不是因为它先进,是因为它不需要公网地址。 这个取舍在工程上很好判断:你的客户端有没有固定公网 IP?没有就用 PullPoint。 有的话推送更省设备资源——因为拉取是「空转也要发请求」,推送是「有事才发」。

二、先问设备支持哪些事件

GetEventProperties 回答两个问题:支不支持 PullPoint、有哪些主题。

项 本例的值 怎么读
PullPointSupport true 不支持就只能走推送
事件主题(部分) tns1:RuleEngine/Motion/Alarm 层级式,从左到右变具体

第二行那个主题是「层级式」的:tns1:RuleEngine → Motion → Alarm, 从左到右越来越具体。 订阅时可以只订阅 tns1:RuleEngine/Motion,这样所有运动相关的子主题都会收到。 这个「只订前缀」的能力很实用:你不必事先知道所有子主题的名字,订一个父级就全收了。 反过来,如果订阅时写错了一个字母,设备不会报错,只是永远收不到事件—— 这种「静默失败」是最难查的一类,所以订阅后一定要拉一次验证。

三、订阅一次拿到三个东西

项 本例的值
拉取地址 http://127.0.0.1:<端口>/onvif/events/subscription/1
CurrentTime 2024-05-01T12:00:00Z
TerminationTime 2024-05-01T12:01:00Z

第一行是地址,PullMessages 要发给它;后两行是这次订阅的起止时间。 第三行是实际项目里最容易忘的:订阅会过期(本例是 60 秒),过期之后再拉会报错。 所以事件循环里必须有「续订」逻辑, 一般是「到期前一半时间续一次」——这和第一章 RTSP 的 Session 保活是同一个套路。 这个坑的表现很典型:程序跑一小时都正常,然后事件突然没了, 日志里也没有任何错误(因为拉取空结果不算错)。 所以续订逻辑不能只看返回值,得看时间——记下 TerminationTime,到点前主动续。

四、拉取的两个参数,和事件体的真相

PullMessages 必须带两个参数,不然会一直阻塞:

<tev:PullMessages>
  <tev:Timeout>PT5S</tev:Timeout>
  <tev:MessageLimit>10</tev:MessageLimit>
</tev:PullMessages>

Timeout 是「最多等多久」, 到时间没事件就返回空的——不设的话有些设备会一直挂着,把线程占死。 MessageLimit 是「一次最多取几条」, 设小一点更灵活,设大一点更省往返——这和分页是同一个取舍。 事件体本身不是结构化数据,是「一堆键值对」:SimpleItem 的 Name 和 Value 都是字符串。 所以解析完还要自己转型——IsMotion=true 这个 true 是字符串,不是布尔。 这就是为什么事件处理代码里到处是字符串比较, 也是为什么「事件里拿到的数值」经常需要二次校验。 要注意这个设计的历史原因:SimpleItem 是「万能的」, 因为事件的主题千变万化——厂家自定义的主题不可能有统一的 schema, 所以规范只能给「键值对」这个最弱的结构。 代价就是类型信息全丢了,全靠字符串约定。

五、动手 Demo:事件,别轮询,去订阅

Demo 走完整的一条链:问能力 → 订阅 → 拉一次 → 看事件体。 然后把事件的 SimpleItem 一个个列出来,看它们都是字符串。

# ① 支持哪些事件
_a, b, _n = S.call(port, "events", "GetEventProperties")
r = ET.fromstring(b)
pp = r.find(".//{%s}PullPointSupport" % S.NS_TEV)
print("PullPoint:", pp.text if pp is not None else "-")

# ② 订阅,拿地址和到期时间
_a, b2, _n = S.call(port, "events", "CreatePullPointSubscription")
r2 = ET.fromstring(b2)
addr = r2.find(".//{%s}Address" % S.NS_WSA5)
term = r2.find(".//{%s}TerminationTime" % S.NS_TEV)
print(addr.text, term.text)

# ③ 拉一次,看事件体
pull = ("<tev:PullMessages><tev:Timeout>PT5S</tev:Timeout>"
        "<tev:MessageLimit>10</tev:MessageLimit></tev:PullMessages>")
_a, b3, _n = S.call(port, "events", "PullMessages", pull)
r3 = ET.fromstring(b3)
msg = r3.find(".//{%s}NotificationMessage" % S.NS_TEV)
print(msg.find("{%s}Topic" % S.NS_TEV).text)
for it in msg.iter("{%s}SimpleItem" % S.NS_TT):
    print(" ", it.get("Name"), "=", it.get("Value"))

实跑输出:

--------------------------------------------------------------------
事件,别轮询,去订阅
--------------------------------------------------------------------
**想知道「有没有人经过」,笨办法是每秒查一次画面,聪明办法是让设备告诉你。**
  **ONVIF 的事件机制就是后者**:订阅一次,然后不停拉取。

**两种模式:**

  模式                  怎么做                            问题
  --------------------------------------------------------------------------------------
  **PullPoint**         **订阅后主动 `PullMessages` 拉**  要客户端起循环
  **BaseNotification**  设备主动 POST 到你的地址          **要公网可达**

  **第一行是绝大多数客户端的做法**:
  **设备不主动推,客户端定时拉**——
  **这样客户端不需要公网地址,穿 NAT 也没问题。**
  **代价是「延迟 = 拉取间隔」**,  **间隔 5 秒就最多延迟 5 秒才知道有人经过。**
  **第二行是「真推送」**:延迟低,
  **但要求设备能访问到你的 HTTP 服务**——  **在局域网里可行,在公网上基本不可行(NAT 后面)。**

**先问设备支持哪些事件(`GetEventProperties`):**

  项                        值
  ----------------------------------------------------------------------
  `PullPointSupport`        true
  事件主题(部分)          `tns1:RuleEngine/Motion/Alarm`

  **第一行 `true` 说明它支持 PullPoint 模式**——
  **这一条要是不支持,第一行的方案就用不了,只能走推送。**
  **第二行那个主题是「层级式」的**:  **`tns1:RuleEngine` → `Motion` → `Alarm`**,
  **从左到右越来越具体。**
  **订阅时可以只订阅 `tns1:RuleEngine/Motion`**,  **这样所有运动相关的子主题都会收到。**

**订阅一次,拿到三个东西:**

  项                    值
  --------------------------------------------------------------------------------
  **拉取地址**          `http://127.0.0.1:51290/onvif/events/subscription/1`
  `CurrentTime`         2024-05-01T12:00:00Z
  `TerminationTime`     2024-05-01T12:01:00Z

  **第一行是地址,`PullMessages` 要发给它;**  **后两行是这次订阅的起止时间。**

  **第三行是实际项目里最容易忘的**:
  **订阅会过期**(本例是 60 秒),  **过期之后再拉会报错。**
  **所以事件循环里必须有「续订」逻辑**,
  **一般是「到期前一半时间续一次」**——
  **这和第一章 RTSP 的 Session 保活是同一个套路**(01.04.04)。

**拉一次,看事件长什么样:**

  部分                  内容
  ----------------------------------------------------------------------
  `Topic`               `tns1:RuleEngine/Motion/Alarm`
  `SimpleItem`          `VideoSource = VideoSource_1`
  `SimpleItem`          `IsMotion = true`

  **事件体不是结构化数据,是「一堆键值对」**:  **`SimpleItem` 的 `Name` 和 `Value` 都是字符串。**
  **所以解析完还要自己转型**——  **`IsMotion=true` 这个 `true` 是字符串,不是布尔。**
  **这就是为什么事件处理代码里到处是字符串比较**,
  **也是为什么「事件里拿到的数值」经常需要二次校验。**

**拉取要带两个参数,不然会一直阻塞:**

  `<tev:PullMessages>`
  `  <tev:Timeout>PT5S</tev:Timeout>`
  `  <tev:MessageLimit>10</tev:MessageLimit>`
  `</tev:PullMessages>`

  **`Timeout` 是「最多等多久」**,
  **到时间没事件就返回空的**——  **不设的话有些设备会一直挂着,把线程占死。**
  **`MessageLimit` 是「一次最多取几条」**,
  **设小一点更灵活,设大一点更省往返**——  **这和分页是同一个取舍。**

SimpleItem 的 Name 和 Value 都是属性,不是子元素—— 所以用 it.get 取,不能 it.text。 这和 07.02.01 讲的「属性 vs 子元素」是同一个坑,只不过这次换了地方。 IsMotion 的值是字符串 true,直接 if it.get(u"Value") 永远为真(非空字符串)。 必须写 == u"true"。 这个错在真实项目里非常常见,因为它「跑起来是对的」——事件确实触发了,只是判断条件失效了, 所以「有没有人都报有」这种 bug 能藏很久。 订阅只做了一次,没有演示续订—— 因为续订至少要等到 TerminationTime 附近,一个 Demo 里等 60 秒不合适。 所以这一节的「续订」只有正文在讲,Demo 里没跑, 这一点必须说清,不能让读者以为代码里已经处理了。

30 秒实验

  1. 把 Timeout 改成 PT0S,看模拟设备是立刻返回还是报错。
  2. 把 MessageLimit 改成 1,再拉一次,看能拿到几条。
  3. 把 IsMotion 的判断改成直接判文本,制造一个「永远为真」的 bug,再看输出。

常见误区

  1. 以为事件是推送。 默认是 PullPoint:客户端定时拉, 推送要走 BaseNotification,且需要客户端公网可达。
  2. 以为订阅一次就永久有效。 订阅会过期,必须有续订逻辑,否则跑一阵就静默失效。
  3. 把 SimpleItem 的值当布尔用。 它是字符串,u"false" 在 Python 里也是真, 必须显式比较。
自测三问(点开对答案) 1. PullPoint 和 BaseNotification 最大的差别是什么? —— **PullPoint 是客户端拉,不需要公网地址**; **BaseNotification 是设备推,要求客户端能被设备访问到。** 2. 订阅返回的三个东西是什么?哪个最容易忘? —— **拉取地址、当前时间、到期时间。** **到期时间最容易忘**——忘了续订就会静默失效。 3. `SimpleItem` 的 `Value` 是什么类型? —— **永远是字符串。** `IsMotion=true` 里的 `true` 不是布尔,**必须显式比较字符串。**

下一节讲 PTZ:为什么「转多少度」这种问法在 ONVIF 里不成立,以及那个「y 负是下」的坑。

相关笔记