做摄像头接入平台这类需求时,ISUP(原EHOME协议)是躲不开的一个坎。我接某个设备接入平台的项目时,手里一批摄像头分布在不同的网络环境里,有些在专网,有些走4G,平台侧根本没法用常规的IP扫描方式找到它们,更别说直接拉RTSP流。后来把思路调过来:让设备主动连接平台,先把信令通道建立起来,再在这个通道上做注册、心跳、取流协商,最终用Spring Boot实现ISUP服务端,配合FFmpeg做RTSP转HTTP-FLV推给Vue前端播放。这条路走通之后,摄像头无论放在哪里,只要它能访问到平台端口,就能上线、能预览、能控制。
这篇文章把当时实现这个“海康威视摄像头ISUP(原EHOME协议)实时预览”项目的完整思路摊开讲,包括协议层面怎么理解、后端Netty怎么搭、注册心跳包怎么处理、RTSP取流怎么和ISUP信令配合、前端flv.js怎么接,以及那些网上文档不会告诉你的坑。适合正在做设备接入平台、准备用Java重写EHOME/ISUP服务的开发者参考,看完至少能少走几天的弯路。
1. 先理解 ISUP(原 EHOME 协议)到底解决什么问题
1.1 为什么要折腾这个“老协议”
海康的设备管理协议从早期的EHOME演进到现在的ISUP,本质解决的问题没有变:让处在复杂网络环境里的设备能够主动找到平台。传统Onvif或者RTSP方式要求平台侧能直接访问设备的IP和端口,可现实中摄像头往往在NAT后面、在4G路由器下面、在企业专网里,平台根本连不到它。ISUP反过来,设备配置好平台的公网IP和端口之后,由设备发起TCP连接,连接建立起来后所有信令都走这条长连接,平台不需要主动去敲门。
EHOME和ISUP的关系,可以理解为同一套协议思路的升级版本。EHOME是老固件上的叫法,ISUP是新一代平台接入协议,端口默认都是7660,加密场景走7661。很多设备端的“平台接入”配置界面里选项就叫“ISUP/EHOME”,说明两者是兼容的。但在Java侧实现服务端的时候,不能默认它们完全一致,不同固件版本对消息字段的处理有细微差别,后面会专门说兼容策略。
1.2 ISUP 平台接入的基本交互流程
设备接入ISUP服务的流程并没有想象中复杂,核心就是三个动作:注册、心跳、业务指令。
设备上电后,根据平台地址配置主动建立TCP连接,然后发送注册报文,报文里包含设备序列号、设备ID、设备名称、软件版本、MAC地址等信息。平台收到注册请求后,要校验这个设备ID是否在系统里注册过,如果合法就返回注册成功响应,并且记住这个连接Channel与设备ID的对应关系。从这一刻起,平台下发控制指令不再需要找设备的IP,直接往这个Channel写XML消息就行。
设备注册成功后会按照配置的心跳周期持续发送心跳包,平台每次收到都要更新这个设备的在线状态,如果一段时间没收到心跳,就要把设备标记为离线。心跳包的作用不只是保活,它还给平台提供了一个判断设备链路健康度的依据。我在实现时把在线状态放到了Redis里,TTL设置成心跳间隔的3倍左右,省得自己维护复杂的过期清理逻辑。
1.3 取流方案:信令归信令,媒体归媒体
很多人第一次接触ISUP时会有一个误解:以为设备接入ISUP平台后,视频流也必须通过ISUP协议传回来。实际上业务上更通用的做法是信令走ISUP,媒体流走RTSP。ISUP负责注册、心跳、设备状态、云台控制这些控制面的事情,真正要预览时,平台根据信令协商出来的媒体信息,直接向后端媒体服务提供RTSP地址,然后由FFmpeg去拉流再转封装。
为什么这么设计?因为RTSP在视频流的传输和播放生态里太成熟了,FFmpeg、VLC、各种播放器都原生支持。如果非要让视频裸流通过ISUP通道转发,协议栈复杂度会明显上升,还得处理RTP打包、丢包重传、时间戳同步这些事,而且不同设备走ISUP媒体通道时的行为差异非常大,容易把自己拖进泥潭。只有一种场景需要考虑ISUP媒体转发,就是设备在极端网络环境里连554端口都无法映射出去,这种情况下才需要研究怎么从ISUP通道里剥离媒体数据。
2. 技术方案设计:从设备到浏览器的整条链路
2.1 是自己写协议栈,还是调官方 SDK
做ISUP接入时首先要做一个选择:自己用Java写协议栈,还是通过JNA调海康官方SDK。这两种路线我都踩过,各自优缺点非常明显。
官方SDK封装程度高,注册、心跳、取流这些接口都给你准备好了,省去了解析XML和抓包分析的精力。但SDK是本地库,服务器部署时要装对应版本的运行环境,而且它更偏向桌面应用场景,放到Spring Boot这种长期运行的Web服务里有内存管理、线程安全方面的隐患。另外SDK的库文件在不同CPU架构上还得单独准备,如果服务器是ARM架构,又是一轮折腾。
自己用Netty实现ISUP服务端,看起来工作量大,实际上可控性更高。因为ISUP的核心信令流程并不复杂,注册包、心跳包、业务请求都是XML格式,Netty做TCP服务端非常顺手,而且服务跑在Linux服务器上不依赖任何本地库,部署就是打一个Jar包的事。我最终选了自研信令服务这条路,把官方文档作为参考,核心逻辑全部由自己掌控。
2.2 端到端的模块划分
整个项目的模块划分按照职责边界拆成了五个部分,每一块都能独立测试和部署,出了问题也容易定位:
| 模块 | 职责 | 关键点 |
|---|---|---|
| isup-server | Netty TCP服务端,监听7660端口,解析/生成ISUP信令 | 用XML解析,空闲检测,Channel管理 |
| device-manager | 设备信息管理,注册校验,在线状态维护 | Redis存在线状态,心跳续期 |
| preview-service | 实时预览任务编排,RTSP取流,FFmpeg转流 | 线程池管理转码进程,超时控制 |
| web-api | 向后端管理系统暴露Rest接口 | 预览启动/停止、云台控制、设备列表 |
| frontend | Vue页面,负责拉取HTTP-FLV流并播放 | flv.js初始化、断流重连 |
模块之间通过接口调用解耦,isup-server收到心跳后只负责更新状态,不需要关心业务侧怎么消费设备数据;preview-service启动FFmpeg之后也只需要把播放地址返回出去,不需要关心前端播放器具体怎么渲染。
2.3 核心数据模型设计
设备接入最核心的一张表是设备信息表,至少要包含设备ID(平台分配给设备的唯一标识)、设备序列号、通道数量、RTSP账号、RTSP密码、所在区域等字段。设备注册时,平台用设备ID查这张表,查不到就拒绝注册。
在线状态这种需要频繁读写的轻量数据放Redis更合适。我用device:online:{deviceId}作为Key,Value存设备当前的Channel信息,TTL设置成心跳间隔的3倍。这样即使设备断线后没来得及发送注销消息,Redis中的状态也会在Key过期后自动消亡,不占多余内存。
预览任务也要管理起来,否则一个设备被多个人同时点开预览,FFmpeg进程会被拉爆。我用一张preview_session表记录每次预览任务,字段包括预览ID、设备ID、通道号、码流类型(主/子码流)、播放地址、启动时间、最后活跃时间。每次新起任务前先查这张表,如果同一个设备同一个通道已经有存活的任务,直接复用已有播放地址。
2.4 设备型号与固件差异的兼容策略
海康的设备线很长,不同固件版本对ISUP协议的支持程度并不一致。我遇到过同一批采购的设备,一部分能正常注册,另一部分注册包里的字段名不同导致解析失败。后来总结出来的经验是:不要把协议解析逻辑写死,对关键字段做兼容处理。
具体做法是注册包解析时忽略未知字段,只提取必要的设备标识信息;XML标签名大小写不敏感解析,避免因为大小写差异导致匹配失败;对可选字段设置默认值,不能因为缺少某个字段就拒绝整个注册包。同时每台设备注册成功后,把它的固件版本和协议版本记录下来,后续如果需要兼容特定版本的设备,可以直接根据这些信息做分支处理。
3. 后端核心实现:Spring Boot 里的 ISUP 服务端
3.1 用 Netty 搭 TCP 服务端
Spring Boot本身不提供TCP服务端能力,所以ISUP服务端用Netty来实现,这是Java领域做长连接服务最顺手的框架。搭建过程很简单,核心就是启动一个ServerBootstrap监听7660端口,为每个连接配置pipeline。
@Component public class IsupServer { private final EventLoopGroup bossGroup = new NioEventLoopGroup(1); private final EventLoopGroup workerGroup = new NioEventLoopGroup(); @PostConstruct public void start() throws InterruptedException { ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(new IdleStateHandler(120, 60, 0)); ch.pipeline().addLast(new IsupMessageDecoder()); ch.pipeline().addLast(new IsupMessageHandler()); } }); bootstrap.bind(7660).sync(); } @PreDestroy public void stop() { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } }这里的IdleStateHandler很有必要。设备可能在没有通知平台的情况下突然断网,TCP连接在操作系统层面还处于半开状态。通过配置读空闲120秒、写空闲60秒,Netty就能在设备失联后主动清理无效连接,释放资源。
3.2 处理注册包与心跳包
ISUP的消息体是XML格式,解码器要做的工作就是按消息边界把一个个完整的XML文档切出来。由于XML的结束标签明确,我用自定义解码器接收数据,累积到出现</Message>标签后,把完整报文交给业务处理器。
public class IsupMessageHandler extends SimpleChannelInboundHandler<IsupMessage> { @Override protected void channelRead0(ChannelHandlerContext ctx, IsupMessage msg) { String cmdType = msg.getCmdType(); String deviceId = msg.getDeviceId(); switch (cmdType) { case "Register": handleRegister(ctx, deviceId, msg); break; case "Keepalive": handleKeepalive(ctx, deviceId); break; case "Alarm": handleAlarm(deviceId, msg); break; default: log.warn("unknown cmdType: {}", cmdType); } } private void handleRegister(ChannelHandlerContext ctx, String deviceId, IsupMessage msg) { DeviceEntity device = deviceMapper.selectByDeviceId(deviceId); if (device == null) { sendResponse(ctx, msg, "InvalidDevice"); return; } // 绑定设备ID与channel DeviceChannelRegistry.bind(deviceId, ctx.channel()); // 写入在线状态 redisTemplate.opsForValue().set("device:online:" + deviceId, ctx.channel().id().asLongText(), Duration.ofMinutes(3)); sendResponse(ctx, msg, "OK"); } private void handleKeepalive(ChannelHandlerContext ctx, String deviceId) { redisTemplate.expire("device:online:" + deviceId, Duration.ofMinutes(3)); sendKeepaliveAck(ctx); } }注册成功的关键是返回响应报文时带上正确的Result字段,同时确保设备ID和Channel完成了双向绑定。我维护了一个全局的ChannelRegistry,用设备ID作为Key,后续下发行云台、预览信令时直接从这个注册表拿Channel,避免遍历所有连接。
3.3 实时预览信令:下发取流请求
当用户在Web页面上点击预览按钮后,后端不是直接拿着摄像头IP去拉RTSP流,而是先通过ISUP长连接向设备下发取流信令。设备收到信令后,会返回媒体协商信息,包括当前设备媒体的传输地址、会话ID等。
public PreviewSession startPreview(String deviceId, Integer channelNo, String streamType) { Channel channel = DeviceChannelRegistry.get(deviceId); if (channel == null) { throw new BizException("设备不在线"); } // 组装ISUP取流信令,指定通道号和码流类型 IsupMessage request = IsupMessage.buildPreviewReq(deviceId, channelNo, streamType); channel.writeAndFlush(request); // 等待设备返回协商结果(通过异步回调或者阻塞队列实现) MediaInfo mediaInfo = MediaNegotiationCache.waitResult(deviceId, 10, TimeUnit.SECONDS); String rtspUrl = buildRtspUrl(deviceId, channelNo, streamType); String flvUrl = startFlvProcess(rtspUrl, deviceId, channelNo); return buildPreviewSession(deviceId, channelNo, flvUrl); }这里面有个细节值得注意:设备协商返回的媒体信息可能和平台配置的RTSP地址不一致,实际取流时要以协商结果为准,不能拿数据库里配置的IP盲拼RTSP地址。否则设备在NAT后面时,协商返回的是内网地址,平台照样连不上。
3.4 RTSP 转 HTTP-FLV:FFmpeg 进程管理
ISUP信令只解决控制和协商的问题,真正让浏览器看到画面,需要把RTSP流转换成前端能播放的HTTP-FLV格式。这里我选择用FFmpeg命令行工具,通过Java的ProcessBuilder拉起子进程完成转流。
private String startFlvProcess(String rtspUrl, String deviceId, Integer channelNo) { String flvUrl = "http://127.0.0.1:8088/live/" + deviceId + "_" + channelNo + ".flv"; ProcessBuilder pb = new ProcessBuilder( "ffmpeg", "-rtsp_transport", "tcp", "-i", rtspUrl, "-c:v", "copy", "-an", "-f", "flv", flvUrl ); pb.redirectErrorStream(true); Process process = pb.start(); // 把进程和预览会话关联,方便停止预览时杀掉 FlvProcessManager.register(deviceId + "_" + channelNo, process); return flvUrl; }这里有两个坑必须说。第一,一定要加-rtsp_transport tcp,前端预览对实时性要求不是极致,但稳定性要求很高,UDP拉流在弱网环境下丢包会导致花屏和卡顿,TCP能解决绝大多数问题。第二,-c:v copy是复制编码流不做转码,性能开销极小,但前提是前端播放器能解码该编码格式。如果摄像头是H.265编码,而目标浏览器不支持H.265解码,就必须改成-c:v libx264做软件转码,否则画面永远黑屏。
3.5 顺手做掉云台控制与报警推送
ISUP通道不仅能做预览,云台控制也是通过这条长连接下发的。前端点一下方向键,后端收到请求后组装一条PTZ控制信令,通过设备对应的Channel发出去,设备就会执行相应动作。消息格式和注册包类似,只是CmdType换成对应的控制指令。
报警消息同样是设备主动上报。报警时设备会把报警类型、通道号、时间戳组装成XML消息推给平台,平台可以在收到后做联动处理,比如写入数据库、推送告警通知。这个功能实现起来并不复杂,只需要在消息处理器的switch里多加一个Alarm分支。
4. 前端 Vue 播放:flv.js 接入细节
4.1 播放器选型与初始化参数
浏览器原生HTML5的video标签不支持FLV格式,所以播放HTTP-FLV流需要借助flv.js。这个库通过MSE(Media Source Extensions)把FLV解封装后喂给浏览器原生播放器,兼容性在现代浏览器里表现不错。
import flvjs from 'flv.js' function createFlvPlayer(videoRef, url) { if (!flvjs.isSupported()) { console.error('当前浏览器不支持 flv.js') return null } const flvPlayer = flvjs.createPlayer({ type: 'flv', isLive: true, url: url }, { enableStashBuffer: false, liveBufferLatencyChasing: true, liveBufferLatencyChasingRate: 0.8 }) flvPlayer.attachMediaElement(videoRef) flvPlayer.load() flvPlayer.play() return flvPlayer }enableStashBuffer: false这个参数建议显式设置。flv.js默认会启用一个较大的缓冲池来对抗网络抖动,但缓冲越大延迟越高,实时预览场景下每多一秒缓冲都是难以接受的。关闭stash buffer后延迟能控制在可接受范围,配合liveBufferLatencyChasing让播放器在缓冲积压时自动追帧。
4.2 播放生命周期管理和断流重连
实时预览和点播最大区别是流可能随时中断。设备重启、网络波动、FFmpeg进程崩溃都会导致FLV流中断,前端必须处理断流后的自动恢复。
我的做法是监听video的error事件和flv.js的STATISTICS_INFO事件,当发现连接异常时,先销毁当前player实例,然后重新调用创建播放器的方法。重连次数做上限控制,比如连续重连5次失败后就提示用户设备可能离线。
function destroyFlvPlayer(player) { if (player) { player.pause() player.unload() player.detachMediaElement() player.destroy() } } // 组件卸载时一定要清理 onBeforeUnmount(() => { destroyFlvPlayer(flvPlayer) })销毁逻辑必须写在组件卸载钩子里,否则离开预览页面后播放器还在后台拉流,不仅浪费带宽,严重时会导致浏览器标签页卡顿。
4.3 多路预览的场景处理
如果需要在一个页面上同时展示多个摄像头画面对比,要注意浏览器对同时活跃的MSE实例是有限制的。实测中同时播放6路以上的HTTP-FLV流时,部分浏览器的内存占用会显著上升,画面可能出现周期性卡顿。
多路场景下的优化方案是控制码流类型。预览墙页面默认请求子码流,子码流分辨率低、码率小,同时播放的压力会小很多。只有用户切换到单画面详情时才拉主码流,追求画质。这种按场景切换码流的设计,在后端启动预览任务时通过streamType参数指定即可。
5. 踩坑集中营:常见问题与排查方法
5.1 设备死活不上线
这是接入ISUP时遇到最多的第一道坎。设备配置好了平台IP和端口,但平台侧就是看不到它上线。排查顺序建议从外到内走一遍:
第一,确认端口可达性。在部署服务的服务器上用telnet或者nc测试设备端到7660端口的连通性,如果是云服务器,还要检查安全组是否放行了7660的TCP入站规则。第二,确认设备选的协议模式。设备端“平台接入”位置如果选了EHOME而平台当前监听的是ISUP模式,虽然多数情况下兼容,但个别老设备会有问题,统一改成ISUP模式可减少变量。第三,确认设备ID在平台侧已经存在。ISUP注册通常要求平台先录入设备ID和序列号,设备发来的注册包如果匹配不上,平台会拒绝。
还有一个经常被忽略的点:服务器时间。海康设备的注册过程中会校验平台时间,如果平台服务器的时间和设备时间偏差过大,设备可能会拒绝注册。部署服务前先开启NTP时间同步,能省掉很多莫名其妙的问题。
5.2 设备在线但点预览黑屏
设备状态列显示在线,但前端预览黑屏,这种情况排查思路需要区分前半段链路和后半段链路。
前半段是ISUP信令链路。先确认后端日志里设备是否响应了取流信令,如果信令下发了但设备没有回应,说明设备端的RTSP服务没有正常启动或者配置的通道号不存在。后半段是RTSP转FLV链路。如果FFmpeg进程没有拉起来,查看进程日志,常见的错误是RTSP鉴权失败、端口不可达、地址错误。
让我特别想强调的一点:通道号千万别填错。海康的RTSP地址规则里,Streaming/Channels/101代表通道1主码流,102代表通道1子码流,201代表通道2主码流。如果设备只有4个通道,通道5的地址自然拉不到流。我见过不止一次因为这里少写一个0、或者把10写成01导致排查半天的情况。
如果确认通道和地址都对,还需要检查编码格式。H.265编码的摄像头的流如果直接copy转FLV,Chrome浏览器默认不支持解码,画面会一直黑屏。这种场景要么换一个支持H.265的播放器方案,要么后端用libx264重新转成H.264再推流。
5.3 延迟大、画面花屏
实时预览的延迟如果到了十几秒,问题多半出在缓冲策略上。前端flv.js默认的stash buffer为了平滑播放会缓存不少数据,直播场景下这种策略是反效果。我把enableStashBuffer设为false之后,延迟从十几秒降到了两三秒,体感差别非常明显。
花屏问题则优先怀疑网络丢包。RTSP拉流如果走UDP,在跨网络、跨运营商的情况下丢包率会很高,画面容易出现块状花屏。给FFmpeg命令加上-rtsp_transport tcp之后,传输从UDP换成TCP,花屏问题绝大多数都能解决。代价是TCP传输在弱网下的延迟略有上升,但换来的是稳定的画面,这笔账完全划算。
5.4 多路并发预览不稳定
跑通单路预览不难,难的是同时预览几十路时不把服务和设备搞崩。FFmpeg转流进程是典型的CPU和内存消耗大户,每路视频流都要占用独立的进程,我遇到的情况是并发超过20路后服务器内存持续上涨,最后不得不做双保险。
第一层保险是共享内存区限额。每路FFmpeg进程设置最大内存限制,防止单路异常导致整个系统崩溃。第二层保险是预览任务上限控制。在业务层设置全局并发上限,超过上限后新的预览请求进入排队状态,前端提示用户稍后再试,而不是无限拉进程。
设备侧的取流并发限制也要考虑,海康设备通常对同一路码流有并发取流上限。当超过上限时,设备会拒绝新的RTSP连接,FFmpeg进程报错退出。解决方法是增加一个取流信号量,每个设备同时只允许一个主码流和两个子码流预览,其他人复用已经建立好的流。
5.5 排查速查表
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 设备不上线 | 端口没通、协议模式不对、设备信息未录入、时间偏差大 | 检查安全组、抓包看注册请求、校准NTP |
| 注册后马上掉线 | 平台在线状态TTL太短、心跳响应不及时 | 调整Redis过期时间、确认心跳Ack在5秒内返回 |
| 点预览黑屏 | RTSP地址错误、通道号不对、H.265不兼容 | 看FFmpeg日志、核对Channels地址、换H.264转码 |
| 延迟高 | 前端缓冲策略问题 | 设置enableStashBuffer:false |
| 花屏 | 网络丢包 | 拉流加-rtsp_transport tcp |
| 并发预览崩溃 | 进程无上限、设备取流超限 | 加并发限制、复用已有预览流 |
6. 做完这个项目后的几点体会
如果让我重新做一遍这个ISUP接入的项目,我会把更多时间花在抓包理解协议上。最初我拿到文档后直接开写,结果注册包解析就返工了好几次,因为文档描述的字段和真实设备发出的XML有出入。后来用抓包工具把注册、心跳、取流、报警几个关键流程的报文全部录下来,对照着理清了字段含义,代码一次通过率才真正提上来。
设备兼容性也是个大坑。同一款摄像头,固件版本不同,ISUP消息行为就可能不一样。不要指望一个版本写死就能兼容所有设备,设计协议解析时就预留好字段映射和扩展点,后期维护会轻松很多。能在内网环境搭建一套模拟设备做自动化回归测试,是保持兼容性的最好投入。
最后想提醒一点安全相关的习惯。ISUP服务端如果直接暴露在公网上,很容易被扫描器盯上。实际部署时至少要做到三件事:设备接入端口不对公网开放,只放行白名单IP;设备默认密码必须修改,密码强度不能妥协;有条件就启用ISUP加密端口7661,避免信令在网络上明文传输。这些不是可选项,是接入方案默认就该有的配置。