想知道「有没有人经过」,笨办法是每秒查一次画面,聪明办法是让设备告诉你。 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 秒实验
- 把
Timeout改成PT0S,看模拟设备是立刻返回还是报错。 - 把
MessageLimit改成 1,再拉一次,看能拿到几条。 - 把
IsMotion的判断改成直接判文本,制造一个「永远为真」的 bug,再看输出。
常见误区
- 以为事件是推送。 默认是 PullPoint:客户端定时拉, 推送要走 BaseNotification,且需要客户端公网可达。
- 以为订阅一次就永久有效。 订阅会过期,必须有续订逻辑,否则跑一阵就静默失效。
- 把
SimpleItem的值当布尔用。 它是字符串,u"false"在 Python 里也是真, 必须显式比较。
自测三问(点开对答案)
1. PullPoint 和 BaseNotification 最大的差别是什么? —— **PullPoint 是客户端拉,不需要公网地址**; **BaseNotification 是设备推,要求客户端能被设备访问到。** 2. 订阅返回的三个东西是什么?哪个最容易忘? —— **拉取地址、当前时间、到期时间。** **到期时间最容易忘**——忘了续订就会静默失效。 3. `SimpleItem` 的 `Value` 是什么类型? —— **永远是字符串。** `IsMotion=true` 里的 `true` 不是布尔,**必须显式比较字符串。**下一节讲 PTZ:为什么「转多少度」这种问法在 ONVIF 里不成立,以及那个「y 负是下」的坑。
- ☐ (本节完,下一节 07.02.12 PTZ控制)