| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
ab6d181075 | feat: 整合海康、大华、朗驰、朗驰海康相机 | 2 days ago |
|
|
fe50d93cf5 | feat: 海康SDK新增支持linux x86架构 | 4 days ago |
|
|
381b6611e6 | feat: 海康SDK新增支持linux x86架构 | 4 days ago |
| @ -0,0 +1,12 @@ | |||||
| <?xml version="1.0" encoding="GB2312"?> | |||||
| <SdkLocal> | |||||
| <SdkLog> | |||||
| <logLevel>3</logLevel><!--req, 1-ERROR, 2-DEBUG, 3-INFO--> | |||||
| <logDirectory>./SDKLOG/</logDirectory><!--the end of the string must be '/'--> | |||||
| <autoDelete>true</autoDelete><!--true: There are less than 10 files in the directory, it will be auto deleted by sdk when the files are more than 10; false: No upper limit to the number of log files--> | |||||
| </SdkLog> | |||||
| <HeartbeatCfg> | |||||
| <Interval>120</Interval> <!-- 心跳时间间隔,单位秒,等于0,使用默认值120s,取值范围为[30, 120] 小于30s,间隔为30s,大于120s,间隔为120s--> | |||||
| <Count>1</Count> <!-- 触发异常回调需要心跳交互异常的次数,等于0,使用默认值1次--> | |||||
| </HeartbeatCfg> | |||||
| </SdkLocal> | |||||
| @ -1,4 +0,0 @@ | |||||
| [2025-09-16 11:20:34.679][DBG] CCoreGlobalCtrlBase::LoadDSo, HPR_LoadDSo Succ, Path[D:/workspace/inspect-nvr/lib/zlib1.dll], hHandleRet[1841233920] | |||||
| [2025-09-16 11:20:34.679][INF] The COM:HCCoreBase ver is 6.1.4.15, 2020_03_05. Async:1. | |||||
| [2025-09-16 11:20:34.679][INF] The COM:Core ver is 6.1.9.47, 2022_11_11. Async:1. | |||||
| [2025-09-16 11:20:34.679][INF] This HCNetSDK ver is 6.1.9.47 Ver 2022_11_11. | |||||
| @ -1,152 +0,0 @@ | |||||
| package com.inspect.nvr.service; | |||||
| import com.inspect.nvr.daHuaCarme.jna.NetSDKLib; | |||||
| import com.inspect.nvr.daHuaCarme.jna.dahua.ToolKits; | |||||
| import com.inspect.nvr.domain.Infrared.NvrInfo; | |||||
| import com.sun.jna.Pointer; | |||||
| import com.sun.jna.ptr.IntByReference; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| import org.springframework.stereotype.Service; | |||||
| import javax.annotation.Resource; | |||||
| import java.nio.file.Files; | |||||
| import java.nio.file.Path; | |||||
| import java.util.concurrent.*; | |||||
| import java.util.concurrent.atomic.AtomicInteger; | |||||
| /** | |||||
| * 大华设备SDK服务 | |||||
| * 同一个IP,同一个通道,串行 | |||||
| * 同一个IP,不同通道,最多4个线程并发 | |||||
| * 不同IP,并发抓图,不限制 | |||||
| */ | |||||
| @Slf4j | |||||
| @Service | |||||
| public class DahuaCameraService extends CommonCameraService { | |||||
| /** | |||||
| * Digest认证抓图URL: | |||||
| * http://<host>/cgi-bin/snapshot.cgi?channel=<channel>&subtype=<subtype> | |||||
| * subtype: 0-主码流, 1-子码流 | |||||
| */ | |||||
| private static final String DIGEST_URL_TEMPLATE = "http://%s/cgi-bin/snapshot.cgi?channel=%d&subtype=1"; | |||||
| // Key: loginId+CmdSerial (登录句柄+流水号), Value: CompletableFuture (用于通知调用线程) | |||||
| private static final ConcurrentHashMap<String, CompletableFuture<byte[]>> PENDING_REQUESTS = new ConcurrentHashMap<>(); | |||||
| private static final fCaptureReceiveCB CAPTURE_RECEIVE_CB = new fCaptureReceiveCB(); | |||||
| // 1. [新增]全局流水号生成器 | |||||
| private static final AtomicInteger SERIAL_COUNTER = new AtomicInteger(1); | |||||
| @Resource | |||||
| private NetSDKLib dhNetSDK; | |||||
| @Resource | |||||
| private DahuaLoginService dahuaLoginService; | |||||
| // CmdSerial请求序列号,有效值范围 0~65535,超过范围会被截断 | |||||
| public static int nextSerial() { | |||||
| return SERIAL_COUNTER.updateAndGet(current -> (current + 1) & 0xFFFF); | |||||
| } | |||||
| public byte[] capture(NvrInfo nvrInfo, int channel) { | |||||
| return withConcurrencyControl(nvrInfo, channel, () -> captureWithRetry(nvrInfo, channel)); | |||||
| } | |||||
| private byte[] captureWithRetry(NvrInfo nvrInfo, int channel) { | |||||
| Path fullPath = getFullPath("dh", nvrInfo.getNvrIp(), channel); | |||||
| int retryCount = 0; | |||||
| int maxRetries = DEFAULT_MAX_RETRIES; | |||||
| while (retryCount < maxRetries) { | |||||
| try { | |||||
| byte[] imageBytes = snapPictureEx(nvrInfo, channel, fullPath); | |||||
| log.info("[大华]抓图成功:第{}次,文件地址:{}", retryCount + 1, fullPath); | |||||
| return imageBytes; | |||||
| } catch (Exception e) { | |||||
| log.error("[大华]抓图异常:第{}次,{}", retryCount + 1, e.getMessage()); | |||||
| } finally { | |||||
| retryCount++; | |||||
| } | |||||
| } | |||||
| // 当SDK抓图失败时,尝试使用Digest认证抓图 | |||||
| byte[] imageBytes = captureDigest(nvrInfo, channel, DIGEST_URL_TEMPLATE, fullPath); | |||||
| if (imageBytes == null) { | |||||
| // 当所有抓图方式均失败时,记录失败图片 | |||||
| writeCaptureFailedImage(fullPath); | |||||
| log.error("[大华]所有抓图方式均失败,图片地址:{}", fullPath); | |||||
| return new byte[0]; | |||||
| } | |||||
| log.info("[大华]digest抓图成功,图片地址:{}", fullPath); | |||||
| return imageBytes; | |||||
| } | |||||
| /** | |||||
| * 大华SDK抓图具体实现(异步) | |||||
| * 通过CompletableFuture实现异步回调通知 | |||||
| */ | |||||
| private byte[] snapPictureEx(NvrInfo nvrInfo, int channel, Path fullPath) { | |||||
| NetSDKLib.LLong loginID = dahuaLoginService.login(nvrInfo); | |||||
| NetSDKLib.SNAP_PARAMS snapParams = new NetSDKLib.SNAP_PARAMS(); | |||||
| snapParams.Channel = channel - 1; // 通道号从0开始 | |||||
| snapParams.mode = 0; // 抓图模式:0-单次抓 | |||||
| snapParams.Quality = 3; | |||||
| snapParams.InterSnap = 0; | |||||
| int mySerialId = nextSerial(); | |||||
| snapParams.CmdSerial = mySerialId; | |||||
| IntByReference reference = new IntByReference(0); | |||||
| // 设置异步抓图回调函数 | |||||
| dhNetSDK.CLIENT_SetSnapRevCallBack(CAPTURE_RECEIVE_CB, null); | |||||
| String requestKey = loginID.longValue() + ":" + mySerialId; | |||||
| CompletableFuture<byte[]> future = new CompletableFuture<>(); | |||||
| PENDING_REQUESTS.put(requestKey, future); | |||||
| final int TIMEOUT_SEC = 5; | |||||
| try { | |||||
| log.info("[大华]开始抓图,LoginID={},IP={},Channel={},Serial={}", loginID, nvrInfo.getNvrIp(), channel, mySerialId); | |||||
| boolean isCaptured = dhNetSDK.CLIENT_SnapPictureEx(loginID, snapParams, reference); | |||||
| if (!isCaptured) { | |||||
| String errorMsg = ToolKits.getErrorCodePrint(dhNetSDK.CLIENT_GetLastError()); | |||||
| throw new RuntimeException("SDK抓图失败:" + errorMsg); | |||||
| } | |||||
| byte[] byteArray = future.get(TIMEOUT_SEC, TimeUnit.SECONDS); | |||||
| Files.write(fullPath, byteArray); | |||||
| return byteArray; | |||||
| } catch (TimeoutException e) { | |||||
| log.error("[大华]抓图超时:在 {} 秒内未收到设备回调,requestKey={}", TIMEOUT_SEC, requestKey); | |||||
| } catch (Exception e) { | |||||
| log.error("[大华]抓图异常:", e); | |||||
| } finally { | |||||
| PENDING_REQUESTS.remove(requestKey); | |||||
| } | |||||
| return null; | |||||
| } | |||||
| /** | |||||
| * Digest认证抓图 | |||||
| */ | |||||
| public byte[] captureDigest(NvrInfo nvrInfo, int channel) { | |||||
| return captureDigest(nvrInfo, channel, DIGEST_URL_TEMPLATE, getFullPath("dh_digest", nvrInfo.getNvrIp(), channel)); | |||||
| } | |||||
| /** | |||||
| * CLIENT_SnapPictureEx异步抓图回调函数重写 | |||||
| */ | |||||
| public static class fCaptureReceiveCB implements NetSDKLib.fSnapRev { | |||||
| @Override | |||||
| public void invoke(NetSDKLib.LLong lLoginID, Pointer pBuf, int RevLen, int EncodeType, int CmdSerial, Pointer dwUser) { | |||||
| // 1. 检查是否有等待该流水号的请求 | |||||
| String requestKey = lLoginID.longValue() + ":" + CmdSerial; | |||||
| CompletableFuture<byte[]> future = PENDING_REQUESTS.remove(requestKey); | |||||
| if (future != null) { | |||||
| log.info("[大华]匹配到抓图回调,LoginID={}, Serial={}", lLoginID, CmdSerial); | |||||
| if (pBuf != null && RevLen > 0) { | |||||
| // 2. 读取图片数据 | |||||
| byte[] data = pBuf.getByteArray(0, RevLen); | |||||
| // 3. 完成Future,通知主线程 | |||||
| future.complete(data); | |||||
| } else { | |||||
| future.completeExceptionally(new RuntimeException("Empty image data")); | |||||
| } | |||||
| } else { | |||||
| // 可能是由于超时已经被移除了,或者是其他类型的抓图 | |||||
| log.error("[大华]收到未匹配的抓图回调,LoginID={}, Serial={}", lLoginID, CmdSerial); | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @ -1,126 +0,0 @@ | |||||
| package com.inspect.nvr.service; | |||||
| import com.alibaba.fastjson.JSONObject; | |||||
| import com.github.benmanes.caffeine.cache.Cache; | |||||
| import com.github.benmanes.caffeine.cache.Caffeine; | |||||
| import com.github.benmanes.caffeine.cache.RemovalCause; | |||||
| import com.inspect.nvr.daHuaCarme.jna.NetSDKLib; | |||||
| import com.inspect.nvr.daHuaCarme.jna.NetSDKLib.LLong; | |||||
| import com.inspect.nvr.domain.Infrared.NvrInfo; | |||||
| import com.inspect.nvr.utils.redis.RedisService; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| import org.springframework.beans.factory.annotation.Autowired; | |||||
| import org.springframework.stereotype.Service; | |||||
| import javax.annotation.Resource; | |||||
| import java.util.concurrent.TimeUnit; | |||||
| /** | |||||
| * 大华登录服务 | |||||
| */ | |||||
| @Slf4j | |||||
| @Service | |||||
| public class DahuaLoginService { | |||||
| private static final String ERROR_LOGOUT_KEY = "dahua:error"; | |||||
| @Autowired | |||||
| private NetSDKLib dhNetSDK; | |||||
| @Resource | |||||
| private RedisService redisService; | |||||
| // 使用 Caffeine 缓存,10分钟未访问自动移除并登出 | |||||
| private final Cache<String, LLong> sessionCache = Caffeine.newBuilder() | |||||
| // 10分钟未被get()就过期 | |||||
| .expireAfterAccess(10, TimeUnit.MINUTES) | |||||
| .removalListener((String ip, LLong loginID, RemovalCause cause) -> { | |||||
| if (loginID != null && (cause == RemovalCause.EXPIRED || cause == RemovalCause.SIZE)) { | |||||
| log.info("[大华]会话超时自动登出,ip: {},loginID: {}", ip, loginID); | |||||
| doLogout(ip, loginID); | |||||
| } | |||||
| }).build(); | |||||
| public synchronized LLong login(NvrInfo nvrInfo) { | |||||
| String ip = nvrInfo.getNvrIp(); | |||||
| LLong existLoginID = sessionCache.getIfPresent(ip); | |||||
| if (existLoginID != null) { | |||||
| log.info("[大华]登录命中缓存,ip: {},loginID: {}", ip, existLoginID); | |||||
| return existLoginID; | |||||
| } | |||||
| // 执行登录 | |||||
| NetSDKLib.NET_IN_LOGIN_WITH_HIGHLEVEL_SECURITY pstInParam = new NetSDKLib.NET_IN_LOGIN_WITH_HIGHLEVEL_SECURITY(); | |||||
| pstInParam.szIP = nvrInfo.getNvrIp().getBytes(); | |||||
| pstInParam.nPort = nvrInfo.getServerPort(); | |||||
| pstInParam.szUserName = nvrInfo.getAccount().getBytes(); | |||||
| pstInParam.szPassword = nvrInfo.getPassword().getBytes(); | |||||
| NetSDKLib.NET_OUT_LOGIN_WITH_HIGHLEVEL_SECURITY pstOutParam = new NetSDKLib.NET_OUT_LOGIN_WITH_HIGHLEVEL_SECURITY(); | |||||
| LLong loginID = dhNetSDK.CLIENT_LoginWithHighLevelSecurity(pstInParam, pstOutParam); | |||||
| if (loginID.intValue() == 0) { | |||||
| int errorCode = dhNetSDK.CLIENT_GetLastError(); | |||||
| throw new RuntimeException("登录失败,错误码:" + errorCode); | |||||
| } | |||||
| // 放入缓存,自动开始计时10分钟 | |||||
| sessionCache.put(nvrInfo.getNvrIp(), loginID); | |||||
| log.info("[大华]登录成功,ip:{},loginID:{}", ip, loginID); | |||||
| return loginID; | |||||
| } | |||||
| /** | |||||
| * 记录注销失败信息到 Redis | |||||
| */ | |||||
| private void recordLogoutError(String ip, LLong loginID, int errorCode) { | |||||
| JSONObject json = new JSONObject(); | |||||
| json.put("ip", ip); | |||||
| json.put("userId", loginID); | |||||
| json.put("errorCode", errorCode); | |||||
| json.put("time", System.currentTimeMillis()); | |||||
| redisService.redisTemplate.opsForZSet().add(ERROR_LOGOUT_KEY, json.toJSONString(), System.currentTimeMillis()); | |||||
| } | |||||
| /** | |||||
| * 登出具体实现 | |||||
| */ | |||||
| public void doLogout(String ip, LLong loginID) { | |||||
| if (loginID != null) { | |||||
| // 执行登出操作 | |||||
| boolean isLogout = dhNetSDK.CLIENT_Logout(loginID); | |||||
| if (isLogout) { | |||||
| log.info("[大华]自动注销成功,ip: {},loginID: {}", ip, loginID.longValue()); | |||||
| } else { | |||||
| int errorCode = dhNetSDK.CLIENT_GetLastError(); | |||||
| log.error("[大华]自动注销失败,ip: {},loginID: {},错误码: {}", ip, loginID.longValue(), errorCode); | |||||
| // 记录失败日志到 Redis | |||||
| recordLogoutError(ip, loginID, errorCode); | |||||
| } | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 登出 | |||||
| */ | |||||
| public synchronized void logout(String ip) { | |||||
| LLong loginID = sessionCache.getIfPresent(ip); | |||||
| if (loginID != null) { | |||||
| sessionCache.invalidate(ip); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 登出所有用户 | |||||
| */ | |||||
| public void logoutAll() { | |||||
| // 获取所有缓存的IP和userID | |||||
| sessionCache.asMap().forEach((ip, loginID) -> { | |||||
| doLogout(ip, loginID); | |||||
| }); | |||||
| // 清空整个缓存 | |||||
| sessionCache.invalidateAll(); | |||||
| log.info("[大华]所有用户已登出"); | |||||
| } | |||||
| /** | |||||
| * 检查是否已登录(同时刷新过期时间) | |||||
| */ | |||||
| public boolean isLoggedIn(String ip) { | |||||
| return sessionCache.getIfPresent(ip) != null; | |||||
| } | |||||
| } | |||||
| @ -1,125 +0,0 @@ | |||||
| package com.inspect.nvr.service; | |||||
| import com.alibaba.fastjson.JSONObject; | |||||
| import com.github.benmanes.caffeine.cache.Cache; | |||||
| import com.github.benmanes.caffeine.cache.Caffeine; | |||||
| import com.github.benmanes.caffeine.cache.RemovalCause; | |||||
| import com.inspect.nvr.domain.Infrared.NvrInfo; | |||||
| import com.inspect.nvr.hikVision.utils.jna.HCNetSDK; | |||||
| import com.inspect.nvr.hikVision.utils.jna.HikVisionUtils; | |||||
| import com.inspect.nvr.utils.redis.RedisService; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| import org.springframework.beans.factory.annotation.Autowired; | |||||
| import org.springframework.stereotype.Service; | |||||
| import javax.annotation.Resource; | |||||
| import java.util.concurrent.TimeUnit; | |||||
| /** | |||||
| * 海康登录服务统一管理 | |||||
| */ | |||||
| @Slf4j | |||||
| @Service | |||||
| public class HikLoginService { | |||||
| private static final String ERROR_LOGOUT_KEY = "hik:error"; | |||||
| @Resource | |||||
| private RedisService redisService; | |||||
| @Autowired | |||||
| private HCNetSDK hcNetSDK; | |||||
| // 使用 Caffeine 缓存,10分钟未访问自动移除并登出 | |||||
| private final Cache<String, Integer> sessionCache = Caffeine.newBuilder() | |||||
| // 10分钟未被get()就过期 | |||||
| .expireAfterAccess(10, TimeUnit.MINUTES) | |||||
| .removalListener((String ip, Integer userID, RemovalCause cause) -> { | |||||
| if (userID != null && (cause == RemovalCause.EXPIRED || cause == RemovalCause.SIZE)) { | |||||
| log.info("[海康]会话超时自动登出,ip: {},userID: {}", ip, userID); | |||||
| doLogout(ip, userID); | |||||
| } | |||||
| }).build(); | |||||
| /** | |||||
| * 登录 | |||||
| */ | |||||
| public synchronized int login(NvrInfo nvrInfo) { | |||||
| String ip = nvrInfo.getNvrIp(); | |||||
| Integer existUserId = sessionCache.getIfPresent(ip); | |||||
| if (existUserId != null) { | |||||
| log.info("[海康]登录命中缓存,ip: {},userID: {}", ip, existUserId); | |||||
| return existUserId; | |||||
| } | |||||
| // 执行登录 | |||||
| HCNetSDK.NET_DVR_USER_LOGIN_INFO m_strLoginInfo = HikVisionUtils.login_V40(nvrInfo.getNvrIp(), nvrInfo.getServerPort().shortValue(), nvrInfo.getAccount(), nvrInfo.getPassword()); | |||||
| HCNetSDK.NET_DVR_DEVICEINFO_V40 m_strDeviceInfo = new HCNetSDK.NET_DVR_DEVICEINFO_V40(); | |||||
| int userID = hcNetSDK.NET_DVR_Login_V40(m_strLoginInfo, m_strDeviceInfo); | |||||
| if (userID < 0) { | |||||
| int errorCode = hcNetSDK.NET_DVR_GetLastError(); | |||||
| throw new RuntimeException("登录失败,错误码:" + errorCode); | |||||
| } | |||||
| // 放入缓存,自动开始计时10分钟 | |||||
| sessionCache.put(nvrInfo.getNvrIp(), userID); | |||||
| log.info("[海康]登录成功,ip:{},userID:{}", ip, userID); | |||||
| return userID; | |||||
| } | |||||
| /** | |||||
| * 记录注销失败信息到 Redis | |||||
| */ | |||||
| private void recordLogoutError(String ip, Integer userID, int errorCode) { | |||||
| JSONObject json = new JSONObject(); | |||||
| json.put("ip", ip); | |||||
| json.put("userID", userID); | |||||
| json.put("errorCode", errorCode); | |||||
| json.put("time", System.currentTimeMillis()); | |||||
| redisService.redisTemplate.opsForZSet().add(ERROR_LOGOUT_KEY, json.toJSONString(), System.currentTimeMillis()); | |||||
| } | |||||
| /** | |||||
| * 登出具体实现 | |||||
| */ | |||||
| public void doLogout(String ip, Integer userID) { | |||||
| if (userID != null) { | |||||
| // 调用登出SDK | |||||
| boolean isLogout = hcNetSDK.NET_DVR_Logout(userID); | |||||
| if (isLogout) { | |||||
| log.info("[海康]登出成功,ip: {},userID: {}", ip, userID); | |||||
| } else { | |||||
| int errorCode = hcNetSDK.NET_DVR_GetLastError(); | |||||
| log.error("[海康]登出失败,ip: {},userID: {},错误码: {}", ip, userID, errorCode); | |||||
| // 登出失败日志记录到Redis中 | |||||
| recordLogoutError(ip, userID, errorCode); | |||||
| } | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 登出 | |||||
| */ | |||||
| public synchronized void logout(String ip) { | |||||
| Integer userID = sessionCache.getIfPresent(ip); | |||||
| if (userID != null) { | |||||
| sessionCache.invalidate(ip); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 登出所有用户 | |||||
| */ | |||||
| public void logoutAll() { | |||||
| // 获取所有缓存的IP和userID | |||||
| sessionCache.asMap().forEach((ip, userID) -> { | |||||
| doLogout(ip, userID); | |||||
| }); | |||||
| // 清空整个缓存 | |||||
| sessionCache.invalidateAll(); | |||||
| log.info("[海康]所有用户已登出"); | |||||
| } | |||||
| /** | |||||
| * 检查是否已登录(同时刷新过期时间) | |||||
| */ | |||||
| public boolean isLoggedIn(String ip) { | |||||
| return sessionCache.getIfPresent(ip) != null; | |||||
| } | |||||
| } | |||||
| @ -1,186 +0,0 @@ | |||||
| package com.inspect.nvr.service; | |||||
| import com.inspect.nvr.domain.Infrared.Camera; | |||||
| import com.inspect.nvr.domain.Infrared.NvrInfo; | |||||
| import com.inspect.nvr.domain.Infrared.TemperatureData; | |||||
| import com.inspect.nvr.jna.lincseek.IRNetSDK; | |||||
| import com.inspect.nvr.jna.lincseek.IRNetSDKCallback; | |||||
| import com.inspect.nvr.jna.lincseek.IRNetSDKConst; | |||||
| import com.inspect.nvr.jna.lincseek.IRNetSDKStruct; | |||||
| import com.inspect.nvr.jna.lincseek.IRNetSDKStruct.IRNETHANDLE; | |||||
| import com.sun.jna.Memory; | |||||
| import com.sun.jna.Pointer; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| import org.springframework.stereotype.Service; | |||||
| import javax.annotation.Resource; | |||||
| import java.nio.file.Files; | |||||
| import java.nio.file.Path; | |||||
| import java.util.concurrent.CompletableFuture; | |||||
| import java.util.concurrent.ConcurrentHashMap; | |||||
| import java.util.concurrent.TimeUnit; | |||||
| import java.util.concurrent.atomic.AtomicInteger; | |||||
| @Slf4j | |||||
| @Service | |||||
| public class LincseekCameraService extends CommonCameraService { | |||||
| // [新增]全局流水号生成器 | |||||
| private static final AtomicInteger SERIAL_COUNTER = new AtomicInteger(1); | |||||
| // Key: CmdSerial (流水号), Value: CompletableFuture (用于通知调用线程) | |||||
| private static final ConcurrentHashMap<String, CompletableFuture<byte[]>> PENDING_REQUESTS = new ConcurrentHashMap<>(); | |||||
| @Resource | |||||
| private IRNetSDK irNetSDK; | |||||
| @Resource | |||||
| private LincseekLoginService lincseekLoginService; | |||||
| public static int nextSerial() { | |||||
| return SERIAL_COUNTER.updateAndGet(current -> (current + 1) & 0xFFFF); | |||||
| } | |||||
| public static Pointer stringToPointer(String str) { | |||||
| if (str == null) return null; | |||||
| byte[] bytes = str.getBytes(); // 1. 转换为字节数组 | |||||
| Pointer pointer = new Memory(bytes.length + 1); // 2. 分配内存(+1 用于存放 \0) | |||||
| pointer.write(0, bytes, 0, bytes.length); // 3. 写入字节 | |||||
| pointer.setByte(bytes.length, (byte) 0); // 4. 追加空字节(Null-termination) | |||||
| return pointer; | |||||
| } | |||||
| /** | |||||
| * 受并发控制的朗驰抓图 | |||||
| */ | |||||
| public byte[] capture(NvrInfo nvrInfo, int channel) { | |||||
| return withConcurrencyControl(nvrInfo, channel, () -> captureWithRetry(nvrInfo, channel)); | |||||
| } | |||||
| private byte[] captureWithRetry(NvrInfo nvrInfo, int channel) { | |||||
| Path fullPath = getFullPath("lc", nvrInfo.getNvrIp(), channel); | |||||
| ensureDirectoryExists(fullPath.getParent()); | |||||
| int retryCount = 0; | |||||
| int maxRetries = DEFAULT_MAX_RETRIES; | |||||
| while (retryCount < maxRetries) { | |||||
| try { | |||||
| byte[] imageBytes = jpegCapSingle(nvrInfo, channel, fullPath); | |||||
| log.info("[朗驰]抓图成功:第{}次,文件地址:{}", retryCount + 1, fullPath); | |||||
| return imageBytes; | |||||
| } catch (Exception e) { | |||||
| log.error("[朗驰]抓图异常(第{}次):{}", retryCount + 1, e.getMessage()); | |||||
| } finally { | |||||
| retryCount++; | |||||
| } | |||||
| } | |||||
| // 当所有抓图方式均失败时,记录失败图片 | |||||
| writeCaptureFailedImage(fullPath); | |||||
| log.error("[朗驰]抓图失败,图片地址:{}", fullPath); | |||||
| return new byte[0]; | |||||
| } | |||||
| private byte[] jpegCapSingle(NvrInfo nvrInfo, int channel, Path fullPath) { | |||||
| IRNetSDKCallback.JpegDataCallback jpegCb = (hHandle, mCh, pBuffer, size, extraData, userdata) -> { | |||||
| String requestKey = userdata.getString(0); | |||||
| CompletableFuture<byte[]> future = PENDING_REQUESTS.remove(requestKey); | |||||
| if (future != null) { | |||||
| log.info("[朗驰]匹配到抓图回调 (requestKey={}, 通道={}, 大小={}B)", requestKey, mCh, size); | |||||
| byte[] jpeg = pBuffer.getByteArray(0, size); | |||||
| future.complete(jpeg); | |||||
| } else { | |||||
| // 可能是由于超时已经被移除了,或者是其他类型的抓图 | |||||
| log.error("[朗驰]未匹配的抓图回调,requestKey={}", requestKey); | |||||
| } | |||||
| }; | |||||
| int mySerialId = nextSerial(); | |||||
| String requestKey = ":" + mySerialId; | |||||
| Pointer userData = stringToPointer(requestKey); | |||||
| IRNETHANDLE capHandle = irNetSDK.IRNET_ClientJpegCapStart("video server", nvrInfo.getNvrIp(), nvrInfo.getAccount(), nvrInfo.getPassword(), (short) nvrInfo.getServerPort().intValue(), jpegCb, userData); | |||||
| if (!IRNETHANDLE.INVALID_HANDLE_VALUE.equals(capHandle)) { | |||||
| CompletableFuture<byte[]> future = new CompletableFuture<>(); | |||||
| log.info("capHandle: {}, {}", capHandle.toString(), capHandle.hashCode()); | |||||
| PENDING_REQUESTS.put(requestKey, future); | |||||
| final int TIMEOUT_SEC = 10; | |||||
| try { | |||||
| boolean isCaptured = irNetSDK.IRNET_ClientJpegCapSingle(capHandle, channel, 100); | |||||
| if (!isCaptured) { | |||||
| throw new RuntimeException("SDK抓图失败"); | |||||
| } | |||||
| byte[] byteArray = future.get(TIMEOUT_SEC, TimeUnit.SECONDS); | |||||
| Files.write(fullPath, byteArray); | |||||
| return byteArray; | |||||
| } catch (Exception e) { | |||||
| throw new RuntimeException("[朗驰]抓图异常:", e); | |||||
| } finally { | |||||
| PENDING_REQUESTS.remove(requestKey); | |||||
| irNetSDK.IRNET_ClientJpegCapStop(capHandle); | |||||
| } | |||||
| } | |||||
| return null; | |||||
| } | |||||
| public TemperatureData getTemp(Camera camera) { | |||||
| NvrInfo nvrInfo = new NvrInfo(); | |||||
| nvrInfo.setNvrIp(camera.getIp()); | |||||
| nvrInfo.setServerPort(camera.getPort()); | |||||
| nvrInfo.setAccount(camera.getUserName()); | |||||
| nvrInfo.setPassword(camera.getPassword()); | |||||
| int channel = camera.getChannel(); | |||||
| return withConcurrencyControl(nvrInfo, channel, () -> tempWithRetry(nvrInfo, channel)); | |||||
| } | |||||
| /** | |||||
| * 获取最高温、最低温、平均温 | |||||
| */ | |||||
| private TemperatureData tempWithRetry(NvrInfo nvrInfo, int channel) { | |||||
| IRNETHANDLE msgHandle = irNetSDK.IRNET_ClientMessageOpen("video server", nvrInfo.getNvrIp(), nvrInfo.getAccount(), nvrInfo.getPassword(), (short) nvrInfo.getServerPort().intValue()); | |||||
| try { | |||||
| if (!IRNETHANDLE.INVALID_HANDLE_VALUE.equals(msgHandle)) { | |||||
| IRNetSDKStruct.VSNET_TEMP_VALUE_EX temps = new IRNetSDKStruct.VSNET_TEMP_VALUE_EX(); | |||||
| temps.write(); | |||||
| int r = irNetSDK.IRNET_ClientMessageOpt(msgHandle, IRNetSDKConst.MessageOpt.MESSAGE_CMD_GET_TEMPVALUE_EX, channel, temps.getPointer(), null, null); | |||||
| if (r == 1) { | |||||
| temps.read(); | |||||
| float maxTemp = temps.m_maxtempinfo.m_temp_value; | |||||
| int maxX = temps.m_maxtempinfo.m_temp_x; | |||||
| int maxY = temps.m_maxtempinfo.m_temp_y; | |||||
| float minTemp = temps.m_mintempinfo.m_temp_value; | |||||
| int minX = temps.m_mintempinfo.m_temp_x; | |||||
| int minY = temps.m_mintempinfo.m_temp_y; | |||||
| float avgTemp = temps.m_avgtempinfo; | |||||
| log.info("[朗驰]测温结果: 最高={}℃({},{}), 最低={}℃({},{}), 平均={}℃", | |||||
| maxTemp, maxX, maxY, minTemp, minX, minY, avgTemp); | |||||
| return new TemperatureData( | |||||
| String.valueOf(maxTemp), | |||||
| String.valueOf(minTemp), | |||||
| avgTemp, | |||||
| 0, | |||||
| channel, | |||||
| -1 | |||||
| ); | |||||
| } | |||||
| } | |||||
| } catch (Exception e) { | |||||
| log.error("[朗驰]测温异常:", e); | |||||
| } finally { | |||||
| irNetSDK.IRNET_ClientMessageClose(msgHandle); | |||||
| } | |||||
| return null; | |||||
| } | |||||
| /** | |||||
| * 跳转预置位 | |||||
| */ | |||||
| public boolean ptzPreset(Camera camera) { | |||||
| //预置位跳转 | |||||
| NvrInfo nvrInfo = new NvrInfo(); | |||||
| nvrInfo.setNvrIp(camera.getIp()); | |||||
| nvrInfo.setServerPort(camera.getPort()); | |||||
| nvrInfo.setAccount(camera.getUserName()); | |||||
| nvrInfo.setPassword(camera.getPassword()); | |||||
| nvrInfo.setChannelNumber(camera.getChannel()); | |||||
| IRNETHANDLE hHandle = lincseekLoginService.login(nvrInfo); | |||||
| boolean ret = irNetSDK.IRNET_ClientPTZCtrl(hHandle, IRNetSDKConst.PTZ_GOTOPOINT, camera.getPointNum(), 0, null, 0); | |||||
| log.info("[朗驰]跳转预置位{}, 结果: {}", camera.getPointNum(), ret); | |||||
| return ret; | |||||
| } | |||||
| } | |||||
| @ -1,123 +0,0 @@ | |||||
| package com.inspect.nvr.service; | |||||
| import com.alibaba.fastjson.JSONObject; | |||||
| import com.github.benmanes.caffeine.cache.Cache; | |||||
| import com.github.benmanes.caffeine.cache.Caffeine; | |||||
| import com.github.benmanes.caffeine.cache.RemovalCause; | |||||
| import com.inspect.nvr.domain.Infrared.NvrInfo; | |||||
| import com.inspect.nvr.jna.lincseek.IRNetSDK; | |||||
| import com.inspect.nvr.jna.lincseek.IRNetSDKStruct; | |||||
| import com.inspect.nvr.jna.lincseek.IRNetSDKStruct.IRNETHANDLE; | |||||
| import com.inspect.nvr.utils.redis.RedisService; | |||||
| import com.sun.jna.Pointer; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| import org.springframework.stereotype.Service; | |||||
| import javax.annotation.Resource; | |||||
| import java.util.concurrent.TimeUnit; | |||||
| @Slf4j | |||||
| @Service | |||||
| public class LincseekLoginService { | |||||
| private static final String ERROR_LOGOUT_KEY = "lincseek:error"; | |||||
| @Resource | |||||
| private RedisService redisService; | |||||
| @Resource | |||||
| private IRNetSDK irNetSDK; | |||||
| // 使用 Caffeine 缓存,10分钟未访问自动移除并登出 | |||||
| private final Cache<String, IRNETHANDLE> sessionCache = Caffeine.newBuilder() | |||||
| // 10分钟未被get()就过期 | |||||
| .expireAfterAccess(10, TimeUnit.MINUTES) | |||||
| .removalListener((String ip, IRNETHANDLE handle, RemovalCause cause) -> { | |||||
| if (handle != null && (cause == RemovalCause.EXPIRED || cause == RemovalCause.SIZE)) { | |||||
| log.info("[朗驰]会话超时自动登出,ip: {},handler: {}", ip, handle); | |||||
| doLogout(ip, handle); | |||||
| } | |||||
| }).build(); | |||||
| /** | |||||
| * 登录(Caffeine.get 保证同一 IP 的并发请求只执行一次登录) | |||||
| */ | |||||
| public IRNETHANDLE login(NvrInfo nvrInfo) { | |||||
| String ip = nvrInfo.getNvrIp(); | |||||
| Integer port = nvrInfo.getServerPort(); | |||||
| if (port == null) { | |||||
| throw new IllegalArgumentException("serverPort 不能为空"); | |||||
| } | |||||
| return sessionCache.get(ip, key -> { | |||||
| IRNetSDKStruct.CHANNEL_CLIENTINFO info = IRNetSDKStruct.CHANNEL_CLIENTINFO.createWithUrl( | |||||
| "video server", nvrInfo.getAccount(), nvrInfo.getPassword(), | |||||
| (byte) nvrInfo.getChannelNumber().intValue(), ip, null, null, 0, null, null); | |||||
| IRNETHANDLE handle = irNetSDK.IRNET_ClientStart(ip, info, port.shortValue(), 0); | |||||
| if (IRNETHANDLE.INVALID_HANDLE_VALUE.equals(handle)) { | |||||
| throw new RuntimeException("[朗驰]登录失败, ip: " + ip); | |||||
| } | |||||
| log.info("[朗驰]登录成功,ip:{},handler:{}", ip, handle); | |||||
| return handle; | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * 记录注销失败信息到 Redis | |||||
| */ | |||||
| private void recordLogoutError(String ip, Integer userID, int errorCode) { | |||||
| JSONObject json = new JSONObject(); | |||||
| json.put("ip", ip); | |||||
| json.put("userID", userID); | |||||
| json.put("errorCode", errorCode); | |||||
| json.put("time", System.currentTimeMillis()); | |||||
| redisService.redisTemplate.opsForZSet().add(ERROR_LOGOUT_KEY, json.toJSONString(), System.currentTimeMillis()); | |||||
| } | |||||
| /** | |||||
| * 登出具体实现 | |||||
| */ | |||||
| public void doLogout(String ip, IRNETHANDLE handle) { | |||||
| if (handle != null) { | |||||
| // 调用登出SDK | |||||
| boolean isLogout = irNetSDK.IRNET_ClientStop(handle); | |||||
| if (isLogout) { | |||||
| log.info("[朗驰]登出成功,ip: {},handle: {}", ip, handle); | |||||
| } else { | |||||
| log.error("[朗驰]登出失败,ip: {},handle: {}", ip, handle); | |||||
| // 登出失败日志记录到Redis中 | |||||
| recordLogoutError(ip, (int) Pointer.nativeValue(handle), -1); | |||||
| } | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 登出 | |||||
| */ | |||||
| public synchronized void logout(String ip) { | |||||
| IRNETHANDLE handle = sessionCache.getIfPresent(ip); | |||||
| if (handle != null) { | |||||
| sessionCache.invalidate(ip); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 登出所有用户 | |||||
| */ | |||||
| public void logoutAll() { | |||||
| // 获取所有缓存的IP和userID | |||||
| sessionCache.asMap().forEach((ip, handle) -> { | |||||
| doLogout(ip, handle); | |||||
| }); | |||||
| // 清空整个缓存 | |||||
| sessionCache.invalidateAll(); | |||||
| log.info("[朗驰]所有用户已登出"); | |||||
| } | |||||
| /** | |||||
| * 检查是否已登录(同时刷新过期时间) | |||||
| */ | |||||
| public boolean isLoggedIn(String ip) { | |||||
| return sessionCache.getIfPresent(ip) != null; | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,186 @@ | |||||
| package com.inspect.nvr.service.camera; | |||||
| import com.inspect.nvr.domain.Infrared.Camera; | |||||
| import com.inspect.nvr.domain.Infrared.TemperatureData; | |||||
| import com.inspect.nvr.enums.CameraEnum; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| import java.nio.file.Files; | |||||
| import java.nio.file.Path; | |||||
| /** | |||||
| * 品牌相机服务抽象基类。 | |||||
| * 统一抓图、云台预置位、测温的并发控制与重试模板,子类只实现品牌 SDK 差异钩子。 | |||||
| * 并发控制、统一重试等基础设施见 AbstractCameraSupport。 | |||||
| */ | |||||
| @Slf4j | |||||
| public abstract class AbstractCameraService extends AbstractCameraSupport | |||||
| implements BrandCameraService { | |||||
| // ------------------------------------------------------------------ | |||||
| // 品牌差异钩子 | |||||
| // ------------------------------------------------------------------ | |||||
| /** | |||||
| * 品牌对应的摄像头枚举,brandName/fileFlag 均由它派生。 | |||||
| */ | |||||
| @Override | |||||
| public abstract CameraEnum cameraType(); | |||||
| /** | |||||
| * 日志品牌名:取枚举 name 字段(中文名),如 大华/海康/朗驰。 | |||||
| */ | |||||
| @Override | |||||
| protected String brandName() { | |||||
| return cameraType().getName(); | |||||
| } | |||||
| /** | |||||
| * 抓图文件名前缀:取枚举 value,如 dahua/hik/linc。 | |||||
| */ | |||||
| protected String fileFlag() { | |||||
| return cameraType().getValue(); | |||||
| } | |||||
| /** | |||||
| * 单次 SDK 抓图,失败返回 null 或抛异常,由模板负责重试与落盘。 | |||||
| */ | |||||
| protected abstract byte[] doCapture(Camera camera) throws Exception; | |||||
| /** | |||||
| * 单次 SDK 预置位跳转,成功 true / 失败 false。 | |||||
| */ | |||||
| protected abstract boolean doGotoPreset(Camera camera) throws Exception; | |||||
| /** | |||||
| * 单次 SDK 测温,失败返回 null 或抛异常,由模板负责重试。各品牌必须实现。 | |||||
| */ | |||||
| protected abstract TemperatureData doMeasureTemperature(Camera camera) throws Exception; | |||||
| /** | |||||
| * Digest 认证抓图 URL 模板;不支持 Digest 兜底的品牌返回 null。 | |||||
| */ | |||||
| protected String digestUrlTemplate() { | |||||
| return null; | |||||
| } | |||||
| /** | |||||
| * 是否支持 DLT664 抓图,默认不支持,支持的品牌重写为 true。 | |||||
| */ | |||||
| protected boolean supportsDlt664() { | |||||
| return false; | |||||
| } | |||||
| // ------------------------------------------------------------------ | |||||
| // 抓图模板 | |||||
| // ------------------------------------------------------------------ | |||||
| @Override | |||||
| public byte[] capture(Camera camera) { | |||||
| return withConcurrencyControl(camera, () -> captureWithRetry(camera)); | |||||
| } | |||||
| private byte[] captureWithRetry(Camera camera) { | |||||
| Path fullPath = getFullPath(fileFlag(), camera.getIp(), camera.getChannel()); | |||||
| // 抓图与预置位/测温共用统一重试模板:取图+落盘在一次尝试内,返回 null 触发重试 | |||||
| byte[] imageBytes = withRetry(camera, "抓图", () -> { | |||||
| byte[] data = doCapture(camera); | |||||
| if (data == null) { | |||||
| return null; | |||||
| } | |||||
| Files.write(fullPath, data); | |||||
| log.info("[{}]抓图成功:{}", brandName(), fullPath); | |||||
| return data; | |||||
| }); | |||||
| if (imageBytes != null) { | |||||
| return imageBytes; | |||||
| } | |||||
| // SDK 抓图全部失败后,尝试 Digest 认证抓图兜底(3参静态方法,避免重复进入并发控制) | |||||
| String digestUrlTemplate = digestUrlTemplate(); | |||||
| if (digestUrlTemplate != null) { | |||||
| byte[] digestBytes = captureDigest(camera, digestUrlTemplate, fullPath); | |||||
| if (digestBytes != null) { | |||||
| log.info("[{}]digest抓图成功,图片地址:{}", brandName(), fullPath); | |||||
| return digestBytes; | |||||
| } | |||||
| } | |||||
| writeCaptureFailedImage(fullPath); | |||||
| log.error("[{}]抓图失败,图片地址:{}", brandName(), fullPath); | |||||
| return null; | |||||
| } | |||||
| /** | |||||
| * Digest 认证抓图入口;不支持 Digest 的品牌返回 null。 | |||||
| */ | |||||
| public byte[] captureDigest(Camera camera) { | |||||
| String digestUrlTemplate = digestUrlTemplate(); | |||||
| if (digestUrlTemplate == null) { | |||||
| log.warn("[{}]不支持Digest认证抓图", brandName()); | |||||
| return null; | |||||
| } | |||||
| Path fullPath = getFullPath(fileFlag() + "_digest", camera.getIp(), camera.getChannel()); | |||||
| return withConcurrencyControl(camera, | |||||
| () -> captureDigest(camera, digestUrlTemplate, fullPath)); | |||||
| } | |||||
| /** | |||||
| * DLT664 抓图:不支持时返回 null;支持的品牌其 doCapture 已内嵌温度数据,直接复用抓图模板。 | |||||
| */ | |||||
| @Override | |||||
| public byte[] captureDlt664(Camera camera) { | |||||
| if (!supportsDlt664()) { | |||||
| log.warn("[{}]不支持DLT664抓图", brandName()); | |||||
| return null; | |||||
| } | |||||
| return capture(camera); | |||||
| } | |||||
| // ------------------------------------------------------------------ | |||||
| // 云台模板 | |||||
| // ------------------------------------------------------------------ | |||||
| @Override | |||||
| public boolean gotoPreset(Camera camera) { | |||||
| return withConcurrencyControl(camera, () -> { | |||||
| Boolean result = withRetry(camera, "预置位跳转", () -> doGotoPreset(camera)); | |||||
| return Boolean.TRUE.equals(result); | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * 组合操作模板:一次预置位跳转 + 一次测温视为一次尝试,任一步失败则整对重试。 | |||||
| */ | |||||
| @Override | |||||
| public TemperatureData gotoPresetAndMeasure(Camera ptzCamera, Camera thermalCamera) { | |||||
| Camera lockCamera = ptzCamera != null ? ptzCamera : thermalCamera; | |||||
| return withConcurrencyControl(lockCamera, | |||||
| () -> withRetry(lockCamera, "预置位跳转+测温", | |||||
| () -> doGotoPresetAndMeasure(ptzCamera, thermalCamera))); | |||||
| } | |||||
| /** | |||||
| * 单次“预置位跳转 + 测温”,普通品牌无需重写;双模在复合实现中分别委托两个品牌。 | |||||
| */ | |||||
| protected TemperatureData doGotoPresetAndMeasure(Camera ptzCamera, Camera thermalCamera) throws Exception { | |||||
| if (!doGotoPreset(ptzCamera)) { | |||||
| throw new RuntimeException("预置位跳转失败"); | |||||
| } | |||||
| if (presetSettleMillis > 0) { | |||||
| Thread.sleep(presetSettleMillis); | |||||
| } | |||||
| TemperatureData data = doMeasureTemperature(thermalCamera); | |||||
| if (data == null) { | |||||
| throw new RuntimeException("测温返回空"); | |||||
| } | |||||
| return data; | |||||
| } | |||||
| // ------------------------------------------------------------------ | |||||
| // 测温模板 | |||||
| // ------------------------------------------------------------------ | |||||
| @Override | |||||
| public TemperatureData measureTemperature(Camera camera) { | |||||
| return withConcurrencyControl(camera, | |||||
| () -> withRetry(camera, "测温", () -> doMeasureTemperature(camera))); | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,47 @@ | |||||
| package com.inspect.nvr.service.camera; | |||||
| import com.inspect.nvr.domain.Infrared.Camera; | |||||
| import com.inspect.nvr.domain.Infrared.TemperatureData; | |||||
| import com.inspect.nvr.enums.CameraEnum; | |||||
| /** | |||||
| * 品牌相机统一能力接口:抓图、预置位跳转、测温。 | |||||
| * 品牌公共流程见 AbstractCameraService;双模 LINC_HIK 见 LincHikCompositeCameraService。 | |||||
| */ | |||||
| public interface BrandCameraService { | |||||
| /** | |||||
| * 该实现对应的品牌枚举,工厂注册表以此为依据。 | |||||
| */ | |||||
| CameraEnum cameraType(); | |||||
| /** | |||||
| * 抓图。SDK 重试与 Digest 兜底全部失败后返回 null(磁盘上仍写入兜底占位图)。 | |||||
| */ | |||||
| byte[] capture(Camera camera); | |||||
| /** | |||||
| * 跳转预置位。全部重试失败返回 false。 | |||||
| */ | |||||
| boolean gotoPreset(Camera camera); | |||||
| /** | |||||
| * 组合操作:跳转预置位一次,等待到位后测温一次,两步视为一个整体重试。 | |||||
| * 单目品牌两个参数传同一个 Camera;双模时 ptzCamera 传可见光相机、thermalCamera 传热成像相机。 | |||||
| * 全部重试失败返回 null。 | |||||
| */ | |||||
| TemperatureData gotoPresetAndMeasure(Camera ptzCamera, Camera thermalCamera); | |||||
| /** | |||||
| * DLT664 红外热成像图抓图(可选能力,默认不支持)。 | |||||
| * 支持的品牌重写;不支持或抓图失败时返回 null。 | |||||
| */ | |||||
| default byte[] captureDlt664(Camera camera) { | |||||
| return null; | |||||
| } | |||||
| /** | |||||
| * 测温。品牌暂未实现或全部重试失败时返回 null。 | |||||
| */ | |||||
| TemperatureData measureTemperature(Camera camera); | |||||
| } | |||||
| @ -0,0 +1,54 @@ | |||||
| package com.inspect.nvr.service.camera; | |||||
| import com.inspect.nvr.enums.CameraEnum; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| import org.springframework.stereotype.Component; | |||||
| import java.util.HashMap; | |||||
| import java.util.List; | |||||
| import java.util.Map; | |||||
| /** | |||||
| * 品牌相机服务工厂:按 {@link CameraEnum#getCode()} 选择实现。 | |||||
| * Spring 自动收集所有 {@link BrandCameraService} 建立注册表; | |||||
| * 未知类型回退海康实现,保持历史默认行为。 | |||||
| */ | |||||
| @Slf4j | |||||
| @Component | |||||
| public class CameraServiceFactory { | |||||
| private final Map<Integer, BrandCameraService> registry = new HashMap<>(); | |||||
| private final HikCameraService defaultService; | |||||
| public CameraServiceFactory(List<BrandCameraService> services, HikCameraService defaultService) { | |||||
| for (BrandCameraService service : services) { | |||||
| Integer code = service.cameraType().getCode(); | |||||
| BrandCameraService previous = registry.put(code, service); | |||||
| if (previous != null) { | |||||
| throw new IllegalStateException("重复注册的相机品牌服务,cameraType=" + service.cameraType()); | |||||
| } | |||||
| log.info("注册品牌相机服务: {} -> {}", code, service.getClass().getSimpleName()); | |||||
| } | |||||
| this.defaultService = defaultService; | |||||
| } | |||||
| /** | |||||
| * 按枚举获取品牌服务。 | |||||
| */ | |||||
| public BrandCameraService get(CameraEnum cameraType) { | |||||
| return get(cameraType.getCode()); | |||||
| } | |||||
| /** | |||||
| * 按 cameraType 编码获取品牌服务,未知类型回退海康。 | |||||
| */ | |||||
| public BrandCameraService get(int cameraType) { | |||||
| BrandCameraService service = registry.get(cameraType); | |||||
| if (service == null) { | |||||
| log.warn("未知摄像头类型: {},回退到海康实现", cameraType); | |||||
| return defaultService; | |||||
| } | |||||
| return service; | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,204 @@ | |||||
| package com.inspect.nvr.service.camera; | |||||
| import com.inspect.nvr.service.camera.login.DahuaLoginService; | |||||
| import com.inspect.nvr.daHuaCarme.jna.NetSDKLib; | |||||
| import com.inspect.nvr.daHuaCarme.jna.dahua.ToolKits; | |||||
| import com.inspect.nvr.daHuaCarme.utils.jna.DahuaUtils; | |||||
| import com.inspect.nvr.domain.Infrared.Camera; | |||||
| import com.inspect.nvr.domain.Infrared.TemperatureData; | |||||
| import com.inspect.nvr.enums.CameraEnum; | |||||
| import com.sun.jna.Pointer; | |||||
| import com.sun.jna.ptr.IntByReference; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| import org.springframework.stereotype.Service; | |||||
| import javax.annotation.Resource; | |||||
| import java.util.concurrent.CompletableFuture; | |||||
| import java.util.concurrent.ConcurrentHashMap; | |||||
| import java.util.concurrent.TimeUnit; | |||||
| import java.util.concurrent.TimeoutException; | |||||
| import java.util.concurrent.atomic.AtomicInteger; | |||||
| /** | |||||
| * 大华设备SDK服务 | |||||
| * 同一个IP,同一个通道,串行 | |||||
| * 同一个IP,不同通道,最大4个线程并发 | |||||
| * 不同IP,并发抓图,不限制 | |||||
| */ | |||||
| @Slf4j | |||||
| @Service | |||||
| public class DahuaCameraService extends AbstractCameraService { | |||||
| /** | |||||
| * Digest认证抓图URL: | |||||
| * http://<host>/cgi-bin/snapshot.cgi?channel=<channel>&subtype=<subtype> | |||||
| * subtype: 0-主码流 1-子码流 | |||||
| */ | |||||
| private static final String DIGEST_URL_TEMPLATE = "http://%s/cgi-bin/snapshot.cgi?channel=%d&subtype=1"; | |||||
| // Key: loginId+CmdSerial(登录句柄+流水号),Value: CompletableFuture(用于通知调用线程) | |||||
| private static final ConcurrentHashMap<String, CompletableFuture<byte[]>> PENDING_REQUESTS = new ConcurrentHashMap<>(); | |||||
| private static final fCaptureReceiveCB CAPTURE_RECEIVE_CB = new fCaptureReceiveCB(); | |||||
| // 全局流水号生成器 | |||||
| private static final AtomicInteger SERIAL_COUNTER = new AtomicInteger(1); | |||||
| @Resource | |||||
| private NetSDKLib dhNetSDK; | |||||
| @Resource | |||||
| private DahuaLoginService dahuaLoginService; | |||||
| // CmdSerial请求序列号,有效值范围 0~65535,超出范围会被截断 | |||||
| public static int nextSerial() { | |||||
| return SERIAL_COUNTER.updateAndGet(current -> (current + 1) & 0xFFFF); | |||||
| } | |||||
| @Override | |||||
| public CameraEnum cameraType() { | |||||
| return CameraEnum.DAHUA; | |||||
| } | |||||
| @Override | |||||
| protected String digestUrlTemplate() { | |||||
| return DIGEST_URL_TEMPLATE; | |||||
| } | |||||
| @Override | |||||
| protected byte[] doCapture(Camera camera) { | |||||
| return snapPictureEx(camera); | |||||
| } | |||||
| /** | |||||
| * 预置位跳转 | |||||
| */ | |||||
| @Override | |||||
| protected boolean doGotoPreset(Camera camera) { | |||||
| NetSDKLib.LLong loginHandle = dahuaLoginService.login(camera); | |||||
| log.info("[" + brandName() + "]预置位跳转,IP={},LoginHandle={},pointNum={}", camera.getIp(), loginHandle, camera.getPointNum()); | |||||
| boolean result = dhNetSDK.CLIENT_DHPTZControlEx2(loginHandle, camera.getChannel() - 1, | |||||
| DahuaUtils.PTZCommand("GOTO_PRESET"), 0, camera.getPointNum(), 0, 0, null); | |||||
| if (result) { | |||||
| log.info("[" + brandName() + "]CLIENT_DHPTZControlEx success,pointNum={}", camera.getPointNum()); | |||||
| } else { | |||||
| log.error("[" + brandName() + "]CLIENT_DHPTZControlEx Failed!!{}", ToolKits.getErrorCodePrint(dhNetSDK.CLIENT_GetLastError())); | |||||
| } | |||||
| return result; | |||||
| } | |||||
| /** | |||||
| * 大华实时测温(蓝本:DahuaServiceImpl#StartRemote)。 | |||||
| * 通过 CLIENT_QueryDevInfo 查询测温规则区域温度。 | |||||
| */ | |||||
| @Override | |||||
| protected TemperatureData doMeasureTemperature(Camera camera) { | |||||
| log.info("[" + brandName() + "]实时测温开始,ip={}, channel={}, presetId={}", camera.getIp(), camera.getChannel(), camera.getPresetId()); | |||||
| NetSDKLib.LLong loginHandle = dahuaLoginService.login(camera); | |||||
| if (loginHandle == null) { | |||||
| log.error("[" + brandName() + "]测温失败:登录句柄为空,ip={}", camera.getIp()); | |||||
| return null; | |||||
| } | |||||
| // 初始化输入结构体,设置条件参数 | |||||
| NetSDKLib.NET_IN_RADIOMETRY_GETTEMPER netIn = new NetSDKLib.NET_IN_RADIOMETRY_GETTEMPER(); | |||||
| netIn.stCondition.nPresetId = camera.getPresetId(); | |||||
| netIn.stCondition.nRuleId = camera.getRuleId(); | |||||
| netIn.stCondition.nMeterType = NetSDKLib.NET_RADIOMETRY_METERTYPE.NET_RADIOMETRY_METERTYPE_AREA; | |||||
| netIn.stCondition.nChannel = camera.getChannel() - 1; | |||||
| // 输出结构体 | |||||
| NetSDKLib.NET_OUT_RADIOMETRY_GETTEMPER netOut = new NetSDKLib.NET_OUT_RADIOMETRY_GETTEMPER(); | |||||
| netOut.stTempInfo = new NetSDKLib.NET_RADIOMETRYINFO(); | |||||
| netIn.write(); | |||||
| netOut.write(); | |||||
| boolean success = dhNetSDK.CLIENT_QueryDevInfo( | |||||
| loginHandle, | |||||
| NetSDKLib.NET_QUERY_DEV_RADIOMETRY_TEMPER, | |||||
| netIn.getPointer(), | |||||
| netOut.getPointer(), | |||||
| null, | |||||
| 5000 | |||||
| ); | |||||
| if (!success) { | |||||
| log.error("[" + brandName() + "]获取设备参数失败,错误码:{}", ToolKits.getErrorCodePrint(dhNetSDK.CLIENT_GetLastError())); | |||||
| return null; | |||||
| } | |||||
| // 将本地内存同步到 Java 字段 | |||||
| netOut.read(); | |||||
| NetSDKLib.NET_RADIOMETRYINFO stTempInfo = netOut.stTempInfo; | |||||
| log.info("[" + brandName() + "]测温结果:最高={}, 最低={}, 平均={}, 温差={}", | |||||
| stTempInfo.fTemperMax, stTempInfo.fTemperMin, stTempInfo.fTemperAver, stTempInfo.fTemperStd); | |||||
| return new TemperatureData( | |||||
| String.valueOf(stTempInfo.fTemperMax), | |||||
| String.valueOf(stTempInfo.fTemperMin), | |||||
| stTempInfo.fTemperAver, | |||||
| stTempInfo.fTemperStd, | |||||
| netIn.stCondition.nPresetId, | |||||
| netIn.stCondition.nRuleId | |||||
| ); | |||||
| } | |||||
| /** | |||||
| * 大华SDK抓图具体实现(异步) | |||||
| * 通过CompletableFuture实现异步回调通知 | |||||
| */ | |||||
| private byte[] snapPictureEx(Camera camera) { | |||||
| NetSDKLib.LLong loginID = dahuaLoginService.login(camera); | |||||
| NetSDKLib.SNAP_PARAMS snapParams = new NetSDKLib.SNAP_PARAMS(); | |||||
| snapParams.Channel = camera.getChannel() - 1; // 通道号从0开始 | |||||
| snapParams.mode = 0; // 抓图模式:0-单次抓 | |||||
| snapParams.Quality = 3; | |||||
| snapParams.InterSnap = 0; | |||||
| int mySerialId = nextSerial(); | |||||
| snapParams.CmdSerial = mySerialId; | |||||
| IntByReference reference = new IntByReference(0); | |||||
| // 设置异步抓图回调函数 | |||||
| dhNetSDK.CLIENT_SetSnapRevCallBack(CAPTURE_RECEIVE_CB, null); | |||||
| String requestKey = loginID.longValue() + ":" + mySerialId; | |||||
| CompletableFuture<byte[]> future = new CompletableFuture<>(); | |||||
| PENDING_REQUESTS.put(requestKey, future); | |||||
| final int TIMEOUT_SEC = 5; | |||||
| try { | |||||
| log.info("[" + brandName() + "]开始抓图,LoginID={},IP={},Channel={},Serial={}", loginID, camera.getIp(), camera.getChannel(), mySerialId); | |||||
| boolean isCaptured = dhNetSDK.CLIENT_SnapPictureEx(loginID, snapParams, reference); | |||||
| if (!isCaptured) { | |||||
| String errorMsg = ToolKits.getErrorCodePrint(dhNetSDK.CLIENT_GetLastError()); | |||||
| throw new RuntimeException("SDK抓图失败:" + errorMsg); | |||||
| } | |||||
| return future.get(TIMEOUT_SEC, TimeUnit.SECONDS); | |||||
| } catch (TimeoutException e) { | |||||
| log.error("[" + brandName() + "]抓图超时:在 {} 秒内未收到设备回调,requestKey={}", TIMEOUT_SEC, requestKey); | |||||
| } catch (Exception e) { | |||||
| log.error("[" + brandName() + "]抓图异常:", e); | |||||
| } finally { | |||||
| PENDING_REQUESTS.remove(requestKey); | |||||
| } | |||||
| return null; | |||||
| } | |||||
| /** | |||||
| * CLIENT_SnapPictureEx异步抓图回调函数重写 | |||||
| */ | |||||
| public static class fCaptureReceiveCB implements NetSDKLib.fSnapRev { | |||||
| @Override | |||||
| public void invoke(NetSDKLib.LLong lLoginID, Pointer pBuf, int RevLen, int EncodeType, int CmdSerial, Pointer dwUser) { | |||||
| // 检查是否有等待该流水号的请求 | |||||
| String requestKey = lLoginID.longValue() + ":" + CmdSerial; | |||||
| CompletableFuture<byte[]> future = PENDING_REQUESTS.remove(requestKey); | |||||
| if (future != null) { | |||||
| log.info("[" + CameraEnum.DAHUA.getName() + "]匹配到抓图回调,LoginID={}, Serial={}", lLoginID, CmdSerial); | |||||
| if (pBuf != null && RevLen > 0) { | |||||
| // 读取图片数据 | |||||
| byte[] data = pBuf.getByteArray(0, RevLen); | |||||
| // 完成Future,通知主线程 | |||||
| future.complete(data); | |||||
| } else { | |||||
| future.completeExceptionally(new RuntimeException("Empty image data")); | |||||
| } | |||||
| } else { | |||||
| // 可能是由于超时已经被移除了,或者是其他类型的抓图 | |||||
| log.error("[" + CameraEnum.DAHUA.getName() + "]收到未匹配的抓图回调,LoginID={}, Serial={}", lLoginID, CmdSerial); | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,78 @@ | |||||
| package com.inspect.nvr.service.camera; | |||||
| import com.inspect.nvr.domain.Infrared.Camera; | |||||
| import com.inspect.nvr.domain.Infrared.TemperatureData; | |||||
| import com.inspect.nvr.enums.CameraEnum; | |||||
| import org.springframework.stereotype.Component; | |||||
| import javax.annotation.Resource; | |||||
| /** | |||||
| * LINC_HIK(3) 双模相机复合实现。 | |||||
| * 海康部分:可见光、云台;朗驰部分:热成像抓图、测温。 | |||||
| * 复用 AbstractCameraSupport 的统一重试;通道锁借用海康服务的锁表, | |||||
| * 防止同设备同通道的其他预置位调用插队转动云台。 | |||||
| */ | |||||
| @Component | |||||
| public class LincHikCompositeCameraService extends AbstractCameraSupport implements BrandCameraService { | |||||
| @Resource | |||||
| private HikCameraService hikCameraService; | |||||
| @Resource | |||||
| private LincseekCameraService lincseekCameraService; | |||||
| @Override | |||||
| public CameraEnum cameraType() { | |||||
| return CameraEnum.LINC_HIK; | |||||
| } | |||||
| @Override | |||||
| protected String brandName() { | |||||
| return CameraEnum.LINC_HIK.getName(); | |||||
| } | |||||
| @Override | |||||
| public byte[] capture(Camera camera) { | |||||
| return lincseekCameraService.capture(camera); | |||||
| } | |||||
| @Override | |||||
| public boolean gotoPreset(Camera camera) { | |||||
| return hikCameraService.gotoPreset(camera); | |||||
| } | |||||
| @Override | |||||
| public TemperatureData measureTemperature(Camera camera) { | |||||
| return lincseekCameraService.measureTemperature(camera); | |||||
| } | |||||
| @Override | |||||
| public byte[] captureDlt664(Camera camera) { | |||||
| return lincseekCameraService.captureDlt664(camera); | |||||
| } | |||||
| /** | |||||
| * 双模组合操作:云台走海康、测温走朗驰;直接调单次钩子,避免两个服务各自内部重试叠加。 | |||||
| * 复用海康服务的通道锁(锁可见光相机的 ip+channel),5s 到位等待与整对重试都在锁内。 | |||||
| */ | |||||
| @Override | |||||
| public TemperatureData gotoPresetAndMeasure(Camera ptzCamera, Camera thermalCamera) { | |||||
| return hikCameraService.withConcurrencyControl(ptzCamera, | |||||
| () -> withRetry(ptzCamera, "预置位跳转+测温", | |||||
| () -> doPresetAndMeasure(ptzCamera, thermalCamera))); | |||||
| } | |||||
| private TemperatureData doPresetAndMeasure(Camera ptzCamera, Camera thermalCamera) throws Exception { | |||||
| if (!hikCameraService.doGotoPreset(ptzCamera)) { | |||||
| throw new RuntimeException("预置位跳转失败"); | |||||
| } | |||||
| if (presetSettleMillis > 0) { | |||||
| Thread.sleep(presetSettleMillis); | |||||
| } | |||||
| TemperatureData data = lincseekCameraService.doMeasureTemperature(thermalCamera); | |||||
| if (data == null) { | |||||
| throw new RuntimeException("测温返回空"); | |||||
| } | |||||
| return data; | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,193 @@ | |||||
| package com.inspect.nvr.service.camera; | |||||
| import com.inspect.nvr.domain.Infrared.Camera; | |||||
| import com.inspect.nvr.domain.Infrared.TemperatureData; | |||||
| import com.inspect.nvr.enums.CameraEnum; | |||||
| import com.inspect.nvr.jna.lincseek.IRNetSDK; | |||||
| import com.inspect.nvr.jna.lincseek.IRNetSDKCallback; | |||||
| import com.inspect.nvr.jna.lincseek.IRNetSDKConst; | |||||
| import com.inspect.nvr.jna.lincseek.IRNetSDKStruct; | |||||
| import com.inspect.nvr.jna.lincseek.IRNetSDKStruct.IRNETHANDLE; | |||||
| import com.inspect.nvr.service.camera.login.LincseekLoginService; | |||||
| import com.inspect.nvr.utils.DLT664Service; | |||||
| import com.sun.jna.Memory; | |||||
| import com.sun.jna.Pointer; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| import org.springframework.stereotype.Service; | |||||
| import javax.annotation.Resource; | |||||
| import java.util.concurrent.CompletableFuture; | |||||
| import java.util.concurrent.ConcurrentHashMap; | |||||
| import java.util.concurrent.TimeUnit; | |||||
| import java.util.concurrent.atomic.AtomicInteger; | |||||
| import static com.inspect.nvr.jna.lincseek.IRNetSDKConst.MessageOpt.MESSAGE_CMD_SET_CAPTURETYPE; | |||||
| import static com.inspect.nvr.jna.lincseek.IRNetSDKConst.VSNET_CAPTURE_TYPE_LCR; | |||||
| @Slf4j | |||||
| @Service | |||||
| public class LincseekCameraService extends AbstractCameraService { | |||||
| // [新增]全局流水号生成器 | |||||
| private static final AtomicInteger SERIAL_COUNTER = new AtomicInteger(1); | |||||
| // Key: CmdSerial (流水号), Value: CompletableFuture (用于通知调用线程) | |||||
| private static final ConcurrentHashMap<String, CompletableFuture<byte[]>> PENDING_REQUESTS = new ConcurrentHashMap<>(); | |||||
| @Resource | |||||
| private IRNetSDK irNetSDK; | |||||
| @Resource | |||||
| private LincseekLoginService lincseekLoginService; | |||||
| public static int nextSerial() { | |||||
| return SERIAL_COUNTER.updateAndGet(current -> (current + 1) & 0xFFFF); | |||||
| } | |||||
| public static Pointer stringToPointer(String str) { | |||||
| if (str == null) return null; | |||||
| byte[] bytes = str.getBytes(); // 1. 转换为字节数组 | |||||
| Pointer pointer = new Memory(bytes.length + 1); // 2. 分配内存(+1 用于存放 \0) | |||||
| pointer.write(0, bytes, 0, bytes.length); // 3. 写入字节 | |||||
| pointer.setByte(bytes.length, (byte) 0); // 4. 追加空字节(Null-termination) | |||||
| return pointer; | |||||
| } | |||||
| @Override | |||||
| public CameraEnum cameraType() { | |||||
| return CameraEnum.LINCSEEK; | |||||
| } | |||||
| @Override | |||||
| protected boolean supportsDlt664() { | |||||
| return true; | |||||
| } | |||||
| @Override | |||||
| protected byte[] doCapture(Camera camera) { | |||||
| return jpegCapSingle(camera); | |||||
| } | |||||
| /** | |||||
| * 预置位跳转 | |||||
| */ | |||||
| @Override | |||||
| protected boolean doGotoPreset(Camera camera) { | |||||
| IRNETHANDLE handle = lincseekLoginService.login(camera); | |||||
| boolean result = irNetSDK.IRNET_ClientPTZCtrl(handle, IRNetSDKConst.PTZ_GOTOPOINT, camera.getPointNum(), 0, null, 0); | |||||
| log.info("[" + brandName() + "]跳转预置位{},结果: {}", camera.getPointNum(), result); | |||||
| return result; | |||||
| } | |||||
| private byte[] jpegCapSingle(Camera camera) { | |||||
| IRNetSDKCallback.JpegDataCallback jpegCb = (hHandle, mCh, pBuffer, size, extraData, userdata) -> { | |||||
| String requestKey = userdata.getString(0); | |||||
| CompletableFuture<byte[]> future = PENDING_REQUESTS.remove(requestKey); | |||||
| if (pBuffer != null && future != null) { | |||||
| log.info("[" + brandName() + "]匹配到抓图回调 (requestKey={}, 通道={}, 大小={}B)", requestKey, mCh, size); | |||||
| byte[] jpgBytes = pBuffer.getByteArray(0, size); | |||||
| if (extraData != null) { | |||||
| IRNetSDKStruct.FFF_TEMPERATURE_DATA tempData = new IRNetSDKStruct.FFF_TEMPERATURE_DATA(extraData); | |||||
| short w = tempData.width; | |||||
| short h = tempData.height; | |||||
| int pixelCount = w * h; | |||||
| if (tempData.temperatueData != null && pixelCount > 0) { | |||||
| log.info("[" + brandName() + "]获取到测温矩阵,w:{}, h:{}", w, h); | |||||
| float[] temps = tempData.temperatueData.getFloatArray(0, pixelCount); | |||||
| float[][] irData = new float[h][w]; | |||||
| for (int row = 0; row < h; row++) { | |||||
| System.arraycopy(temps, row * w, irData[row], 0, w); | |||||
| } | |||||
| try { | |||||
| jpgBytes = DLT664Service.generate(jpgBytes, irData, cameraType().name()); | |||||
| log.info("[" + brandName() + "]DLT664文件格式生成成功!"); | |||||
| } catch (Exception e) { | |||||
| log.error("[" + brandName() + "]DLT664文件格式生成失败!"); | |||||
| } | |||||
| } | |||||
| } | |||||
| future.complete(jpgBytes); | |||||
| } else { | |||||
| // 可能是由于超时已经被移除了,或者是其他类型的抓图 | |||||
| log.error("[{}]未匹配的抓图回调,requestKey={}", brandName(), requestKey); | |||||
| } | |||||
| }; | |||||
| int mySerialId = nextSerial(); | |||||
| String requestKey = ":" + mySerialId; | |||||
| Pointer userData = stringToPointer(requestKey); | |||||
| IRNETHANDLE capHandle = irNetSDK.IRNET_ClientJpegCapStart("video server", camera.getIp(), camera.getUserName(), camera.getPassword(), (short) camera.getPort(), jpegCb, userData); | |||||
| if (!IRNETHANDLE.INVALID_HANDLE_VALUE.equals(capHandle)) { | |||||
| CompletableFuture<byte[]> future = new CompletableFuture<>(); | |||||
| log.info("capHandle: {}, {}, channel: {}", capHandle.toString(), capHandle.hashCode(), camera.getChannel()); | |||||
| PENDING_REQUESTS.put(requestKey, future); | |||||
| final int TIMEOUT_SEC = 10; | |||||
| try { | |||||
| Memory cpty = new Memory(2); | |||||
| cpty.setShort(0, (short) VSNET_CAPTURE_TYPE_LCR); | |||||
| IRNETHANDLE msgHandle = irNetSDK.IRNET_ClientMessageOpen("video server", camera.getIp(), camera.getUserName(), camera.getPassword(), (short) camera.getPort()); | |||||
| if (!IRNETHANDLE.INVALID_HANDLE_VALUE.equals(msgHandle)) { | |||||
| int ret = irNetSDK.IRNET_ClientMessageOpt(msgHandle, MESSAGE_CMD_SET_CAPTURETYPE, 0, cpty, null, null); | |||||
| if (ret == 0) { | |||||
| log.error("CaptureType set failed!"); | |||||
| } | |||||
| irNetSDK.IRNET_ClientMessageClose(msgHandle); | |||||
| } | |||||
| boolean isCaptured = irNetSDK.IRNET_ClientJpegCapSingle(capHandle, camera.getChannel(), 100); | |||||
| if (!isCaptured) { | |||||
| throw new RuntimeException("SDK抓图失败"); | |||||
| } | |||||
| return future.get(TIMEOUT_SEC, TimeUnit.SECONDS); | |||||
| } catch (Exception e) { | |||||
| throw new RuntimeException("[" + brandName() + "]抓图异常:", e); | |||||
| } finally { | |||||
| PENDING_REQUESTS.remove(requestKey); | |||||
| irNetSDK.IRNET_ClientJpegCapStop(capHandle); | |||||
| } | |||||
| } | |||||
| return null; | |||||
| } | |||||
| @Override | |||||
| protected TemperatureData doMeasureTemperature(Camera camera) { | |||||
| return getTempValueEx(camera); | |||||
| } | |||||
| /** | |||||
| * 获取最高温、最低温、平均温 | |||||
| */ | |||||
| private TemperatureData getTempValueEx(Camera camera) { | |||||
| IRNETHANDLE msgHandle = irNetSDK.IRNET_ClientMessageOpen("video server", camera.getIp(), camera.getUserName(), camera.getPassword(), (short) camera.getPort()); | |||||
| try { | |||||
| if (!IRNETHANDLE.INVALID_HANDLE_VALUE.equals(msgHandle)) { | |||||
| IRNetSDKStruct.VSNET_TEMP_VALUE_EX temps = new IRNetSDKStruct.VSNET_TEMP_VALUE_EX(); | |||||
| temps.write(); | |||||
| int r = irNetSDK.IRNET_ClientMessageOpt(msgHandle, IRNetSDKConst.MessageOpt.MESSAGE_CMD_GET_TEMPVALUE_EX, camera.getChannel(), temps.getPointer(), null, null); | |||||
| if (r == 1) { | |||||
| temps.read(); | |||||
| float maxTemp = temps.m_maxtempinfo.m_temp_value; | |||||
| int maxX = temps.m_maxtempinfo.m_temp_x; | |||||
| int maxY = temps.m_maxtempinfo.m_temp_y; | |||||
| float minTemp = temps.m_mintempinfo.m_temp_value; | |||||
| int minX = temps.m_mintempinfo.m_temp_x; | |||||
| int minY = temps.m_mintempinfo.m_temp_y; | |||||
| float avgTemp = temps.m_avgtempinfo; | |||||
| log.info("[" + brandName() + "]测温结果: 最高={}℃({},{}), 最低={}℃({},{}), 平均={}℃", | |||||
| maxTemp, maxX, maxY, minTemp, minX, minY, avgTemp); | |||||
| return new TemperatureData( | |||||
| String.valueOf(maxTemp), | |||||
| String.valueOf(minTemp), | |||||
| avgTemp, | |||||
| 0, | |||||
| camera.getChannel(), | |||||
| -1 | |||||
| ); | |||||
| } | |||||
| } | |||||
| } catch (Exception e) { | |||||
| log.error("[" + brandName() + "]测温异常:", e); | |||||
| } finally { | |||||
| irNetSDK.IRNET_ClientMessageClose(msgHandle); | |||||
| } | |||||
| return null; | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,114 @@ | |||||
| package com.inspect.nvr.service.camera.login; | |||||
| import com.github.benmanes.caffeine.cache.Cache; | |||||
| import com.github.benmanes.caffeine.cache.Caffeine; | |||||
| import com.github.benmanes.caffeine.cache.RemovalCause; | |||||
| import com.inspect.nvr.domain.Infrared.Camera; | |||||
| import com.inspect.nvr.enums.CameraEnum; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| import java.util.concurrent.TimeUnit; | |||||
| /** | |||||
| * 品牌相机登录会话服务抽象基类。 | |||||
| * 统一管理会话句柄缓存(10分钟未访问自动过期并登出)、登出失败记录, | |||||
| * 子类只需实现品牌 SDK 的登录/登出差异钩子。 | |||||
| */ | |||||
| @Slf4j | |||||
| public abstract class AbstractLoginService<H> { | |||||
| /** | |||||
| * 会话缓存:10分钟未被访问即过期;仅过期/容量淘汰时回调 SDK 登出。 | |||||
| */ | |||||
| private final Cache<String, H> sessionCache = Caffeine.newBuilder() | |||||
| .expireAfterAccess(10, TimeUnit.MINUTES) | |||||
| .removalListener((String ip, H handle, RemovalCause cause) -> { | |||||
| if (handle != null && (cause == RemovalCause.EXPIRED || cause == RemovalCause.SIZE)) { | |||||
| log.info("[{}]会话超时自动登出,ip: {},句柄: {}", brandName(), ip, handle); | |||||
| doLogout(ip, handle); | |||||
| } | |||||
| }).build(); | |||||
| /** | |||||
| * 品牌对应的摄像头枚举,与 AbstractCameraService 同一口径。 | |||||
| */ | |||||
| protected abstract CameraEnum cameraType(); | |||||
| /** | |||||
| * 日志品牌名:取枚举 name 字段(中文名),如海康/大华/朗驰。 | |||||
| */ | |||||
| protected String brandName() { | |||||
| return cameraType().getName(); | |||||
| } | |||||
| /** | |||||
| * 品牌 SDK 登录,失败抛 RuntimeException,不允许返回 null。 | |||||
| */ | |||||
| protected abstract H doSdkLogin(Camera camera); | |||||
| /** | |||||
| * 品牌 SDK 登出,成功返回 true。 | |||||
| */ | |||||
| protected abstract boolean doSdkLogout(H handle); | |||||
| /** | |||||
| * 最近一次 SDK 错误码;无错误码能力的品牌返回 -1。 | |||||
| */ | |||||
| protected abstract int lastErrorCode(); | |||||
| /** | |||||
| * 登录:命中缓存直接返回并刷新过期时间;未命中时 Caffeine.get 保证同一 IP 并发只执行一次 SDK 登录。 | |||||
| */ | |||||
| public H login(Camera camera) { | |||||
| String ip = camera.getIp(); | |||||
| H existHandle = sessionCache.getIfPresent(ip); | |||||
| if (existHandle != null) { | |||||
| log.info("[{}]登录命中缓存,ip: {},句柄: {}", brandName(), ip, existHandle); | |||||
| return existHandle; | |||||
| } | |||||
| return sessionCache.get(ip, key -> { | |||||
| H handle = doSdkLogin(camera); | |||||
| log.info("[{}]登录成功,ip:{},句柄:{}", brandName(), ip, handle); | |||||
| return handle; | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * 登出具体实现;SDK 登出失败时记录到 Redis。 | |||||
| */ | |||||
| public void doLogout(String ip, H handle) { | |||||
| if (handle == null) { | |||||
| return; | |||||
| } | |||||
| if (doSdkLogout(handle)) { | |||||
| log.info("[{}]登出成功,ip: {},句柄: {}", brandName(), ip, handle); | |||||
| return; | |||||
| } | |||||
| int errorCode = lastErrorCode(); | |||||
| log.error("[{}]登出失败,ip: {},句柄: {},错误码: {}", brandName(), ip, handle, errorCode); | |||||
| // recordLogoutError(ip, handle, errorCode); | |||||
| } | |||||
| /** | |||||
| * 主动登出指定 IP:仅移除缓存。移除原因为 EXPLICIT,不会触发自动登出回调(不额外调用 SDK 登出)。 | |||||
| */ | |||||
| public void logout(String ip) { | |||||
| sessionCache.invalidate(ip); | |||||
| } | |||||
| /** | |||||
| * 登出所有会话并清空缓存。 | |||||
| */ | |||||
| public void logoutAll() { | |||||
| sessionCache.asMap().forEach(this::doLogout); | |||||
| sessionCache.invalidateAll(); | |||||
| log.info("[{}]所有用户已登出", brandName()); | |||||
| } | |||||
| /** | |||||
| * 是否已登录(getIfPresent 同时刷新访问过期时间)。 | |||||
| */ | |||||
| public boolean isLoggedIn(String ip) { | |||||
| return sessionCache.getIfPresent(ip) != null; | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,54 @@ | |||||
| package com.inspect.nvr.service.camera.login; | |||||
| import com.inspect.nvr.daHuaCarme.jna.NetSDKLib; | |||||
| import com.inspect.nvr.daHuaCarme.jna.NetSDKLib.LLong; | |||||
| import com.inspect.nvr.domain.Infrared.Camera; | |||||
| import com.inspect.nvr.enums.CameraEnum; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| import org.springframework.beans.factory.annotation.Autowired; | |||||
| import org.springframework.stereotype.Service; | |||||
| import javax.annotation.Resource; | |||||
| /** | |||||
| * 大华登录服务:会话句柄为 CLIENT_LoginWithHighLevelSecurity 返回的 LLong。 | |||||
| */ | |||||
| @Slf4j | |||||
| @Service | |||||
| public class DahuaLoginService extends AbstractLoginService<LLong> { | |||||
| @Resource | |||||
| private NetSDKLib dhNetSDK; | |||||
| @Override | |||||
| protected CameraEnum cameraType() { | |||||
| return CameraEnum.DAHUA; | |||||
| } | |||||
| @Override | |||||
| protected LLong doSdkLogin(Camera camera) { | |||||
| NetSDKLib.NET_IN_LOGIN_WITH_HIGHLEVEL_SECURITY inParam = | |||||
| new NetSDKLib.NET_IN_LOGIN_WITH_HIGHLEVEL_SECURITY(); | |||||
| inParam.szIP = camera.getIp().getBytes(); | |||||
| inParam.nPort = camera.getPort(); | |||||
| inParam.szUserName = camera.getUserName().getBytes(); | |||||
| inParam.szPassword = camera.getPassword().getBytes(); | |||||
| NetSDKLib.NET_OUT_LOGIN_WITH_HIGHLEVEL_SECURITY outParam = | |||||
| new NetSDKLib.NET_OUT_LOGIN_WITH_HIGHLEVEL_SECURITY(); | |||||
| LLong loginID = dhNetSDK.CLIENT_LoginWithHighLevelSecurity(inParam, outParam); | |||||
| if (loginID == null || loginID.intValue() == 0) { | |||||
| throw new RuntimeException("登录失败,错误码:" + lastErrorCode()); | |||||
| } | |||||
| return loginID; | |||||
| } | |||||
| @Override | |||||
| protected boolean doSdkLogout(LLong loginID) { | |||||
| return dhNetSDK.CLIENT_Logout(loginID); | |||||
| } | |||||
| @Override | |||||
| protected int lastErrorCode() { | |||||
| return dhNetSDK.CLIENT_GetLastError(); | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,47 @@ | |||||
| package com.inspect.nvr.service.camera.login; | |||||
| import com.inspect.nvr.domain.Infrared.Camera; | |||||
| import com.inspect.nvr.enums.CameraEnum; | |||||
| import com.inspect.nvr.hikVision.utils.jna.HCNetSDK; | |||||
| import com.inspect.nvr.hikVision.utils.jna.HikVisionUtils; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| import org.springframework.beans.factory.annotation.Autowired; | |||||
| import org.springframework.stereotype.Service; | |||||
| /** | |||||
| * 海康登录服务:会话句柄为 NET_DVR_Login_V40 返回的 userID(int)。 | |||||
| */ | |||||
| @Slf4j | |||||
| @Service | |||||
| public class HikLoginService extends AbstractLoginService<Integer> { | |||||
| @Autowired | |||||
| private HCNetSDK hcNetSDK; | |||||
| @Override | |||||
| protected CameraEnum cameraType() { | |||||
| return CameraEnum.HIKVISION; | |||||
| } | |||||
| @Override | |||||
| protected Integer doSdkLogin(Camera camera) { | |||||
| HCNetSDK.NET_DVR_USER_LOGIN_INFO loginInfo = HikVisionUtils.login_V40( | |||||
| camera.getIp(), (short) camera.getPort(), camera.getUserName(), camera.getPassword()); | |||||
| HCNetSDK.NET_DVR_DEVICEINFO_V40 deviceInfo = new HCNetSDK.NET_DVR_DEVICEINFO_V40(); | |||||
| int userID = hcNetSDK.NET_DVR_Login_V40(loginInfo, deviceInfo); | |||||
| if (userID < 0) { | |||||
| throw new RuntimeException("登录失败,错误码:" + lastErrorCode()); | |||||
| } | |||||
| return userID; | |||||
| } | |||||
| @Override | |||||
| protected boolean doSdkLogout(Integer userID) { | |||||
| return hcNetSDK.NET_DVR_Logout(userID); | |||||
| } | |||||
| @Override | |||||
| protected int lastErrorCode() { | |||||
| return hcNetSDK.NET_DVR_GetLastError(); | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,53 @@ | |||||
| package com.inspect.nvr.service.camera.login; | |||||
| import com.inspect.nvr.domain.Infrared.Camera; | |||||
| import com.inspect.nvr.enums.CameraEnum; | |||||
| import com.inspect.nvr.jna.lincseek.IRNetSDK; | |||||
| import com.inspect.nvr.jna.lincseek.IRNetSDKStruct; | |||||
| import com.inspect.nvr.jna.lincseek.IRNetSDKStruct.IRNETHANDLE; | |||||
| import com.sun.jna.Pointer; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| import org.springframework.stereotype.Service; | |||||
| import javax.annotation.Resource; | |||||
| /** | |||||
| * 朗驰登录服务:会话句柄为 IRNET_ClientStart 返回的 IRNETHANDLE。 | |||||
| * 朗驰 SDK 无错误码接口,登出失败时错误码记 -1。 | |||||
| */ | |||||
| @Slf4j | |||||
| @Service | |||||
| public class LincseekLoginService extends AbstractLoginService<IRNETHANDLE> { | |||||
| @Resource | |||||
| private IRNetSDK irNetSDK; | |||||
| @Override | |||||
| protected CameraEnum cameraType() { | |||||
| return CameraEnum.LINCSEEK; | |||||
| } | |||||
| @Override | |||||
| protected IRNETHANDLE doSdkLogin(Camera camera) { | |||||
| String ip = camera.getIp(); | |||||
| IRNetSDKStruct.CHANNEL_CLIENTINFO info = IRNetSDKStruct.CHANNEL_CLIENTINFO.createWithUrl( | |||||
| "video server", camera.getUserName(), camera.getPassword(), | |||||
| (byte) camera.getChannel(), ip, null, null, 0, null, null); | |||||
| IRNETHANDLE handle = irNetSDK.IRNET_ClientStart(ip, info, (short) camera.getPort(), 0); | |||||
| if (IRNETHANDLE.INVALID_HANDLE_VALUE.equals(handle)) { | |||||
| throw new RuntimeException("[" + brandName() + "]登录失败, ip: " + ip); | |||||
| } | |||||
| return handle; | |||||
| } | |||||
| @Override | |||||
| protected boolean doSdkLogout(IRNETHANDLE handle) { | |||||
| return irNetSDK.IRNET_ClientStop(handle); | |||||
| } | |||||
| @Override | |||||
| protected int lastErrorCode() { | |||||
| // 朗驰 SDK 未提供错误码接口 | |||||
| return -1; | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,193 @@ | |||||
| package com.inspect.nvr.utils; | |||||
| import lombok.Data; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| import java.nio.ByteBuffer; | |||||
| import java.nio.ByteOrder; | |||||
| import java.nio.charset.StandardCharsets; | |||||
| import java.time.LocalDateTime; | |||||
| import java.time.format.DateTimeFormatter; | |||||
| import java.util.Arrays; | |||||
| /** | |||||
| * 规范参考 DL/T 664——2016 《带电设备红外诊断应用规范》 | |||||
| */ | |||||
| @Slf4j | |||||
| public class DLT664Service { | |||||
| // 各字段字节长度:数值字段直接取对应 Java 类型的字节数,定长字节数组用命名常量 | |||||
| private static final int SIZE_FILE_VERSION = Short.BYTES; | |||||
| private static final int SIZE_WIDTH = Short.BYTES; | |||||
| private static final int SIZE_HEIGHT = Short.BYTES; | |||||
| private static final int SIZE_DATE_TIME = 14; | |||||
| private static final int SIZE_EMISS = Float.BYTES; | |||||
| private static final int SIZE_AMBIENT_TEMPERATURE = Float.BYTES; | |||||
| private static final int SIZE_LEN = Byte.BYTES; | |||||
| private static final int SIZE_DISTANCE = Integer.BYTES; | |||||
| private static final int SIZE_RELATIVE_HUMIDITY = Byte.BYTES; | |||||
| private static final int SIZE_REFLECTED_TEMPERATURE = Float.BYTES; | |||||
| private static final int SIZE_STRING_FIELD = 32; | |||||
| private static final int SIZE_LONGITUDE = Double.BYTES; | |||||
| private static final int SIZE_LATITUDE = Double.BYTES; | |||||
| private static final int SIZE_ALTITUDE = Integer.BYTES; | |||||
| private static final int SIZE_DESCRIPTION_LENGTH = Integer.BYTES; | |||||
| private static final int SIZE_OFFSET = Integer.BYTES; | |||||
| private static final int SIZE_FILE_END_TYPE = 16; | |||||
| // 定长 ASCII 字符串字段的补齐字节 | |||||
| private static final byte STRING_PAD = (byte) ' '; | |||||
| public static byte[] generate(byte[] imageBytes, float[][] temperatureMatrix, String productor) { | |||||
| Info info = new Info(); | |||||
| info.setWidth((short) temperatureMatrix[0].length); | |||||
| info.setHeight((short) temperatureMatrix.length); | |||||
| info.setIrData(temperatureMatrix); | |||||
| String dateTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); | |||||
| info.setDateTime(toAsciiBytes(dateTime)); | |||||
| info.setProductor(toAsciiBytes(productor)); | |||||
| return generate(imageBytes, info); | |||||
| } | |||||
| public static byte[] generate(byte[] imageBytes, Info info) { | |||||
| float[][] irData = info.getIrData(); | |||||
| int width = info.getWidth(); | |||||
| int height = info.getHeight(); | |||||
| int descriptionLength = info.getDescriptionLength(); | |||||
| // 红外数据块大小,表达式顺序与下方写入顺序一一对应 | |||||
| int dataSize = SIZE_FILE_VERSION + SIZE_WIDTH + SIZE_HEIGHT | |||||
| + SIZE_DATE_TIME | |||||
| + width * height * Float.BYTES | |||||
| + SIZE_EMISS + SIZE_AMBIENT_TEMPERATURE + SIZE_LEN + SIZE_DISTANCE | |||||
| + SIZE_RELATIVE_HUMIDITY + SIZE_REFLECTED_TEMPERATURE | |||||
| + SIZE_STRING_FIELD * 3 | |||||
| + SIZE_LONGITUDE + SIZE_LATITUDE + SIZE_ALTITUDE | |||||
| + SIZE_DESCRIPTION_LENGTH + descriptionLength; | |||||
| ByteBuffer dataBuf = ByteBuffer.allocate(dataSize).order(ByteOrder.LITTLE_ENDIAN); | |||||
| // 文件版本、矩阵宽高 | |||||
| dataBuf.putShort(info.getFileVersion()); | |||||
| dataBuf.putShort((short) width); | |||||
| dataBuf.putShort((short) height); | |||||
| // 拍摄时间 | |||||
| putFixedBytes(dataBuf, info.getDateTime(), SIZE_DATE_TIME, STRING_PAD); | |||||
| // 温度矩阵 | |||||
| for (int row = 0; row < height; row++) { | |||||
| for (int col = 0; col < width; col++) { | |||||
| dataBuf.putFloat(irData[row][col]); | |||||
| } | |||||
| } | |||||
| // 环境参数 | |||||
| dataBuf.putFloat(info.getEmiss()); | |||||
| dataBuf.putFloat(info.getAmbientTemperature()); | |||||
| dataBuf.put(info.getLen()); | |||||
| dataBuf.putInt(info.getDistance()); | |||||
| dataBuf.put(info.getRelativeHumidity()); | |||||
| dataBuf.putFloat(info.getReflectedTemperature()); | |||||
| // 定长字符串 | |||||
| putFixedBytes(dataBuf, info.getProductor(), SIZE_STRING_FIELD, STRING_PAD); | |||||
| putFixedBytes(dataBuf, info.getType(), SIZE_STRING_FIELD, STRING_PAD); | |||||
| putFixedBytes(dataBuf, info.getSerialNo(), SIZE_STRING_FIELD, STRING_PAD); | |||||
| // 经纬度、海拔、备注 | |||||
| dataBuf.putDouble(info.getLongitude()); | |||||
| dataBuf.putDouble(info.getLatitude()); | |||||
| dataBuf.putInt(info.getAltitude()); | |||||
| dataBuf.putInt(descriptionLength); | |||||
| if (descriptionLength > 0) { | |||||
| putFixedBytes(dataBuf, info.getDescriptionData(), descriptionLength, (byte) 0); | |||||
| } | |||||
| if (dataBuf.hasRemaining()) { | |||||
| throw new IllegalStateException("dataSize 与实际写入字节数不一致,剩余 " + dataBuf.remaining() + " 字节"); | |||||
| } | |||||
| byte[] dataBlock = dataBuf.array(); | |||||
| // 拼接: 图片 + 红外数据块 + 偏移量(4) + 文件末尾标识(16) | |||||
| int offset = imageBytes.length; | |||||
| byte[] result = new byte[offset + dataBlock.length + SIZE_OFFSET + SIZE_FILE_END_TYPE]; | |||||
| System.arraycopy(imageBytes, 0, result, 0, offset); | |||||
| System.arraycopy(dataBlock, 0, result, offset, dataBlock.length); | |||||
| ByteBuffer.wrap(result, offset + dataBlock.length, SIZE_OFFSET) | |||||
| .order(ByteOrder.LITTLE_ENDIAN) | |||||
| .putInt(offset); | |||||
| System.arraycopy(info.getFileEndType(), 0, result, offset + dataBlock.length + SIZE_OFFSET, SIZE_FILE_END_TYPE); | |||||
| log.info("DLT664文件组装成功"); | |||||
| return result; | |||||
| } | |||||
| /** | |||||
| * 写入定长字节:不足补 fill,超出截断。 | |||||
| */ | |||||
| private static void putFixedBytes(ByteBuffer buf, byte[] src, int len, byte fill) { | |||||
| byte[] bytes = new byte[len]; | |||||
| Arrays.fill(bytes, fill); | |||||
| if (src != null) { | |||||
| System.arraycopy(src, 0, bytes, 0, Math.min(src.length, len)); | |||||
| } | |||||
| buf.put(bytes); | |||||
| } | |||||
| private static byte[] toAsciiBytes(String s) { | |||||
| return s != null ? s.getBytes(StandardCharsets.US_ASCII) : null; | |||||
| } | |||||
| @Data | |||||
| public static class Info { | |||||
| // 文件版本 | |||||
| private short fileVersion = (short) 0x0100; | |||||
| // 矩阵宽度 | |||||
| private short width; | |||||
| // 矩阵高度 | |||||
| private short height; | |||||
| // 拍摄时间 (14 bytes ASCII) | |||||
| private byte[] dateTime; | |||||
| // 整个温度矩阵 | |||||
| private float[][] irData; | |||||
| // 辐射率 | |||||
| private float emiss; | |||||
| // 环境温度 | |||||
| private float ambientTemperature; | |||||
| // 镜头度数 | |||||
| private byte len; | |||||
| // 拍摄距离 (4 bytes) | |||||
| private int distance; | |||||
| // 相对湿度 | |||||
| private byte relativeHumidity; | |||||
| // 反射温度 | |||||
| private float reflectedTemperature; | |||||
| // 生产厂家 (32 bytes) | |||||
| private byte[] productor; | |||||
| // 产品型号 (32 bytes) | |||||
| private byte[] type; | |||||
| // 产品序列号 (32 bytes) | |||||
| private byte[] serialNo; | |||||
| // 经度 | |||||
| private double longitude = 0; | |||||
| // 纬度 | |||||
| private double latitude = 0; | |||||
| // 海拔 (4 bytes) | |||||
| private int altitude = 0; | |||||
| // 备注信息长度, 0 表示没有存储信息 | |||||
| private int descriptionLength = 0; | |||||
| // 备注信息 (descriptionLength bytes) | |||||
| private byte[] descriptionData; | |||||
| // 红外数据的起始偏移地址 | |||||
| private int irDataOffset; | |||||
| // 文件末尾标识 (16 bytes) | |||||
| private byte[] fileEndType = { | |||||
| (byte) 0x37, (byte) 0x66, (byte) 0x07, (byte) 0x1a, | |||||
| (byte) 0x12, (byte) 0x3a, (byte) 0x4c, (byte) 0x9f, | |||||
| (byte) 0xa9, (byte) 0x5d, (byte) 0x21, (byte) 0xd2, | |||||
| (byte) 0xda, (byte) 0x7d, (byte) 0x26, (byte) 0xbc | |||||
| }; | |||||
| } | |||||
| } | |||||