【物联网控制体系项目实战】—— 整体架构流程与 WS 实现
文章目录
- 前言
- 背景
- 一、读者定位与你能学到什么
- 二、术语表
- 三、核心角色与标识
- 四、架构与时序图(核心要点)
- 4.1 架构图(1/2):控制面(扫码 → 设备连流)
- 4.2 架构图(2/2):数据面(前端直连流服务SDK订阅)
- 4.3 端到端时序
- 五、WebSocket 设计
- 5.1 关键代码示例与参数说明(通用化示例)
- 5.2 参数解释:
- 5.3 常见事件与处理:
- 5.4 为什么是“WS + HTTP”的双通道
- 5.5 WS实际场景案例:
- 5.5.1 实时语言转文字
- 5.5.2 实时任务执行时间计算(准确 + 流畅)
- 5.6 断网情况分析
- 六、错误处理与容灾
- 七、安全与鉴权
- 八、工程化与最佳实践
- 九、非技术叙事:用户视角
- 总结
前言
这是一份面向“产品、研发、测试、运维”的通用 IoT 控制架构说明,旨在帮助非实时音视频/物联网背景的同学也能快速理解“扫码绑定 → 任务控制 → 实时数据展示”的端到端路径。文中所有域名、路径、字段均为通用化示例,落地请以实际环境为准。
背景
-
用户使用手机扫码进入“控制器”页面,服务端将“手机会话与设备会话”绑定到同一房间/通道。
-
前端通过 WebSocket 接收设备侧变更通知,通过 HTTP 发起任务指令(如开启采集/录制/转写)。
-
终端设备连接流媒体/能力服务执行任务,成功后通知服务端。
-
前端刷新任务信息可选择进入会中控制与展示(实时发言人语音转文字)。
物联网(IoT)系统本质上是传统前后端通信模式在终端场景的延伸
一、读者定位与你能学到什么
- 产品/项目:能快速画出“控制面/数据面”的核心链路,理解关键 ID 的作用与安全边界。
- 前端:能复用 WS 设计与时间同步策略,掌握实时转写 UI 的正确数据模型。
- 后端:能清楚网关/任务路由/票据发放在整体中的位置与幂等设计。
- 运维/测试:能据此制定健康检查、重连策略、告警与压测模型。
二、术语表
| 术语 | 含义 |
|---|---|
| token | 扫码后颁发的短期令牌,鉴权前端与 WS。 |
| spaceId | 房间/通道标识,将“手机与设备”聚合在同一广播域。 |
| mac | 设备唯一标识,用于单点指令路由与事件过滤。 |
| clientID | 前端实例标识(每标签页/实例唯一),便于并发会话定位。 |
| taskId | 一次任务(如一次会议、一次转写任务)的唯一标识。 |
| 流服务(Stream Service) | 提供“实时音频→文本”的处理与订阅能力。 |
| 通知网关(Notify/WS) | 面向前端的统一消息路由/广播能力。 |
三、核心角色与标识
| 角色与标识 | 含义 |
|---|---|
| 前端(手机 Web) | 扫码进入控制器页,承载控制、展示、长连接。 |
| 设备(终端) | 执行能力(采集、录制、转写、推/订阅流等)。 |
| 服务端 | 会话绑定、任务路由(业务服务器将前端发起的任务请求准确转发到目标终端设备)、WS 广播、token票据发放。 |
| spaceId(房间) | 用于 WS 频道广播,将“手机与终端设备”绑定到同一房间。 |
| mac(设备唯一标识) | 用于单点通知/精确路由到设备(在同一 spaceId 房间内可能有多台设备,mac 用于“单点路由与过滤”)。 |
| taskId(任务Id) | 一次任务的唯一标识。 |
| clientID(每个标签页面生成唯一ID) | 用户级会话的精准定位器。 |
四、架构与时序图(核心要点)
4.1 架构图(1/2):控制面(扫码 → 设备连流)

4.2 架构图(2/2):数据面(前端直连流服务SDK订阅)

4.3 端到端时序

五、WebSocket 设计
- 连接:
wss://<host>/ws/notify?bizType=iot&platform=web&token=<token>&clientID=<uuid> - 建链后订阅:
{ topic: 'room', tags: ['spaceId:<spaceId>'] } - 心跳:每 ~120-150s 发送
/heartbeat - 重连:最多 N 次退避重连(固定 3 秒间隔——简单退避);重连后续订
- 推送约定:
/app/setting/change:房间配置/任务变更(客户端应“拉列表”同步)/task/create/fail:创建任务失败
发送封装(示例):
class WSClient {constructor(url: string) { /* ... */ }sendRequest(url: string, body?: any) {this.socket.send(JSON.stringify({ id: uuid(), url, body: body || {} }));}
}
5.1 关键代码示例与参数说明(通用化示例)
import { v4 as uuid } from 'uuid';type MessageHandler = (msg: any, raw: MessageEvent) => void;export class NotifyWS {private socket!: WebSocket;private url: string;public onmessage?: MessageHandler;public onopen?: () => void;// 心跳与重连private heartbeatTimer: any = null;private reconnectAttempts = 0;private reconnectLock = false;constructor(params: {host: string;token: string; // 服务端下发的短期票据bizType?: string; // 业务类型(例:iot)platform?: string; // 客户端平台(例:web)clientID?: string; // 客户端唯一标识}) {const { host, token, bizType = 'iot', platform = 'web', clientID = uuid().replace(/-/g, '') } = params;const query = new URLSearchParams({ bizType, platform, token, clientID });this.url = `wss://${host}/ws/notify?${query.toString()}`;this.init();}private init() {this.socket = new WebSocket(this.url);this.socket.onopen = () => {this.onopen?.();this.startHeartbeat();};this.socket.onmessage = (evt: MessageEvent) => {const data = typeof evt.data === 'string' ? JSON.parse(evt.data) : evt.data;this.onmessage?.(data, evt);};this.socket.onerror = () => {this.tryReconnect();};this.socket.onclose = () => {this.stopHeartbeat();};}// 订阅频道(示例:房间广播)subscribeBySpace(spaceId: string) {this.send('/notify/api/v1/subscribe', {channels: [{ topic: 'room', tags: [`spaceId:${spaceId}`] }],});}// 心跳保活(120~150s 之间皆可)private startHeartbeat() {this.heartbeatTimer && clearInterval(this.heartbeatTimer);this.heartbeatTimer = setInterval(() => {if (this.socket?.readyState === WebSocket.OPEN) {this.send('/notify/api/v1/heartbeat');}}, 135 * 1000);}private stopHeartbeat() {if (this.heartbeatTimer) {clearInterval(this.heartbeatTimer);this.heartbeatTimer = null;}}// 退避重连(简单示例)private tryReconnect() {if (this.reconnectLock) return;this.reconnectAttempts += 1;if (this.reconnectAttempts > 3) {// 放弃重连,交由外层兜底return;}this.reconnectLock = true;setTimeout(() => {this.init();this.reconnectLock = false;}, 3000);}// 发送统一封装send(url: string, body?: any) {const msg = { id: uuid(), url, body: body || {} };this.socket?.send(JSON.stringify(msg));}// 主动断开close() {this.send('/notify/api/v1/disconnect');this.stopHeartbeat();this.socket?.close();}
}
5.2 参数解释:
bizType:业务线标识,网关可按此做路由/隔离。platform:客户端平台标识,便于统计与策略(如心跳频率)。token:服务端下发的短期令牌,用于 WS 鉴权。clientID:客户端唯一标识,用于定位端与并发会话控制。topic:订阅主题(如 room 表示房间维度消息)。tags:订阅标签(如spaceId:<id>将手机与设备拉入同一房间)。
5.3 常见事件与处理:
/app/setting/change:收到后调用GET /controller/task/list?mac=...拉取权威任务态;/task/create/fail:展示错误并重置 UI;- 其他业务事件可扩展:如配置推送、设备在线状态、能力告警等。
5.4 为什么是“WS + HTTP”的双通道
- WS:用于“事件驱动”,例如任务状态更新、配置变更、失败通知;延迟低、无需轮询。
- HTTP:用于“权威读取/指令下发”,例如任务创建/结束、刷新任务列表;具备幂等、鉴权、审计优势。
- 组合优势:WS 触发 + HTTP 拉取权威数据,既实时又可控,便于审计与回放。
HTTP API(通用契约草案)
-
h5扫码授权与会话绑定
- GET
/controller/space/auth?projectionCode=... - Resp:
{ token: string, spaceId: string, mac: string }
- GET
-
任务控制
- POST
/controller/task/create→ Body:{ taskType, config, organizerId, mac } - POST
/controller/task/end→ Body:{ taskId, mac } - GET
/controller/task/list?mac=...
- POST
-
流媒体/能力接入
- GET
/stream/address→{ address, forwardAddress } - GET
/stream/token?taskId=...→{ token, streamInfo, isOrganizer, isLogin }
- GET
说明:实际路径与字段以后端为准;建议所有接口支持 Header
token(token 来源二维码扫码获取)认证。
5.5 WS实际场景案例:
5.5.1 实时语言转文字
-
接收信息:系统持续接收ASR数据流
-
判断句子状态:每条数据包含 sentenceEnd 和 finalResult 字段
-
两种处理模式:
- 句子未结束:按 sid 重复替换更新(同一行实时更新)
- 句子已结束:锁定显示,开始新的一行
关键技术点:
-
sid作为唯一标识:相同sid = 同一句话的不同版本
-
去重替换机制:_asrDataMap[_asr.sid] = _asr 实现覆盖
-
状态区分:finalResult 控制UI样式(实时vs最终)
-
句子边界:sentenceEnd 决定是否开始新行
5.5.2 实时任务执行时间计算(准确 + 流畅)
混合算法:
// 服务端周期性推送服务端的权威累计时长 time(ms)
statusTime = timeFromServer;
lastSyncTs = Date.now(); // 本地每秒显示:
liveTime = statusTime + (Date.now() - lastSyncTs); // 服务端推送时长 + 流逝时间(当前时间点 - 推送时长的时间点)
策略:
- running:启 1s 定时器刷新;
- paused:停止定时器、保持静态;
- 收到新状态时重置
statusTime/lastSyncTs,自动校准误差。
优势:以服务器为准保证准确性;本地叠加保证显示流畅;断线后收到新状态即可校准。
5.6 断网情况分析
| 特征 | 服务端主动断开 | 网络彻底断开 |
|---|---|---|
| 触发速度 | 即时(毫秒级) | 延迟(依赖心跳超时) |
| 错误信息 | 含关闭码(如1000,1006) | 无任何错误回调 |
| 重连策略 | 立即重连 | 指数退避(2s,4s,8s…) |
| UI提示 | “服务端升级中,请稍候” | “网络不稳定,正在尝试重连…” |
| 检测方式 | onclose事件 | 心跳超时 |
六、错误处理与容灾
- WS:断线自动重连 + 续订;心跳保活。
- 创建失败:收到
/task/create/fail时,提示并重置 UI。 - 录制/能力异常:状态进入
reiniting时提示重连,禁用操作按钮,恢复后解禁。 - Token 失效:本地
checkToken()拦截,展示错误页,停止后续动作。
七、安全与鉴权
- 扫码后的
token作为短期票据,仅用在控制器/WS/任务接口。 - node 层可提供“白名单接口跳过 Cookie→Token”的能力(如
resistTokenApi),若请求头带token则直通;若无则尝试由 Cookie 衍生,兼容 Web/第三方系统。 - 任务控制(暂停/恢复/结束)应在服务端校验角色与权限。
八、工程化与最佳实践
- WS 只做“事件触发”,权威数据统一通过 HTTP 拉取(读写分离、幂等)。
- 子组件对外只暴露必要事件(如
timeUpdate、handleAction),避免父层耦合流媒体能力细节。 - 所有单点消息需配合 clientID 校验,避免串扰。
- 严格管理定时器与 WS 生命周期(mount/unmount 清理)。
九、非技术叙事:用户视角
- 打开控制器,扫码绑定当前会议室设备(这是“把手机与设备放进同一房间(spaceId)”)。
- 点击“开启任务”,后台把你的请求准确转发到当前设备(靠设备 mac 找到它)。
- 设备开始工作,后端通过“通知网关”告诉你:成功了,来看看任务信息吧。
- 页面刷新任务详情,拿到 taskId,进入会中页;此时你能看到“谁在说话、说了什么、说了多久”。
- 期间若网络抖动,系统会自动重连,恢复后继续同步。
- 会议结束时,你点击停止,后台通知设备收尾,随后你能查看回放。
总结
-
总体流程
-
扫码后:
GET /controller/space/auth→{ token, spaceId, mac } -
WS:连接后订阅
topic=room, tags=[spaceId:...] -
开启任务:
POST /controller/task/create { mac, config, organizerId } -
接受任务推送:
/app/setting/change→ 前端GET /controller/task/list -
会中:获取流票据与地址 → 订阅实时转写与状态 ,混合算法显示时长
-
结束任务:
POST /controller/task/end { taskId, mac }
