JavaScript 和 Chrome 中的 SSDP 发现






4.50/5 (3投票s)
在 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 搜索,将执行以下步骤。
- 创建一个 UDP 端口并将其连接到多播组 239.255.255.250
- 在端口 1900 上发出 M-SEARCH 查询
- 等待来自其他设备端口 1900 的传入响应
- 解析响应
- 一段时间后停止监听
第一项主要由 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 结构定义为 palette
类 div
元素的子元素。该元素是隐藏的,但对于每个响应,我将克隆 ssdpDevice
类 div
元素;更改一些子成员;并将其附加到页面可见的部分。
<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