65.9K
CodeProject 正在变化。 阅读更多。
Home

JavaScript 和 Chrome 中的 SSDP 发现

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.50/5 (3投票s)

2019 年 1 月 7 日

CPOL

5分钟阅读

viewsIcon

19658

downloadIcon

581

在 Chrome 中使用 HTML 和 JavaScript 从内部发现本地设备。

引言

在实现几个项目时,我决定使用 HTML 来实现它们,因为这样可以在我感兴趣的绝大多数设备上运行。我的项目需要发现连接到家庭网络的其他设备。我使用了 SSDP 进行发现。

什么是 SSDP?

SSDP(简单服务发现协议)是 UPnP 的一部分,基于 UDP 协议,用于在网络中查找其他设备和服务。它被许多设备实现,包括网络附加存储设备、智能电视和家庭自动化系统。许多这些设备通过 JSON 调用暴露功能。您可以轻松创建接口来控制这些设备。但是,由于 HTML 和 JavaScript 的标准不包含 UDP 接口,因此执行发现操作并非易事。SSDP 的替代方法包括让用户手动输入目标设备的 IP 地址或扫描网络。后一种选择在某些企业网络上执行时可能会引发安全警报,并且可能非常耗时。对于大多数情况,解决方案是平台相关的。有各种基于 HTML 的解决方案允许您通过 UDP 进行通信。例如,BrightSign HTML5 播放器通过使用 roDatagramSocket 支持 UDP。Chrome 通过 chrome.udp.sockets 提供 UDP 通信。

在 Chrome 中使用 UDP 的要求

网页无法访问此接口(出于合理的原因,因为否则可能被滥用)。虽然 Web 应用无法访问,但 Chrome 扩展可以。Chrome 扩展在其他浏览器中不起作用。但截至本文撰写之时,Chrome 占浏览器市场份额的 67%,并且微软已宣布他们将使用 Chromium 作为其 Edge 浏览器的基础。虽然这种 UDP 套接字实现并未在广泛的浏览器中提供,但由于它是大多数桌面用户的首选浏览器,因此它基本上可供广大用户使用。要将 HTML 代码作为扩展运行,还需要另外两个元素:一个清单文件和一个后台脚本。后台脚本将创建一个窗口并将起始 HTML 加载到其中。

chrome.app.runtime.onLaunched.addListener(function() {
    chrome.app.window.create('index.html', {
        'outerBounds': {
        'width': 600,
        'height': 800
        }
    });
});

我不会详细介绍清单文件中的内容,但我会重点介绍其最重要的元素。清单文件采用 JSON 格式。初始脚本在 app.background.scripts 中定义。其他重要元素是 permission 元素,没有它,尝试通过 UDP 通信或加入多播组将失败,还有 manifest_version 元素。其他元素都比较直观。

        {
            "name": "SSDP Browser",
            "version": "0.1",
            "manifest_version": 2,
            "minimum_chrome_version": "27",
            "description": "Discovers SSDP devices on the network",
            "app": {
              "background": {
                "scripts": [
                  "./scripts/background.js"
                ]
              }
            },
          
            "icons": {
                "128": "./images/j2i-128.jpeg",
                "64": "./images/j2i-64.jpeg",
                "32": "./images/j2i-32.jpeg"
            },
          
            "permissions": [
              "http://*/",
              "storage",
              {
                "socket": ["udp-send-to", "udp-bind", "udp-multicast-membership"]
              }
            ]
          }    

执行 UDP SSDP 的步骤

Google 已经提供了一个可用的包装器,作为使用网络上的多播的 chrome.udp.sockets 的代码示例。在其未修改的形式中,Google 的代码示例假定文本以 16 位 Unicode 字符编码进行编码。SSDP 使用 8 位 ASCII 编码。我采用了 Google 的类,并对其进行了小改动,以使用 ASCII 而不是 Unicode。要执行 SSDP 搜索,将执行以下步骤。

  1. 创建一个 UDP 端口并将其连接到多播组 239.255.255.250
  2. 在端口 1900 上发出 M-SEARCH 查询
  3. 等待来自其他设备端口 1900 的传入响应
  4. 解析响应
  5. 一段时间后停止监听

第一项主要由 Google Multicast 类处理。我们只需将端口和地址传递给它即可。M-SEARCH 查询是一个 string。至于最后一项,响应何时停止进入并不确定。有些设备即使未被请求,似乎偶尔也会向网络广播自己。理论上,您可能会一直收到响应。在某个时候,我建议停止监听。五到十秒通常就足够了。M-SEARCH 参数存在差异,但以下内容可用于请求所有设备。还有其他查询可用于过滤具有特定功能的设备。以下是我使用的 string;不明显的是,在最后一行文本之后,有两个空行。

M-SEARCH * HTTP/1.1
HOST: 239.255.255.250:1900
MAN: "ssdp:discover"
MX: 3
ST: ssdp:all
USER-AGENT: Joel's SSDP Implementation

当收到响应时,我们分配给 MulticastScoket.onDiagram 的函数将被调用,并传入一个字节数组,其中包含响应、响应来自的 IP 地址以及响应发送的端口号(对于我们当前的应用,将是 1900)。在下面的代码示例中,我启动搜索并将响应打印到 JavaScript 控制台。

const SSDP_ADDRESS = '239.255.255.250';
const SSDP_PORT = 1900;
const SSDP_REQUEST_PAYLOAD =    "M-SEARCH * HTTP/1.1\r\n"+
                                "HOST: 239.255.255.250:1900\r\n"+
                                "MAN: \"ssdp:discover\"\r\n"+
                                "MX: 3\r\n"+
                                "ST: ssdp:all\r\n"+
                                "USER-AGENT: Joel's SSDP Implementation\r\n\r\n";

var searchSocket = null;

function beginSSDPDiscovery() { 
    if (searchSocket)
        return;
    $('.responseList').empty();
    searchSocket = new MulticastSocket({address:SSDP_ADDRESS, port:SSDP_PORT});
    searchSocket.onDiagram = function(arrayBuffer, remote_address, remote_port) {
        console.log('response from ', remote_address, " ", remote_port);
        var msg = searchSocket.arrayBufferToString8(arrayBuffer);
        console.log(msg);        
    }
    searchSocket.connect({call:function(c) {
        console.log('connect result',c);
        searchSocket.sendDiagram(SSDP_REQUEST_PAYLOAD,{call:()=>{console.log('success')}});
        setTimeout(endSSDPDiscovery, 5000);
    }});    
}

并非解析响应 string 很难,而是如果响应是 JSON 对象会更方便。我创建了一个函数,可以对响应进行快速转换,这样我就可以像处理其他 JSON 对象一样处理它。

function discoveryStringToDiscoveryDictionary(str) {
    var lines = str.split('\r');
    var retVal = {}
    lines.forEach((l) => {
        var del = l.indexOf(':');
        if(del>1) {
            var key = l.substring(0,del).trim().toLowerCase();
            var value = l.substring(del+1).trim();
            retVal[key]=value;
        }
    });
    return retVal;
}   

经过这种转换后,我网络上的 Roku 流媒体播放器返回了以下响应。(我已经更改了序列号。)

{
    cache-control: "max-age=3600",
    device-group.roku.com: "D1E000C778BFF26AD000",
    ext: "",
    location: "http://192.168.1.163:8060/",
    server: "Roku UPnP/1.0 Roku/9.0.0",
    st: "roku:ecp",
    usn: "uuid:roku:ecp:1XX000000000",
    wakeup: "MAC=08:05:81:17:9d:6d;Timeout=10"    ,
}

示例代码已共享得足够用于使用,但为了不依赖开发 JavaScript 控制台,我将更改示例以在 UI 中显示响应。为了简单起见,我已将用于每个结果的 HTML 结构定义为 palettediv 元素的子元素。该元素是隐藏的,但对于每个响应,我将克隆 ssdpDevicediv 元素;更改一些子成员;并将其附加到页面可见的部分。

    <html>
    <head>
        <link rel="stylesheet" href="styles/style.css" />
        <script src="./scripts/jquery-3.3.1.min.js"></script>
        <script src="./scripts/MulticastSocket.js"></script>
        <script src="./scripts/app.js"></script>
    </head>
    <body>
        <div class="visualRoot">
            <div>
                <button id="scanNetworkButton">Scan Network</button>
            </div>
            <div class="responseList">

            </div>
        </div>
        <div class="palette">

            <div class="ssdpDevice">
                <div>address: <span class="ipAddress"></span></div>
                <div >location: <span class="location"></span></div>
                <div >server: <span class="server"></span></div>
                <div> search target:<span class="searchTarget"></span></div>
            </div>

        </div>
    </body>
</html> 

修改后的函数现在将如下所示地显示 SSDP 响应的 HTML:

        function beginSSDPDiscovery() { 
            if (searchSocket)
                return;
            $('.responseList').empty();
            searchSocket = new MulticastSocket({address:SSDP_ADDRESS, port:SSDP_PORT});
            searchSocket.onDiagram = function(arrayBuffer, remote_address, remote_port) {
                console.log('response from ', remote_address, " ", remote_port);
                var msg = searchSocket.arrayBufferToString8(arrayBuffer);
                console.log(msg);
                discoveryData = discoveryStringToDiscoveryDictionary(msg);
                console.log(discoveryData);
        
                var template = $('.palette').find('.ssdpDevice').clone();
                $(template).find('.ipAddress').text(remote_address);
                $(template).find('.location').text(discoveryData.location);
                $(template).find('.server').text(discoveryData.server);
                $(template).find('.searchTarget').text(discoveryData.st)
                $('.responseList').append(template);
            }
            searchSocket.connect({call:function(c) {
                console.log('connect result',c);
                searchSocket.sendDiagram(SSDP_REQUEST_PAYLOAD,{call:()=>{console.log('success')}});
                setTimeout(endSSDPDiscovery, 5000);
            }});    
        } 

已发现设备!接下来怎么办?

接下来的步骤取决于您的意图和设备的功能。不同的设备实现不同的 API 来访问其功能。您可以在 UPnP 规范下找到有关通用 API 的更多信息,尽管许多通过此方式可被发现的设备可能通过其自己的 API 提供功能。

历史

  • 2018 年 12 月 28 日 - 首次发布于 j2inet.blog
  • 2019 年 1 月 7 日 - 发布于 CodeProject.com
© . All rights reserved.