☰
UPnP设备发现与调用排查:用Coherence和UPnP-Inspector定位服务隐身问题
2026/10/6 10:02:22 网站建设 项目流程

简介:UPnP-Inspector 是一份基于 Coherence DLNA/UPnP 框架的 UPnP 设备与服务分析器源码包,面向从事智能家居、DLNA 媒体共享与网络协议调试的 Python 开发者,也适合想学习 UPnP 协议细节的进阶读者。它可帮助分析局域网中的 UPnP 设备、服务、操作与状态变量,调用任意服务操作,提取设备与服务描述 XML,并追踪事件,同时还能作为简易 DLNA 控制点浏览媒体服务器内容、控制媒体渲染器播放音乐。资源共 58 个文件,以 15 个 py 源码模块和 31 个 png 图标资源为主,另含 txt 说明、desktop 启动项、nsi 安装脚本及 licence 等,压缩包约 152KB,结构紧凑。目前已有 373 人学习下载。通过阅读 devices、events、mediaserver、mediarenderer 等模块,读者可掌握 UPnP 设备发现、事件订阅与 DLNA 控制流程,并借助 extract、log 等工具完成协议抓取与排错,是调试与学习 UPnP 的实用参考。

1. UPnP-Inspector 到底在扫什么:从一个「设备看得见却连不上」的现场说起

局域网里投屏搜不到电视、NAS 媒体库在手机端时有时无、智能音箱突然从 App 列表里消失——这类问题的共同点是设备明明在线,服务却像隐身。UPnP-Inspector 就是冲着这个场景来的:它基于 Coherence DLNA/UPnP 框架,把局域网内 UPnP 设备的发现、描述、服务列表和控制端点全部摊开给你看。Coherence 是一套 Python 实现的 DLNA/UPnP 栈,既能当服务端也能当控制点,UPnP-Inspector 复用的正是它的设备发现与服务解析能力。它适合三类人:调投屏和媒体共享的客户端开发者、排查智能家居互联互通的后端工程师、以及需要确认 devicespec.tmpl 这类设备描述模板渲染结果是否正确的集成人员。读完你能自己跑起发现流程、读懂设备描述文档、定位服务调用失败的具体环节。

2. 把 Coherence 的设备发现链路拆开:SSDP、描述文档与服务表

2.1 SSDP 发现为什么是整条链路的起点

UPnP 的设备发现不靠扫描端口,而是靠 SSDP(Simple Service Discovery Protocol)。控制点往组播地址发 M-SEARCH 请求,设备单播回应自己的 location,也就是设备描述文档的 URL。这一步决定了后面所有分析的上限:如果 M-SEARCH 的 ST 头写错,或者设备只回应特定 ST,你抓到的设备列表就是残缺的。

Coherence 里负责这块的是coherence.upnp下的设备与服务基类,控制点侧通过 SSDP 监听和发送实现发现。常见做法是先用标准 ST 值ssdp:all做一轮全量发现,再针对urn:schemas-upnp-org:device:MediaRenderer:1这类具体类型做定向发现。两者的差别很实际:全量发现拿到的设备多,但部分设备对ssdp:all回应不积极;定向发现精准,但你得先知道目标设备类型。

M-SEARCH 的关键参数有三个:MX控制设备随机延迟回应的最大秒数,设太小会丢包,设 2 到 3 比较稳;MAN固定为"ssdp:discover",写错直接没人理;ST是搜索目标,决定你要发现哪类设备或服务。很多「设备看得见却连不上」的现场,根因就是 ST 写成了设备类型,而目标设备只按服务类型回应。

2.2 设备描述文档里真正要读的字段

SSDP 回应只给你一个 location URL,真正的设备信息在描述文档里。这份 XML 通常由 devicespec.tmpl 这类模板渲染出来,字段结构遵循 UPnP Device Architecture 规范。你要重点读的不是 friendlyName 这种展示字段,而是下面这些决定能不能连上的:

字段作用读错会怎样
deviceType设备类型 URN类型判断错,后续服务匹配全偏
UDN设备唯一标识去重和状态跟踪失效
serviceList服务列表找不到控制端点
serviceType服务类型 URN调用错服务
controlURL控制端点相对路径请求打到错误地址
eventSubURL事件订阅地址状态变更收不到通知
SCPDURL服务描述文档地址不知道服务有哪些动作和参数

controlURL 和 SCPDURL 都是相对路径,必须和 location 的 base URL 拼接后才能用。这是新手最容易翻车的地方:直接拿相对路径发请求,返回 404,然后误判设备不支持。正确做法是解析 location 得到 scheme、host、port,再和相对路径做 urljoin。

2.3 用 Coherence 跑通一次最小发现

下面这段代码用 Coherence 的控制点能力做一次 SSDP 发现,并把设备描述文档里的关键字段打出来。它不依赖任何图形界面,适合在排查现场直接跑。

# minimal_discover.py # 依赖: pip install coherence # 作用: 发起 SSDP 发现, 解析设备描述文档, 打印服务表 import socket import re import urllib.request import xml.etree.ElementTree as ET SSDP_ADDR = "239.255.255.250" SSDP_PORT = 1900 # MX=2 给设备留出随机延迟回应的窗口, 太小容易丢回应 MSEARCH = "\r\n".join([ "M-SEARCH * HTTP/1.1", f"HOST: {SSDP_ADDR}:{SSDP_PORT}", 'MAN: "ssdp:discover"', "MX: 2", "ST: ssdp:all", # 全量发现, 想看特定类型换成对应 URN "", "" ]).encode() def discover(timeout=5): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.settimeout(timeout) sock.sendto(MSEARCH, (SSDP_ADDR, SSDP_PORT)) seen = {} while True: try: data, addr = sock.recvfrom(65507) except socket.timeout: break text = data.decode(errors="ignore") loc = re.search(r"LOCATION:\s*(\S+)", text, re.I) if loc: seen[loc.group(1)] = addr[0] sock.close() return seen def parse_device(location): # 描述文档可能较大, 设超时避免卡死 with urllib.request.urlopen(location, timeout=5) as resp: root = ET.fromstring(resp.read()) ns = {"d": "urn:schemas-upnp-org:device-1-0"} dev = root.find("d:device", ns) info = { "friendlyName": dev.findtext("d:friendlyName", default="", namespaces=ns), "deviceType": dev.findtext("d:deviceType", default="", namespaces=ns), "UDN": dev.findtext("d:UDN", default="", namespaces=ns), "services": [], } for svc in dev.findall("d:serviceList/d:service", ns): info["services"].append({ "serviceType": svc.findtext("d:serviceType", default="", namespaces=ns), "controlURL": svc.findtext("d:controlURL", default="", namespaces=ns), "SCPDURL": svc.findtext("d:SCPDURL", default="", namespaces=ns), }) return info if __name__ == "__main__": for loc, ip in discover().items(): print(f"[{ip}] {loc}") try: dev = parse_device(loc) print(" name:", dev["friendlyName"]) print(" type:", dev["deviceType"]) for s in dev["services"]: print(" svc :", s["serviceType"], "->", s["controlURL"]) except Exception as e: print(" parse failed:", e)

逻辑说明:discover发 M-SEARCH 后循环收包,用正则抓 LOCATION 并按 URL 去重,因为同一设备可能多次回应。parse_device用 ElementTree 解析描述文档,命名空间必须带上,否则find找不到节点。参数上,MX设 2 是平衡发现速度和丢包率的结果,局域网设备多时可以调到 3;timeout是收包总时长,设备多时给 5 秒以上。

2.4 服务描述文档决定你能不能调对动作

拿到 controlURL 还不够,真正调用动作前要读 SCPDURL 指向的服务描述文档。它列出了这个服务支持的所有 action、每个 action 的输入输出参数、以及相关的 state variable。很多人调 SetAVTransportURI 失败,不是参数值错,而是参数名大小写或顺序和 SCPD 里定义的不一致。UPnP 的 SOAP 调用对参数名是敏感的,SCPD 里写InstanceID,你发instanceId就可能被拒。

读 SCPD 时重点看三块:actionList 里的 name 和 argumentList、每个 argument 的 direction(in/out)和 relatedStateVariable、以及 serviceStateTable 里变量的 dataType 和 allowedValueRange。allowedValueRange 经常被忽略,但音量、亮度这类数值型参数超范围时,设备可能静默失败而不是报错。

3. 用 UPnP-Inspector 做设备与服务分析:从发现到调用验证

3.1 把发现结果落成可对比的设备清单

排查现场最怕的是「上次能连这次不能」,所以发现结果要能落盘对比。UPnP-Inspector 这类分析器的价值就在于把每次发现的设备、服务、控制端点结构化输出,方便 diff。我一般会把上一节的解析结果写成 JSON,字段固定,然后两次运行做对比。

# snapshot.py # 作用: 把发现结果写成 JSON 快照, 便于前后对比 import json from minimal_discover import discover, parse_device def snapshot(path="upnp_snapshot.json"): result = [] for loc, ip in discover().items(): try: dev = parse_device(loc) dev["location"] = loc dev["ip"] = ip result.append(dev) except Exception as e: result.append({"location": loc, "ip": ip, "error": str(e)}) # sort_keys 保证字段顺序稳定, 方便 diff with open(path, "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False, indent=2, sort_keys=True) return result if __name__ == "__main__": snap = snapshot() print(f"devices: {len(snap)}")

逻辑说明:sort_keys=True是关键,否则字典顺序变化会让 diff 全是噪声。ensure_ascii=False保证中文设备名可读。参数上,快照文件名固定,方便脚本化对比;如果设备多,可以在 discover 里把 timeout 调大。

对比时重点看三类变化:设备从列表消失(发现层问题)、服务列表变化(描述文档或固件变化)、controlURL 变化(端点迁移)。这三类对应完全不同的排查方向,混在一起看会浪费时间。

3.2 调用一个动作验证服务是否真的可用

发现和解析都通过,不代表服务能调。最直接的验证是调一个无副作用的动作,比如 AVTransport 的 GetTransportInfo 或 RenderingControl 的 GetVolume。下面用 SOAP 直接发请求,不依赖额外库。

# call_action.py # 作用: 对指定 controlURL 调用一个 UPnP action, 验证服务可用性 import urllib.request from urllib.parse import urljoin SOAP_TMPL = """<?xml version="1.0"?> <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"> <s:Body> <u:{action} xmlns:u="{service}"> {args} </u:{action}> </s:Body> </s:Envelope>""" def call_action(base, control_url, service, action, args=None): url = urljoin(base, control_url) # 相对路径必须拼接 args_xml = "".join(f"<{k}>{v}</{k}>" for k, v in (args or {}).items()) body = SOAP_TMPL.format(action=action, service=service, args=args_xml).encode() req = urllib.request.Request(url, data=body, method="POST") req.add_header("Content-Type", 'text/xml; charset="utf-8"') # SOAPACTION 头必须带引号, 且格式固定 req.add_header("SOAPACTION", f'"{service}#{action}"') with urllib.request.urlopen(req, timeout=5) as resp: return resp.read().decode() if __name__ == "__main__": base = "http://192.168.1.50:49152/" ctrl = "/upnp/control/renderingcontrol1" svc = "urn:schemas-upnp-org:service:RenderingControl:1" print(call_action(base, ctrl, svc, "GetVolume", {"InstanceID": 0, "Channel": "Master"}))

逻辑说明:urljoin处理相对路径拼接,这是前面反复强调的点。SOAPACTION头的格式是"serviceType#actionName",外层引号不能省,很多设备对格式严格。参数上,InstanceID对 AVTransport 和 RenderingControl 通常是 0,Channel用Master是通用值。如果返回 500,先看响应体里的 UPnPError,错误码 401 是参数错,501 是动作不支持。

3.3 devicespec.tmpl 渲染结果怎么核对

devicespec.tmpl 是设备描述文档的模板,渲染后的 XML 必须符合 UPnP 规范,否则控制点解析会失败。核对时重点看这几项:XML 声明和编码是否一致、命名空间是否声明完整、URLBase 是否存在且正确、serviceList 里每个 service 的 controlURL 和 SCPDURL 是否可访问。

一个常见问题是模板里 URLBase 写死成http://localhost,设备实际 IP 变了之后控制点拼出来的地址全错。另一个是 serviceType 的版本号写错,比如把:1写成:2,控制点按:1匹配就找不到。核对方法很简单:拿渲染后的 XML 跑一遍上一节的 parse_device,能解析出完整服务表且 controlURL 可访问,基本就没问题。

提示:核对 devicespec.tmpl 时,别只看渲染成功,要实际用控制点解析一遍。模板语法正确不代表语义正确。

4. 排查 UPnP 发现与调用的常见翻车点

4.1 设备列表为空或时有时无

现象:跑发现脚本,有时能拿到设备,有时一个都没有,同一网络同一设备。

原因:SSDP 基于 UDP 组播,本身不保证可靠。设备回应有随机延迟,如果收包 timeout 太短,或者本机防火墙拦了组播回应,就会丢。另外部分设备对ssdp:all回应不积极,只对具体 ST 回应。

解决:把 MX 调到 2 到 3,收包 timeout 给到 5 秒以上;检查本机防火墙是否放行 UDP 1900 入站;对目标设备改用具体 ST 做定向发现。如果还是不稳,多跑几轮取并集。

4.2 描述文档解析报命名空间错误

现象:parse_device抛异常,提示找不到 device 节点,但 XML 肉眼看着正常。

原因:UPnP 描述文档的命名空间是urn:schemas-upnp-org:device-1-0,ElementTree 的find必须带命名空间前缀,否则匹配不到。有些设备还会在根节点用默认命名空间,写法不同但语义一样。

解决:统一用带命名空间的查找,如root.find("d:device", {"d": "urn:schemas-upnp-org:device-1-0"})。如果设备命名空间版本不同,先打印 root.tag 确认实际命名空间再调整。

4.3 动作调用返回 401 或 500

现象:SOAP 请求发出去,设备返回 HTTP 500,响应体里有 UPnPError。

原因:401 通常是参数名、参数值或参数顺序和 SCPD 定义不符;500 可能是动作不支持、InstanceID 不存在、或 SOAPACTION 头格式错。

解决:先读 SCPD 确认 action 的 argumentList,参数名严格照抄;确认 SOAPACTION 头是"serviceType#action"带引号;InstanceID 从 0 开始试。响应体里的 errorCode 和 errorDescription 是最直接的线索,别只看 HTTP 状态码。

4.4 controlURL 拼接后 404

现象:controlURL 从描述文档里读出来是/upnp/control/xxx,直接请求 404。

原因:controlURL 是相对路径,必须和 location 的 base 拼接。直接拿相对路径请求,打到了错误的主机或端口。

解决:用urljoin(location, controlURL)拼接。注意 location 本身可能带路径,urljoin 会正确处理。拼接后打印完整 URL 确认。

4.5 事件订阅收不到通知

现象:调用了 eventSubURL 订阅,但设备状态变化时收不到 NOTIFY。

原因:事件订阅需要先发 SUBSCRIBE 请求,带上 CALLBACK 和 NT、TIMEOUT 头,设备才会往你的回调地址发通知。回调地址必须是设备能访问到的本机地址,用 127.0.0.1 设备访问不到。

解决:CALLBACK 用本机在局域网的实际 IP,端口要监听;SUBSCRIBE 成功后设备返回 SID,续订和退订都要带这个 SID;TIMEOUT 到期前要续订,否则订阅失效。

5. 把分析器用成长期工具:快照对比与自动化巡检

单次发现只能解决当下问题,真正省事的是把 UPnP-Inspector 的分析能力做成定时巡检。我的习惯是每天固定时间跑一次快照,和前一天对比,设备消失或服务变化时告警。这样投屏突然不能用之前,你往往能提前看到设备从列表里掉了。

具体做法是在快照脚本上加一层对比逻辑,重点比对 UDN 维度的设备存在性和 serviceList 维度的服务变化。UDN 是设备唯一标识,比 IP 可靠,设备换 IP 但 UDN 不变时不会被误判为新设备。下面这段对比逻辑可以直接接在 snapshot 后面。

# diff_snapshot.py # 作用: 对比两次快照, 输出设备和服务变化 import json def load(path): with open(path, encoding="utf-8") as f: return {d["UDN"]: d for d in json.load(f) if "UDN" in d} def diff(old_path, new_path): old, new = load(old_path), load(new_path) gone = old.keys() - new.keys() added = new.keys() - old.keys() changed = [] for udn in old.keys() & new.keys(): o_svcs = {s["serviceType"] for s in old[udn]["services"]} n_svcs = {s["serviceType"] for s in new[udn]["services"]} if o_svcs != n_svcs: changed.append((udn, o_svcs - n_svcs, n_svcs - o_svcs)) return gone, added, changed if __name__ == "__main__": gone, added, changed = diff("upnp_snapshot_old.json", "upnp_snapshot.json") print("gone:", gone) print("added:", added) for udn, lost, gained in changed: print(f"{udn} lost={lost} gained={gained}")

逻辑说明:以 UDN 为 key 做集合运算,gone 是消失的设备,added 是新出现的,changed 是服务列表变化的。参数上,快照文件按日期命名,对比时传前后两天的路径。这套逻辑跑通后,接个定时任务就是一套轻量巡检。

进阶一点的做法是把 SCPD 也纳入快照,记录每个服务的 action 列表。固件升级后 action 增删是常见变化,提前发现能避免调用失败。不过 SCPD 文档多,快照体积会涨,建议只对关键服务做。

最后说个我踩过的坑:早期我用 IP 做设备标识,结果 DHCP 重新分配后,同一台设备被当成新设备,告警全是误报。换成 UDN 之后世界清净了。UPnP 这套东西看着简单,细节全在规范和实现的差异里,多留快照、多对比,比临时抓包高效得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询