UPnP协议中的订阅机制分析

2 阅读7分钟

在UPnP中,事件基于GENA(Generic Event Notification Architecture)实现状态变化通知。UPnP的订阅机制是其事件通知功能的核心,允许控制点订阅设备的状态变化事件,从而在设备状态改变时实时接收通知。

1, 定义相关的订阅操作函数

object UpnpGenaClient {
    // 定义 GENA 相关常量
    private const val SUBSCRIBE_METHOD = "SUBSCRIBE"
    private const val UN_SUBSCRIBE_METHOD = "UNSUBSCRIBE"
    private const val TIMEOUT_HEADER = "TIMEOUT"
    private const val CALLBACK_HEADER = "CALLBACK"
    private const val NT_HEADER = "NT" // NT:通知类型
    private const val NTS_HEADER = "NTS" // NTS:通知子类型(如upnp:propchange)
    private const val SID_HEADER = "SID"
    private const val NT_EVENT = "upnp:event"
    private const val NTS_ALIVE = "upnp:propchange"

    // 订阅事件
    // 函数的主要功能是向指定的服务控制 URL 发送订阅请求,设置请求头包含回调 URL、超时时间和通知类型等信息,根据服务器的响应状态码判断订阅是否成功,如果成功则返回会话 ID,失败则返回 null。
    // 对于第一个参数serviceControlUrl,
    // 是通过 SSDP 协议发现设备,获取设备描述文件,再从文件中提取服务控制 URL 路径并与设备基本 URL 组合得到的。
    // 对于第二个参数,当订阅的服务有事件发生时,会向该 URL 发送通知。
    // 如果服务器是部署在本地的话,可以如下定义 val callbackUrl = "http://localhost:8080/callback"
    // 对于第三个参数,val timeout = 3600
    fun subscribe(serviceControlUrl: String, callbackUrl: String, timeout: Int): String? {
        try {
            val url = URL(serviceControlUrl)
            val connection = url.openConnection() as HttpURLConnection
            // 设置 HTTP 请求的方法为 SUBSCRIBE_METHOD。
            // SUBSCRIBE_METHOD 应该是一个常量,代表了用于订阅服务的 HTTP 请求方法,通常在 UPnP(通用即插即用)的事件订阅场景中使用 SUBSCRIBE 作为请求方法。
            connection.requestMethod = SUBSCRIBE_METHOD
            // 设置 HTTP 请求头中的 CALLBACK_HEADER 字段。CALLBACK_HEADER 应该是一个常量,代表回调 URL 对应的请求头字段名。
            // 将 callbackUrl 用尖括号 <> 包裹后作为该请求头的值,这样服务端在处理订阅请求时就知道当有事件发生时应该向哪个 URL 发送通知。
            connection.setRequestProperty(CALLBACK_HEADER, "<$callbackUrl>")
            // 设置 HTTP 请求头中的 TIMEOUT_HEADER 字段。TIMEOUT_HEADER 应该是一个常量,代表超时时间对应的请求头字段名。
            // 将超时时间 timeout 与字符串 "Second-" 拼接后作为该请求头的值,例如如果 timeout 为 3600,则请求头的值为 "Second-3600",表示订阅的超时时间为 3600 秒。
            connection.setRequestProperty(TIMEOUT_HEADER, "Second-$timeout")
            // 设置 HTTP 请求头中的 NT_HEADER 字段。
            // NT_HEADER 是一个常量"NT",代表通知类型对应的请求头字段名。
            // NT_EVENT是一个常量"upnp:event",代表要订阅的通知类型,通常在 UPnP 中使用 upnp:event 表示订阅事件通知。
            connection.setRequestProperty(NT_HEADER, NT_EVENT)

            val responseCode = connection.responseCode
            if (responseCode == HttpURLConnection.HTTP_OK) {
                // 如果订阅请求成功,调用 HttpURLConnection 对象的 getHeaderField 方法,获取响应头中 SID_HEADER 字段的值。
                // SID_HEADER是一个常量"SID",代表会话 ID(SID)对应的响应头字段名。会话 ID 用于唯一标识这次订阅,后续可能会用于取消订阅或其他与订阅相关的操作。将该会话 ID 作为函数的返回值返回。
                return connection.getHeaderField(SID_HEADER)
            }
        } catch (e: Exception) {
            e.printStackTrace()
        }
        return null
    }

    // 取消订阅事件
    // serviceControlUrl:这是一个字符串类型的参数,表示服务控制的 URL 地址,取消订阅的请求将发送到该地址。
    // subscriptionId:同样是字符串类型的参数,代表之前订阅操作所获得的唯一订阅 ID,用于标识要取消的具体订阅。
    fun unsubscribe(serviceControlUrl: String, subscriptionId: String): Boolean {
        try {
            // 根据传入的 serviceControlUrl 创建一个 URL 对象,该对象用于后续建立网络连接。
            val url = URL(serviceControlUrl)
            // 调用 URL 对象的 openConnection() 方法打开一个网络连接,返回的是 URLConnection 类型的对象,将其强制转换为 HttpURLConnection 类型,以便使用 HTTP 协议相关的功能。
            val connection = url.openConnection() as HttpURLConnection
            // 将 HTTP 请求的方法设置为 UN_SUBSCRIBE_METHOD,是一个常量"UNSUBSCRIBE",代表取消订阅的请求方法,通常在 UPnP 协议中为 UNSUBSCRIBE
            connection.requestMethod = UN_SUBSCRIBE_METHOD
            // 通过 setRequestProperty 方法设置请求头,SID_HEADER 是一个常量"SID",代表会话 ID(SID)对应的请求头字段名,将 subscriptionId 作为该请求头的值,用于告知服务器要取消的具体订阅。
            connection.setRequestProperty(SID_HEADER, subscriptionId)
            // 调用 connection.responseCode 触发实际的 HTTP 请求,并获取服务器的响应状态码
            // HttpURLConnection 为了优化性能和资源使用,将请求的发送操作延迟到了需要获取服务器响应的时候。
            // connection.responseCode 是 HttpURLConnection 类的一个属性,用于获取服务器的响应状态码。
            // 由于只有在服务器处理完请求并返回响应后,才能获取到响应状态码,
            // 所以当访问这个属性时,HttpURLConnection 会自动触发之前配置好的 HTTP 请求,并等待服务器的响应。
            // HttpURLConnection 采用了一种延迟执行请求的设计思路。当你创建 HttpURLConnection 对象并设置请求方法、请求头和请求体等参数时,这些操作仅仅是在配置请求,并没有真正向服务器发送请求。
            val responseCode = connection.responseCode
            return responseCode == HttpURLConnection.HTTP_OK
        } catch (e: Exception) {
            e.printStackTrace()
        }
        return false
    }

    // 模拟接收事件通知,requestData:接收到的事件通知数据
    fun receiveEventNotification(requestData: String) {
        // 检查通知数据中是否包含 NTS_ALIVE("upnp:propchange") 字符串。
        //若包含,则打印通知信息,并可在其中添加处理逻辑。
        if (requestData.contains(NTS_ALIVE)) {
            println("Received event notification: $requestData")
            // 这里可以添加处理事件通知的逻辑
        }
    }
}

在上面的代码中,subscribe函数中的参数是通过 SSDP 协议发现设备,获取设备描述文件,再从文件中提取服务控制 URL 路径并与设备基本 URL 组合得到的。
其获取过程如下:

1,客户端(如控制设备)要先发现网络中的 UPnP 设备,这通常借助 SSDP(简单服务发现协议)来完成。客户端会发送一个多播或广播消息,在网络中搜索符合特定类型的设备。
M-SEARCH * HTTP/1.1
HOST: 239.255.255.250:1900
MAN: "ssdp:discover"
MX: 3
ST: urn:schemas-upnp-org:device:MediaRenderer:1 
ST是指定要搜索的设备类型,这里是媒体渲染器设备。

2,设备接收到该请求后,若符合指定类型,就会向客户端发送响应。响应中会包含设备描述文件的 URL
HTTP/1.1 200 OK
CACHE-CONTROL: max-age=1800
DATE: Fri, 12 Mar 2025 10:30:00 GMT
EXT:
LOCATION: http://192.168.1.100:1400/xml/device_description.xml
OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01
01-NLS: 1234567890abcdef
SERVER: Linux/5.4.0, UPnP/1.0, Sonos/1.0
ST: urn:schemas-upnp-org:device:MediaRenderer:1
USN: uuid:12345678-1234-1234-1234-1234567890ab::urn:schemas-upnp-org:device:MediaRenderer:1
其中,LOCATION 字段给出了设备描述文件的 URL3,客户端通过 HTTP GET 请求获取设备描述文件(通常是 XML 格式)。以上面的 LOCATION 为例,客户端会向 http://192.168.1.100:1400/xml/device_description.xml 发送请求。设备描述文件会详细描述设备的信息和它所提供的服务。
<root xmlns="urn:schemas-upnp-org:device-1-0">
    <specVersion>
        <major>1</major>
        <minor>0</minor>
    </specVersion>
    <device>
        <deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
        <friendlyName>Sonos Speaker</friendlyName>
        <manufacturer>Sonos</manufacturer>
        <manufacturerURL>http://www.sonos.com</manufacturerURL>
        <modelDescription>Sonos Play:1</modelDescription>
        <modelName>Play:1</modelName>
        <modelNumber>1.0</modelNumber>
        <modelURL>http://www.sonos.com/products/play1</modelURL>
        <UDN>uuid:12345678-1234-1234-1234-1234567890ab</UDN>
        <serviceList>
            <service>
                <serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
                <serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
                <SCPDURL>/xml/AVTransport_scpd.xml</SCPDURL>
                <controlURL>/MediaRenderer/AVTransport/Control</controlURL>
                <eventSubURL>/MediaRenderer/AVTransport/Event</eventSubURL>
            </service>
        </serviceList>
    </device>
</root>
在这个文件里,serviceList 部分会列出设备所提供的服务。每个服务都有对应的 controlURL 字段,它给出了该服务的控制 URL 路径。

4,构建 serviceControlUrl
要得到完整的 serviceControlUrl,需要把设备的基本 URL(来自设备发现响应中的 LOCATION 字段)和设备描述文件里的 controlURL 路径组合起来。以上面的例子来说:
设备基本 URLhttp://192.168.1.100:1400
服务控制 URL 路径:/MediaRenderer/AVTransport/Control
完整的 serviceControlUrl 就是:http://192.168.1.100:1400/MediaRenderer/AVTransport/Control

综上所述,serviceControlUrl 是通过 SSDP 协议发现设备,获取设备描述文件,再从文件中提取服务控制 URL 路径并与设备基本 URL 组合得到的。

订阅的目的是允许控制点注册对设备特定事件的兴趣,以便在事件发生时能够及时收到通知,而无需频繁轮询设备状态。
订阅过程:
控制点向设备的服务控制 URL 发送 SUBSCRIBE 请求,请求中包含回调 URL(用于接收事件通知)、超时时间等信息。如上述代码中,通过设置CALLBACK_HEADER、TIMEOUT_HEADER和NT_HEADER等请求头来完成订阅请求的配置。 设备收到订阅请求后,验证请求并为该订阅分配一个唯一的会话 ID(SID),在响应中通过SID_HEADER返回给控制点。

事件通知:
当设备上发生感兴趣的事件时,设备会向控制点提供的回调 URL 发送包含事件信息的通知消息。通知消息通常采用特定的格式,如上述代码中通过判断requestData是否包含NTS_ALIVE来确定是否为有效的事件通知。 控制点接收到通知后,可以根据事件信息执行相应的操作,如更新界面显示、触发其他业务逻辑等。

取消订阅:
控制点可以通过向设备的服务控制 URL 发送 UN - SUBSCRIBE 请求,携带之前获取的会话 ID,来取消对特定事件的订阅。设备收到请求后,会停止向该控制点发送相关事件通知,并释放与该订阅相关的资源。 通过 UPnP 的订阅机制,控制点能够及时获取设备状态变化信息,实现更高效、实时的设备控制和交互,同时减少了网络带宽占用和设备资源消耗。

2,调用订阅函数

// val callbackUrl = "http://your-server.com/callback"
    // val timeout = 3600
    fun subscribeToSonosDevices(sonosDevices: List<SonosDevice>, callbackUrl: String, timeout: Int) {
        for (device in sonosDevices) {
            // 假设从设备响应中提取服务控制 URL,实际中需要根据具体响应格式解析
            val serviceControlUrl = extractServiceControlUrl(device.response)
            if (serviceControlUrl != null) {
                val subscriptionId = UpnpGenaClient.subscribe(serviceControlUrl, callbackUrl, timeout)
                if (subscriptionId != null) {
                    println("Subscribed to Sonos device at ${device.ip} with ID: $subscriptionId")
                    UpnpGenaClient.unsubscribe(serviceControlUrl, subscriptionId)
                } else {
                    println("Failed to subscribe to Sonos device at ${device.ip}")
                }
            }
        }
    }

    private fun extractServiceControlUrl(response: String): String? {
        // 这里需要根据实际的设备响应格式解析出服务控制 URL
        return "http://192.168.1.100:1400/MediaRenderer/AVTransport/Control"
    }

3, 创建服务器

import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpHandler
import java.io.IOException
import java.io.OutputStream

class EventNotificationHandler : HttpHandler {
    @Throws(IOException::class)
    override fun handle(exchange: HttpExchange) {
        if ("POST" == exchange.requestMethod) {
            val requestBody = exchange.requestBody.bufferedReader().use { it.readText() }
            // 处理事件通知
            handleEventNotification(requestBody)
            val response = "OK".toByteArray()
            exchange.sendResponseHeaders(200, response.size.toLong())
            val os: OutputStream = exchange.responseBody
            os.write(response)
            os.close()
        }
    }

    private fun handleEventNotification(requestData: String) {
        println("Received event notification: $requestData")
        UpnpGenaClient.receiveEventNotification(requestData)
    }
}

fun main() {
    try {
        // 创建一个 HTTP 服务器,监听 8080 端口
        val server = HttpServer.create(InetSocketAddress(8080), 0)
        // 将 EventNotificationHandler 注册到服务器的 "/callback" 路径上
        // 这意味着当有请求发送到 http://localhost:8080/callback(如果服务器部署在本地)时,该请求会由 EventNotificationHandler 来处理。
        server.createContext("/callback", EventNotificationHandler())
        // 启动服务器
        server.start()
        println("Server started on port 8080")
    } catch (e: IOException) {
        e.printStackTrace()
    }
}

4, GENA分析
GENA(Generic Event Notification Architecture)是 UPnP(Universal Plug and Play)协议中的一个重要组成部分,用于实现设备之间的事件通知机制。
工作原理:
订阅 / 通知模型:GENA 采用订阅 / 通知模型。设备可以向其他设备或控制点(Control Point)发送事件通知,而其他设备或控制点可以通过订阅特定的事件来接收这些通知。当被订阅的事件发生时,发布者(Publisher)会将包含事件信息的通知发送给订阅者(Subscriber)。 基于 HTTP 协议:GENA 基于 HTTP 协议实现,利用 HTTP 的 POST 方法来发送事件通知。订阅者通过向发布者的特定 URL 发送订阅请求来建立订阅关系,发布者则在事件发生时向订阅者提供的回调 URL 发送通知。
消息类型:
订阅请求(Subscribe):订阅者向发布者发送的请求,用于请求订阅特定的事件。请求中包含订阅者希望接收通知的回调 URL 以及订阅的超时时间等信息。
订阅响应(Subscribe Response):发布者对订阅请求的响应。如果订阅成功,响应中会包含一个唯一的订阅 ID(Subscription ID),订阅者后续可以使用该 ID 来管理订阅,如取消订阅等。
事件通知(Event Notification):发布者在事件发生时向订阅者发送的消息,包含了事件的相关信息,如事件的名称、属性值的变化等。事件通知以 XML 格式封装,以便于不同设备之间的解析和理解。
取消订阅请求(Unsubscribe):订阅者用于取消之前建立的订阅关系的请求,请求中包含要取消的订阅 ID。
取消订阅响应(Unsubscribe Response):发布者对取消订阅请求的响应,确认订阅是否成功取消。