feat(aima): 实现智能测试任务执行队列和媒体录制功能
- 新增 SYSTEM_EXECUTE_ACTION_QUEUE 枚举值支持边缘设备动作队列执行 - 修改 aima_task_test_case 表主键为 task_id 和 sort_order 组合 - 添加 AimaFlowExecutionListener 监听器实现任务执行进度跟踪 - 引入并发安全的 Map 结构存储任务执行状态避免并发问题 - 实现测试用例绑定顺序管理和执行进度计算逻辑 - 集成 FlowMediaParamResolver 处理图像和视频媒体参数 - 完善 AimaTaskInstanceService 支持测试用例顺序执行 - 添加 AimaTestLog 记录实际执行结果、截图和视频链接 - 更新 EdgeCameraService 支持工作流录像会话管理 - 实现后端本地录像功能包括开始/停止录制操作 - 添加定时清理过期录像会话和 WebSocket 实时推送支持
This commit is contained in:
parent
18c4d3c0d7
commit
05992d1980
@ -44,8 +44,7 @@ public class EdgeMicrophoneController {
|
|||||||
@PreAuthorize("@ss.hasPermi('system:microphone:stop')")
|
@PreAuthorize("@ss.hasPermi('system:microphone:stop')")
|
||||||
@PostMapping("/stop")
|
@PostMapping("/stop")
|
||||||
public AjaxResult stopRecord(@RequestBody EdgeCommonVO edgeCommonVO) {
|
public AjaxResult stopRecord(@RequestBody EdgeCommonVO edgeCommonVO) {
|
||||||
microphoneService.stopRecord(edgeCommonVO);
|
return AjaxResult.success(microphoneService.stopRecord(edgeCommonVO));
|
||||||
return AjaxResult.success();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation("暂停录音")
|
@ApiOperation("暂停录音")
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import com.cmvr.common.core.controller.BaseController;
|
|||||||
import com.cmvr.common.core.domain.AjaxResult;
|
import com.cmvr.common.core.domain.AjaxResult;
|
||||||
import com.cmvr.edge.client.model.system.EdgeSystemJsonCommandVO;
|
import com.cmvr.edge.client.model.system.EdgeSystemJsonCommandVO;
|
||||||
import com.cmvr.edge.client.model.system.EdgeSystemUpdateParamsVO;
|
import com.cmvr.edge.client.model.system.EdgeSystemUpdateParamsVO;
|
||||||
|
import com.cmvr.edge.client.model.system.EdgeActionQueueVO;
|
||||||
import com.cmvr.edge.client.service.EdgeSystemService;
|
import com.cmvr.edge.client.service.EdgeSystemService;
|
||||||
import io.swagger.annotations.Api;
|
import io.swagger.annotations.Api;
|
||||||
import io.swagger.annotations.ApiOperation;
|
import io.swagger.annotations.ApiOperation;
|
||||||
@ -48,6 +49,12 @@ public class EdgeSystemController extends BaseController {
|
|||||||
return success(JSON.toJSONString(edgeSystemService.executeJsonCommand(request)));
|
return success(JSON.toJSONString(edgeSystemService.executeJsonCommand(request)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation("Execute an edge-local serial action queue")
|
||||||
|
@PostMapping("/actionQueue")
|
||||||
|
public AjaxResult executeActionQueue(@RequestBody EdgeActionQueueVO request) {
|
||||||
|
return success(JSON.toJSONString(edgeSystemService.executeActionQueue(request)));
|
||||||
|
}
|
||||||
|
|
||||||
@ApiOperation("停止所有设备")
|
@ApiOperation("停止所有设备")
|
||||||
@GetMapping("/stopAll")
|
@GetMapping("/stopAll")
|
||||||
public AjaxResult stopAll(@RequestParam("robotId") String robotId) {
|
public AjaxResult stopAll(@RequestParam("robotId") String robotId) {
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import com.google.gson.JsonObject;
|
|||||||
import io.swagger.annotations.Api;
|
import io.swagger.annotations.Api;
|
||||||
import io.swagger.annotations.ApiOperation;
|
import io.swagger.annotations.ApiOperation;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
@ -50,8 +51,9 @@ public class TeFlowiseController extends BaseController {
|
|||||||
.put("test", request.getQuestion());
|
.put("test", request.getQuestion());
|
||||||
runParams.put("5bb26d52e1861c6936f3c3149eaf17e8", runParams1);
|
runParams.put("5bb26d52e1861c6936f3c3149eaf17e8", runParams1);
|
||||||
TeTaskExecuteNormalVO taskExecuteNormalVO = TeTaskExecuteNormalVO.builder()
|
TeTaskExecuteNormalVO taskExecuteNormalVO = TeTaskExecuteNormalVO.builder()
|
||||||
.taskId("b6ab5ffff783d9b25d0dc34f13be222d")
|
.taskId(StringUtils.defaultIfBlank(request.getTaskId(), "b6ab5ffff783d9b25d0dc34f13be222d"))
|
||||||
.robotId("4ed1246c465b97975f96c9ef8371a3bd")
|
.robotId(StringUtils.defaultIfBlank(request.getRobotId(), "4ed1246c465b97975f96c9ef8371a3bd"))
|
||||||
|
.runtimeAssignments(request.getRuntimeAssignments())
|
||||||
.runParams(JSONObject.from(runParams)).build();
|
.runParams(JSONObject.from(runParams)).build();
|
||||||
if (!taskInstIdList.isEmpty()) {
|
if (!taskInstIdList.isEmpty()) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -48,6 +48,9 @@ user:
|
|||||||
|
|
||||||
# Spring配置
|
# Spring配置
|
||||||
spring:
|
spring:
|
||||||
|
jackson:
|
||||||
|
date-format: "yyyy-MM-dd HH:mm:ss"
|
||||||
|
time-zone: Asia/Shanghai
|
||||||
sql:
|
sql:
|
||||||
init:
|
init:
|
||||||
mode: always
|
mode: always
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.cmvr.aima.domain;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import io.swagger.annotations.ApiModel;
|
import io.swagger.annotations.ApiModel;
|
||||||
import io.swagger.annotations.ApiModelProperty;
|
import io.swagger.annotations.ApiModelProperty;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
@ -46,6 +47,18 @@ public class AimaTestLog extends BaseEntity
|
|||||||
@ApiModelProperty("测试用例ID")
|
@ApiModelProperty("测试用例ID")
|
||||||
private String testCaseId;
|
private String testCaseId;
|
||||||
|
|
||||||
|
/** 工作流检测项 ID,仅用于接口和 WebSocket 展示,不落日志表。 */
|
||||||
|
@TableField(exist = false)
|
||||||
|
private String itemId;
|
||||||
|
|
||||||
|
/** 当前绑定项的执行位置,从 1 开始。 */
|
||||||
|
@TableField(exist = false)
|
||||||
|
private Integer itemOrder;
|
||||||
|
|
||||||
|
/** 同一检测项在任务中第几次出现。 */
|
||||||
|
@TableField(exist = false)
|
||||||
|
private Integer itemOccurrence;
|
||||||
|
|
||||||
@Excel(name = "用例名称")
|
@Excel(name = "用例名称")
|
||||||
@ApiModelProperty("用例名称")
|
@ApiModelProperty("用例名称")
|
||||||
private String caseName;
|
private String caseName;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.cmvr.aima.domain.vo;
|
package com.cmvr.aima.domain.vo;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import com.cmvr.test.model.vo.ResourceRuntimeAssignmentVO;
|
import com.cmvr.test.model.vo.ResourceRuntimeAssignmentVO;
|
||||||
import io.swagger.annotations.ApiModel;
|
import io.swagger.annotations.ApiModel;
|
||||||
import io.swagger.annotations.ApiModelProperty;
|
import io.swagger.annotations.ApiModelProperty;
|
||||||
@ -17,4 +18,7 @@ public class AimaTaskStartVO {
|
|||||||
|
|
||||||
@ApiModelProperty("机器人角色运行分配")
|
@ApiModelProperty("机器人角色运行分配")
|
||||||
private List<ResourceRuntimeAssignmentVO> runtimeAssignments = new ArrayList<>();
|
private List<ResourceRuntimeAssignmentVO> runtimeAssignments = new ArrayList<>();
|
||||||
|
|
||||||
|
@ApiModelProperty("按检测项和出现序号组织的本次运行参数")
|
||||||
|
private JSONObject runParams = new JSONObject();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,19 @@
|
|||||||
package com.cmvr.aima.listener;
|
package com.cmvr.aima.listener;
|
||||||
|
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import com.cmvr.common.utils.DateUtils;
|
import com.cmvr.common.utils.DateUtils;
|
||||||
import com.cmvr.framework.websocket.service.MessagePushService;
|
import com.cmvr.framework.websocket.service.MessagePushService;
|
||||||
import com.cmvr.aima.domain.AimaTaskInstance;
|
import com.cmvr.aima.domain.AimaTaskInstance;
|
||||||
import com.cmvr.aima.domain.AimaTestLog;
|
import com.cmvr.aima.domain.AimaTestLog;
|
||||||
import com.cmvr.aima.enums.AimaTaskStatusEnum;
|
import com.cmvr.aima.enums.AimaTaskStatusEnum;
|
||||||
import com.cmvr.aima.service.IAimaTaskInstanceService;
|
import com.cmvr.aima.service.IAimaTaskInstanceService;
|
||||||
|
import com.cmvr.aima.service.IAimaTaskService;
|
||||||
import com.cmvr.aima.service.IAimaTestLogService;
|
import com.cmvr.aima.service.IAimaTestLogService;
|
||||||
import com.cmvr.test.flow.runtime.event.FlowExecutionEvent;
|
import com.cmvr.test.flow.runtime.event.FlowExecutionEvent;
|
||||||
import com.cmvr.test.flow.runtime.event.FlowExecutionListener;
|
import com.cmvr.test.flow.runtime.event.FlowExecutionListener;
|
||||||
import com.cmvr.test.flow.runtime.event.FlowExecutionModuleCodes;
|
import com.cmvr.test.flow.runtime.event.FlowExecutionModuleCodes;
|
||||||
|
import com.cmvr.test.flow.runtime.operator.FlowMediaParamResolver;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@ -28,18 +31,23 @@ import java.util.*;
|
|||||||
public class AimaFlowExecutionListener implements FlowExecutionListener {
|
public class AimaFlowExecutionListener implements FlowExecutionListener {
|
||||||
private final IAimaTaskInstanceService aimaTaskInstanceService;
|
private final IAimaTaskInstanceService aimaTaskInstanceService;
|
||||||
private final IAimaTestLogService aimaTestLogService;
|
private final IAimaTestLogService aimaTestLogService;
|
||||||
|
private final IAimaTaskService aimaTaskService;
|
||||||
private final MessagePushService messagePushService;
|
private final MessagePushService messagePushService;
|
||||||
|
|
||||||
/** key:itemId, value:已完成节点集合 */
|
/** key:itemId, value:已完成节点集合 */
|
||||||
private final Map<String, Set<String>> itemNodeExecutMap = new HashMap<>();
|
private final Map<String, Set<String>> itemNodeExecutMap = new java.util.concurrent.ConcurrentHashMap<>();
|
||||||
/** key:taskInsId(流程实例id), value:已执行item集合 */
|
/** key:taskInsId(流程实例id), value:已执行item集合 */
|
||||||
private final Map<String, Set<String>> taskItemExecutMap = new HashMap<>();
|
private final Map<String, Set<String>> taskItemExecutMap = new java.util.concurrent.ConcurrentHashMap<>();
|
||||||
|
/** key: flow instance ID, value: test case IDs in binding order. */
|
||||||
|
private final Map<String, List<String>> taskCaseBindingMap = new java.util.concurrent.ConcurrentHashMap<>();
|
||||||
|
|
||||||
public AimaFlowExecutionListener(IAimaTaskInstanceService aimaTaskInstanceService,
|
public AimaFlowExecutionListener(IAimaTaskInstanceService aimaTaskInstanceService,
|
||||||
IAimaTestLogService aimaTestLogService,
|
IAimaTestLogService aimaTestLogService,
|
||||||
|
IAimaTaskService aimaTaskService,
|
||||||
MessagePushService messagePushService) {
|
MessagePushService messagePushService) {
|
||||||
this.aimaTaskInstanceService = aimaTaskInstanceService;
|
this.aimaTaskInstanceService = aimaTaskInstanceService;
|
||||||
this.aimaTestLogService = aimaTestLogService;
|
this.aimaTestLogService = aimaTestLogService;
|
||||||
|
this.aimaTaskService = aimaTaskService;
|
||||||
this.messagePushService = messagePushService;
|
this.messagePushService = messagePushService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,11 +68,13 @@ public class AimaFlowExecutionListener implements FlowExecutionListener {
|
|||||||
// 1.节点完成才记录当前item下node
|
// 1.节点完成才记录当前item下node
|
||||||
if (StrUtil.isNotBlank(itemId) && StrUtil.isNotBlank(nodeId)
|
if (StrUtil.isNotBlank(itemId) && StrUtil.isNotBlank(nodeId)
|
||||||
&& FlowExecutionEvent.EventType.NODE_COMPLETED == event.getEventType()) {
|
&& FlowExecutionEvent.EventType.NODE_COMPLETED == event.getEventType()) {
|
||||||
itemNodeExecutMap.computeIfAbsent(itemId, k -> new HashSet<>()).add(nodeId);
|
itemNodeExecutMap.computeIfAbsent(itemExecutionKey(instId, event),
|
||||||
|
k -> java.util.concurrent.ConcurrentHashMap.newKeySet()).add(nodeId);
|
||||||
}
|
}
|
||||||
// 任意事件:当前任务绑定item(item一启动就入Map)
|
// 任意事件:当前任务绑定item(item一启动就入Map)
|
||||||
if (StrUtil.isNotBlank(instId) && StrUtil.isNotBlank(itemId)) {
|
if (StrUtil.isNotBlank(instId) && StrUtil.isNotBlank(itemId)) {
|
||||||
taskItemExecutMap.computeIfAbsent(instId, k -> new HashSet<>()).add(itemId);
|
taskItemExecutMap.computeIfAbsent(instId,
|
||||||
|
k -> java.util.concurrent.ConcurrentHashMap.newKeySet()).add(itemExecutionKey(instId, event));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询任务实例
|
// 查询任务实例
|
||||||
@ -114,11 +124,14 @@ public class AimaFlowExecutionListener implements FlowExecutionListener {
|
|||||||
int finishedItemNum = itemTotal - 1;
|
int finishedItemNum = itemTotal - 1;
|
||||||
|
|
||||||
// 当前item已完成节点数
|
// 当前item已完成节点数
|
||||||
Set<String> finishedNodeSet = itemNodeExecutMap.getOrDefault(itemId, new HashSet<>());
|
Set<String> finishedNodeSet = itemNodeExecutMap.getOrDefault(itemExecutionKey(instId, event), new HashSet<>());
|
||||||
int finishedNode = finishedNodeSet.size();
|
int finishedNode = finishedNodeSet.size();
|
||||||
int totalNode = event.getNodeCount() == 0 ? 1 : event.getNodeCount();
|
int totalNode = event.getNodeCount() == 0 ? 1 : event.getNodeCount();
|
||||||
|
|
||||||
// 已完整跑完用例占比
|
// 已完整跑完用例占比
|
||||||
|
if (event.getItemOrder() != null && event.getItemOrder() > 0) {
|
||||||
|
finishedItemNum = event.getItemOrder() - 1;
|
||||||
|
}
|
||||||
double itemTotalRatio = finishedItemNum / (double) totalCases;
|
double itemTotalRatio = finishedItemNum / (double) totalCases;
|
||||||
// 当前运行item内部节点占比
|
// 当前运行item内部节点占比
|
||||||
double innerRatio = finishedNode / (double) totalNode / totalCases;
|
double innerRatio = finishedNode / (double) totalNode / totalCases;
|
||||||
@ -134,16 +147,27 @@ public class AimaFlowExecutionListener implements FlowExecutionListener {
|
|||||||
String logId = UUID.randomUUID().toString().replace("-", "");
|
String logId = UUID.randomUUID().toString().replace("-", "");
|
||||||
// 四舍五入保留2位小数
|
// 四舍五入保留2位小数
|
||||||
progress = Math.round(progress * 100) / 100.0;
|
progress = Math.round(progress * 100) / 100.0;
|
||||||
|
JSONObject outputParams = event.getOutputParams();
|
||||||
|
String imageUrl = FlowMediaParamResolver.lastUrl(outputParams, "imageUrl");
|
||||||
|
String videoUrl = FlowMediaParamResolver.lastUrl(outputParams, "videoUrl");
|
||||||
|
String testCaseId = resolveTestCaseId(instId, taskInstance.getTaskId(), event);
|
||||||
|
|
||||||
// 保存日志到数据库
|
// 保存日志到数据库
|
||||||
AimaTestLog testLog = AimaTestLog.builder()
|
AimaTestLog testLog = AimaTestLog.builder()
|
||||||
.id(logId)
|
.id(logId)
|
||||||
.taskInstanceId(taskInstance.getId())
|
.taskInstanceId(taskInstance.getId())
|
||||||
.taskId(taskInstance.getTaskId())
|
.taskId(taskInstance.getTaskId())
|
||||||
.testCaseId(itemId)
|
.testCaseId(testCaseId)
|
||||||
|
.itemId(itemId)
|
||||||
|
.itemOrder(event.getItemOrder())
|
||||||
|
.itemOccurrence(event.getItemOccurrence())
|
||||||
.logLevel("INFO")
|
.logLevel("INFO")
|
||||||
.logType("执行日志")
|
.logType("执行日志")
|
||||||
.logContent("节点【" + event.getNodeName() + "】" + info)
|
.logContent("节点【" + event.getNodeName() + "】" + info)
|
||||||
|
.actualResult(outputParams == null || outputParams.isEmpty()
|
||||||
|
? null : outputParams.toJSONString())
|
||||||
|
.screenshotUrl(imageUrl)
|
||||||
|
.videoUrl(videoUrl)
|
||||||
.logTime(now)
|
.logTime(now)
|
||||||
.status(targetStatus)
|
.status(targetStatus)
|
||||||
.executionStep(event.getNodeName())
|
.executionStep(event.getNodeName())
|
||||||
@ -172,6 +196,24 @@ public class AimaFlowExecutionListener implements FlowExecutionListener {
|
|||||||
Set<String> itemList = taskItemExecutMap.getOrDefault(taskInsId, new HashSet<>());
|
Set<String> itemList = taskItemExecutMap.getOrDefault(taskInsId, new HashSet<>());
|
||||||
itemList.forEach(itemNodeExecutMap::remove);
|
itemList.forEach(itemNodeExecutMap::remove);
|
||||||
taskItemExecutMap.remove(taskInsId);
|
taskItemExecutMap.remove(taskInsId);
|
||||||
|
taskCaseBindingMap.remove(taskInsId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String itemExecutionKey(String taskInsId, FlowExecutionEvent event) {
|
||||||
|
if (event.getItemOrder() != null && event.getItemOrder() > 0) {
|
||||||
|
return taskInsId + "|" + event.getItemOrder();
|
||||||
|
}
|
||||||
|
return taskInsId + "|" + event.getItemId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveTestCaseId(String taskInsId, String taskId, FlowExecutionEvent event) {
|
||||||
|
if (event.getItemOrder() == null || event.getItemOrder() < 1) {
|
||||||
|
return event.getItemId();
|
||||||
|
}
|
||||||
|
List<String> testCaseIds = taskCaseBindingMap.computeIfAbsent(taskInsId,
|
||||||
|
key -> aimaTaskService.getTestCaseIds(taskId).stream().map(testCase -> testCase.getId()).toList());
|
||||||
|
int index = event.getItemOrder() - 1;
|
||||||
|
return index < testCaseIds.size() ? testCaseIds.get(index) : event.getItemId();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import java.util.List;
|
|||||||
import com.github.yulichang.base.MPJBaseMapper;
|
import com.github.yulichang.base.MPJBaseMapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
import com.cmvr.aima.domain.AimaTaskTestCase;
|
import com.cmvr.aima.domain.AimaTaskTestCase;
|
||||||
|
import com.cmvr.aima.domain.AimaTestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 爱玛任务与测试用例关联Mapper接口
|
* 爱玛任务与测试用例关联Mapper接口
|
||||||
@ -38,6 +39,9 @@ public interface AimaTaskTestCaseMapper extends MPJBaseMapper<AimaTaskTestCase>
|
|||||||
*/
|
*/
|
||||||
List<String> selectTestCaseIdsByTaskId(String taskId);
|
List<String> selectTestCaseIdsByTaskId(String taskId);
|
||||||
|
|
||||||
|
/** 按任务中的位置查询用例,重复用例会返回多条独立记录。 */
|
||||||
|
List<AimaTestCase> selectTestCasesByTaskId(String taskId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量删除任务-测试用例关联
|
* 批量删除任务-测试用例关联
|
||||||
*
|
*
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.cmvr.aima.service.impl;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
import com.alibaba.fastjson2.JSONObject;
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
|
||||||
import com.cmvr.aima.domain.dto.AimaTaskInstanceQuery;
|
import com.cmvr.aima.domain.dto.AimaTaskInstanceQuery;
|
||||||
@ -13,6 +14,7 @@ import com.cmvr.test.flow.control.FlowControlService;
|
|||||||
import com.cmvr.test.flow.runtime.engine.FlowTaskRuntimeService;
|
import com.cmvr.test.flow.runtime.engine.FlowTaskRuntimeService;
|
||||||
import com.cmvr.test.flow.runtime.event.FlowExecutionModuleCodes;
|
import com.cmvr.test.flow.runtime.event.FlowExecutionModuleCodes;
|
||||||
import com.cmvr.test.model.vo.TeTaskExecuteNormalVO;
|
import com.cmvr.test.model.vo.TeTaskExecuteNormalVO;
|
||||||
|
import com.cmvr.test.model.vo.TeTaskOrchestraVO;
|
||||||
import com.cmvr.test.service.ITeTaskOrchestrationService;
|
import com.cmvr.test.service.ITeTaskOrchestrationService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.context.annotation.Lazy;
|
import org.springframework.context.annotation.Lazy;
|
||||||
@ -25,6 +27,9 @@ import com.cmvr.aima.enums.AimaTaskStatusEnum;
|
|||||||
import com.cmvr.aima.service.IAimaTaskService;
|
import com.cmvr.aima.service.IAimaTaskService;
|
||||||
import com.cmvr.aima.domain.AimaTask;
|
import com.cmvr.aima.domain.AimaTask;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 爱玛任务执行实例Service业务层处理
|
* 爱玛任务执行实例Service业务层处理
|
||||||
@ -138,12 +143,37 @@ public class AimaTaskInstanceServiceImpl extends ServiceImpl<AimaTaskInstanceMap
|
|||||||
|
|
||||||
|
|
||||||
AimaTask aimaTask = aimaTaskService.selectAimaTaskById(instance.getTaskId());
|
AimaTask aimaTask = aimaTaskService.selectAimaTaskById(instance.getTaskId());
|
||||||
JSONObject runParams = new JSONObject();
|
if (aimaTask == null || StrUtil.isBlank(aimaTask.getTaskConfigId())) {
|
||||||
// 从test模块的任务编排中获取所有的检测项id
|
throw new ServiceException("智能测试任务没有关联流程编排");
|
||||||
if (aimaTask != null && aimaTask.getTaskConfigId() != null) {
|
|
||||||
teTaskOrchestrationService.queryByTaskId(aimaTask.getTaskConfigId())
|
|
||||||
.forEach(item -> runParams.put(item.getItemId(), new JSONObject()));
|
|
||||||
}
|
}
|
||||||
|
List<String> orderedItemIds = aimaTaskService.getTestCaseIds(instance.getTaskId()).stream()
|
||||||
|
.map(com.cmvr.aima.domain.AimaTestCase::getDetectItemId)
|
||||||
|
.filter(StrUtil::isNotBlank)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
if (orderedItemIds.isEmpty()) {
|
||||||
|
throw new ServiceException("智能测试任务没有绑定可执行的测试用例");
|
||||||
|
}
|
||||||
|
teTaskOrchestrationService.insertTeTaskOrchestration(TeTaskOrchestraVO.builder()
|
||||||
|
.taskId(aimaTask.getTaskConfigId())
|
||||||
|
.itemIds(orderedItemIds)
|
||||||
|
.build());
|
||||||
|
JSONObject runParams = new JSONObject();
|
||||||
|
JSONObject requestedRunParams = startVO == null ? null : startVO.getRunParams();
|
||||||
|
Map<String, Integer> itemOccurrences = new HashMap<>();
|
||||||
|
// 从test模块的任务编排中获取所有的检测项id
|
||||||
|
teTaskOrchestrationService.queryByTaskId(aimaTask.getTaskConfigId()).forEach(item -> {
|
||||||
|
int occurrence = itemOccurrences.merge(item.getItemId(), 1, Integer::sum);
|
||||||
|
JSONObject submitted = requestedRunParams == null ? null
|
||||||
|
: requestedRunParams.getJSONObject(item.getItemId() + ":" + occurrence);
|
||||||
|
if (submitted == null && requestedRunParams != null) {
|
||||||
|
submitted = requestedRunParams.getJSONObject(item.getItemId());
|
||||||
|
}
|
||||||
|
JSONObject itemParams = submitted == null ? new JSONObject() : new JSONObject(submitted);
|
||||||
|
itemParams.put("phoneId", instance.getPhoneId());
|
||||||
|
itemParams.put("vehicleId", instance.getVehicleId());
|
||||||
|
itemParams.put("taskInstanceId", instance.getId());
|
||||||
|
runParams.put(item.getItemId() + ":" + occurrence, itemParams);
|
||||||
|
});
|
||||||
// 调用流程引擎执行
|
// 调用流程引擎执行
|
||||||
String insId = flowTaskRuntimeService.executeTask(
|
String insId = flowTaskRuntimeService.executeTask(
|
||||||
TeTaskExecuteNormalVO.builder()
|
TeTaskExecuteNormalVO.builder()
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import java.util.List;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
|
||||||
import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
|
||||||
import com.cmvr.aima.domain.AimaTaskTestCase;
|
import com.cmvr.aima.domain.AimaTaskTestCase;
|
||||||
import com.cmvr.aima.domain.AimaTestCase;
|
import com.cmvr.aima.domain.AimaTestCase;
|
||||||
@ -111,6 +110,21 @@ public class AimaTaskServiceImpl extends ServiceImpl<AimaTaskMapper, AimaTask> i
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void bindTestCases(String taskId, List<String> testCaseIds) {
|
public void bindTestCases(String taskId, List<String> testCaseIds) {
|
||||||
|
aimaTaskTestCaseMapper.deleteTaskTestCaseByTaskId(taskId);
|
||||||
|
AimaTask existingTask = aimaTaskMapper.selectAimaTaskById(taskId);
|
||||||
|
if (existingTask == null) {
|
||||||
|
throw new IllegalArgumentException("任务不存在: " + taskId);
|
||||||
|
}
|
||||||
|
if (testCaseIds == null || testCaseIds.isEmpty()) {
|
||||||
|
AimaTask emptyTask = new AimaTask();
|
||||||
|
emptyTask.setId(taskId);
|
||||||
|
emptyTask.setTestCaseCount(0);
|
||||||
|
aimaTaskMapper.updateAimaTask(emptyTask);
|
||||||
|
if (existingTask.getTaskConfigId() != null) {
|
||||||
|
teTaskOrchestrationService.deleteByTaskId(existingTask.getTaskConfigId());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 先删除旧的关联
|
// 先删除旧的关联
|
||||||
aimaTaskTestCaseMapper.deleteTaskTestCaseByTaskId(taskId);
|
aimaTaskTestCaseMapper.deleteTaskTestCaseByTaskId(taskId);
|
||||||
|
|
||||||
@ -121,7 +135,7 @@ public class AimaTaskServiceImpl extends ServiceImpl<AimaTaskMapper, AimaTask> i
|
|||||||
AimaTaskTestCase taskTestCase = new AimaTaskTestCase();
|
AimaTaskTestCase taskTestCase = new AimaTaskTestCase();
|
||||||
taskTestCase.setTaskId(taskId);
|
taskTestCase.setTaskId(taskId);
|
||||||
taskTestCase.setTestCaseId(testCaseIds.get(i));
|
taskTestCase.setTestCaseId(testCaseIds.get(i));
|
||||||
taskTestCase.setSortOrder(i);
|
taskTestCase.setSortOrder(i + 1);
|
||||||
list.add(taskTestCase);
|
list.add(taskTestCase);
|
||||||
}
|
}
|
||||||
aimaTaskTestCaseMapper.batchInsertTaskTestCase(list);
|
aimaTaskTestCaseMapper.batchInsertTaskTestCase(list);
|
||||||
@ -133,27 +147,27 @@ public class AimaTaskServiceImpl extends ServiceImpl<AimaTaskMapper, AimaTask> i
|
|||||||
aimaTaskMapper.updateAimaTask(task);
|
aimaTaskMapper.updateAimaTask(task);
|
||||||
|
|
||||||
// 查询测试用例,获取detectItemId列表
|
// 查询测试用例,获取detectItemId列表
|
||||||
List<AimaTestCase> testCases = aimaTestCaseMapper.selectBatchIds(testCaseIds);
|
List<AimaTestCase> testCases = getTestCaseIds(taskId);
|
||||||
List<String> detectItemIds = testCases.stream()
|
List<String> detectItemIds = testCases.stream()
|
||||||
.map(AimaTestCase::getDetectItemId)
|
.map(AimaTestCase::getDetectItemId)
|
||||||
.filter(itemId -> itemId != null && !itemId.isEmpty())
|
.filter(itemId -> itemId != null && !itemId.isEmpty())
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
// 同步任务编排到test模块
|
// 同步任务编排到test模块
|
||||||
if (!detectItemIds.isEmpty()) {
|
if (!detectItemIds.isEmpty() && existingTask.getTaskConfigId() != null) {
|
||||||
AimaTask aimaTask = aimaTaskMapper.selectAimaTaskById(taskId);
|
|
||||||
TeTaskOrchestraVO teTaskOrchestraVO = TeTaskOrchestraVO.builder()
|
TeTaskOrchestraVO teTaskOrchestraVO = TeTaskOrchestraVO.builder()
|
||||||
.taskId(aimaTask.getTaskConfigId())
|
.taskId(existingTask.getTaskConfigId())
|
||||||
.itemIds(detectItemIds)
|
.itemIds(detectItemIds)
|
||||||
.build();
|
.build();
|
||||||
teTaskOrchestrationService.insertTeTaskOrchestration(teTaskOrchestraVO);
|
teTaskOrchestrationService.insertTeTaskOrchestration(teTaskOrchestraVO);
|
||||||
|
} else if (existingTask.getTaskConfigId() != null) {
|
||||||
|
teTaskOrchestrationService.deleteByTaskId(existingTask.getTaskConfigId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<AimaTestCase> getTestCaseIds(String taskId) {
|
public List<AimaTestCase> getTestCaseIds(String taskId) {
|
||||||
List<String> ids = aimaTaskTestCaseMapper.selectTestCaseIdsByTaskId(taskId);
|
return aimaTaskTestCaseMapper.selectTestCasesByTaskId(taskId);
|
||||||
return aimaTestCaseMapper.selectList(new QueryWrapper<AimaTestCase>().in("id", ids));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,32 @@
|
|||||||
<select id="selectTestCaseIdsByTaskId" parameterType="String" resultType="String">
|
<select id="selectTestCaseIdsByTaskId" parameterType="String" resultType="String">
|
||||||
SELECT test_case_id FROM aima_task_test_case
|
SELECT test_case_id FROM aima_task_test_case
|
||||||
WHERE task_id = #{taskId}
|
WHERE task_id = #{taskId}
|
||||||
ORDER BY sort_order
|
ORDER BY sort_order, test_case_id
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectTestCasesByTaskId" parameterType="String" resultType="AimaTestCase">
|
||||||
|
SELECT tc.id,
|
||||||
|
tc.create_by,
|
||||||
|
tc.create_time,
|
||||||
|
tc.update_by,
|
||||||
|
tc.update_time,
|
||||||
|
tc.remark,
|
||||||
|
tc.case_code,
|
||||||
|
tc.case_name,
|
||||||
|
tc.case_category,
|
||||||
|
tc.test_description,
|
||||||
|
tc.precondition,
|
||||||
|
tc.test_steps,
|
||||||
|
tc.expected_result,
|
||||||
|
tc.priority,
|
||||||
|
tc.enabled,
|
||||||
|
rel.sort_order AS sort_order,
|
||||||
|
tc.estimated_duration,
|
||||||
|
tc.detect_item_id
|
||||||
|
FROM aima_task_test_case rel
|
||||||
|
INNER JOIN aima_test_case tc ON tc.id = rel.test_case_id
|
||||||
|
WHERE rel.task_id = #{taskId}
|
||||||
|
ORDER BY rel.sort_order
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<delete id="deleteTaskTestCaseByTaskIds" parameterType="String">
|
<delete id="deleteTaskTestCaseByTaskIds" parameterType="String">
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
package com.cmvr.edge.client.model.system;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class EdgeActionQueueVO {
|
||||||
|
private String robotId;
|
||||||
|
private String actionId;
|
||||||
|
private Integer totalTimeoutMs;
|
||||||
|
private List<Step> steps = new ArrayList<>();
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Step {
|
||||||
|
private String stepId;
|
||||||
|
private String type;
|
||||||
|
private String deviceId;
|
||||||
|
private Integer timeoutMs;
|
||||||
|
private JSONObject params = new JSONObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import com.cmvr.edge.client.model.EdgeCommonVO;
|
|||||||
import com.cmvr.edge.client.model.camera.EdgeCameraPtzVO;
|
import com.cmvr.edge.client.model.camera.EdgeCameraPtzVO;
|
||||||
import io.grpc.stub.StreamObserver;
|
import io.grpc.stub.StreamObserver;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -49,6 +50,12 @@ public interface EdgeCameraService {
|
|||||||
*/
|
*/
|
||||||
public String stopRecording(EdgeCommonVO edgeCommonVO);
|
public String stopRecording(EdgeCommonVO edgeCommonVO);
|
||||||
|
|
||||||
|
/** Starts a backend-local recording session from the robot camera stream. */
|
||||||
|
String startWorkflowRecording(EdgeCommonVO edgeCommonVO);
|
||||||
|
|
||||||
|
/** Stops and finalizes a backend-local workflow recording. */
|
||||||
|
File stopWorkflowRecording(EdgeCommonVO edgeCommonVO, String recordingId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取相机图片组
|
* 获取相机图片组
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import cmvr.api.MicrophoneCommand;
|
|||||||
import com.cmvr.edge.client.model.EdgeCommonVO;
|
import com.cmvr.edge.client.model.EdgeCommonVO;
|
||||||
import com.cmvr.edge.client.model.microphone.EdgeMicrophoneVolumeVO;
|
import com.cmvr.edge.client.model.microphone.EdgeMicrophoneVolumeVO;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 边缘麦克风服务接口
|
* 边缘麦克风服务接口
|
||||||
* 定义了与边缘麦克风进行交互的所有方法
|
* 定义了与边缘麦克风进行交互的所有方法
|
||||||
@ -25,6 +27,12 @@ public interface EdgeMicrophoneService {
|
|||||||
*/
|
*/
|
||||||
String stopRecord(EdgeCommonVO edgeCommonVO);
|
String stopRecord(EdgeCommonVO edgeCommonVO);
|
||||||
|
|
||||||
|
/** Starts a backend-local WAV recording from the robot microphone stream. */
|
||||||
|
String startWorkflowRecording(EdgeCommonVO edgeCommonVO);
|
||||||
|
|
||||||
|
/** Stops and finalizes a backend-local workflow recording. */
|
||||||
|
File stopWorkflowRecording(EdgeCommonVO edgeCommonVO, String recordingId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 暂停录音
|
* 暂停录音
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import cmvr.api.SystemCommand;
|
|||||||
import com.cmvr.device.domain.DeDeviceRegistration;
|
import com.cmvr.device.domain.DeDeviceRegistration;
|
||||||
import com.cmvr.edge.client.model.system.EdgeSystemJsonCommandVO;
|
import com.cmvr.edge.client.model.system.EdgeSystemJsonCommandVO;
|
||||||
import com.cmvr.edge.client.model.system.EdgeSystemUpdateParamsVO;
|
import com.cmvr.edge.client.model.system.EdgeSystemUpdateParamsVO;
|
||||||
|
import com.cmvr.edge.client.model.system.EdgeActionQueueVO;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@ -24,6 +25,8 @@ public interface EdgeSystemService {
|
|||||||
|
|
||||||
Common.JsonDeviceCommand.Feedback executeJsonCommand(EdgeSystemJsonCommandVO request);
|
Common.JsonDeviceCommand.Feedback executeJsonCommand(EdgeSystemJsonCommandVO request);
|
||||||
|
|
||||||
|
SystemCommand.ActionQueueCommand.Feedback executeActionQueue(EdgeActionQueueVO request);
|
||||||
|
|
||||||
|
|
||||||
public List<DeDeviceRegistration> deviceList(String robotId);
|
public List<DeDeviceRegistration> deviceList(String robotId);
|
||||||
|
|
||||||
|
|||||||
@ -22,9 +22,11 @@ import com.cmvr.system.service.ISysFileService;
|
|||||||
import com.google.protobuf.ByteString;
|
import com.google.protobuf.ByteString;
|
||||||
import io.grpc.Status;
|
import io.grpc.Status;
|
||||||
import io.grpc.stub.StreamObserver;
|
import io.grpc.stub.StreamObserver;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
@ -45,6 +47,7 @@ import java.awt.image.WritableRaster;
|
|||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.DataOutputStream;
|
import java.io.DataOutputStream;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
@ -53,7 +56,11 @@ import java.nio.ByteBuffer;
|
|||||||
import java.nio.ByteOrder;
|
import java.nio.ByteOrder;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Iterator;
|
import java.util.Iterator;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
@ -64,18 +71,26 @@ import java.util.concurrent.atomic.AtomicReference;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class EdgeCameraServiceImpl implements EdgeCameraService, EdgeStreamService {
|
public class EdgeCameraServiceImpl implements EdgeCameraService, EdgeStreamService {
|
||||||
|
|
||||||
|
private static final long RECORDING_FINALIZE_TIMEOUT_SECONDS = 15L;
|
||||||
|
private static final long RECORDING_FIRST_FRAME_TIMEOUT_SECONDS = 10L;
|
||||||
|
private static final long RECORDING_MAX_AGE_MILLIS = TimeUnit.HOURS.toMillis(6L);
|
||||||
|
|
||||||
private final GrpcServiceManager grpcServiceManager;
|
private final GrpcServiceManager grpcServiceManager;
|
||||||
private final ISysFileService sysFileService;
|
private final ISysFileService sysFileService;
|
||||||
|
|
||||||
private final MessagePushService messagePushService;
|
private final MessagePushService messagePushService;
|
||||||
|
|
||||||
|
private final Map<String, WorkflowRecordingSession> workflowRecordings = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CameraCommand.CameraState status(EdgeCommonVO edgeCommonVO) {
|
public CameraCommand.CameraState status(EdgeCommonVO edgeCommonVO) {
|
||||||
CameraServiceGrpc.CameraServiceBlockingStub stub = grpcServiceManager.getGrpcClient(edgeCommonVO.getRobotId(), CameraServiceGrpc.CameraServiceBlockingStub.class);
|
CameraServiceGrpc.CameraServiceBlockingStub stub = grpcServiceManager.getGrpcClient(edgeCommonVO.getRobotId(), CameraServiceGrpc.CameraServiceBlockingStub.class);
|
||||||
CameraCommand.GetCameraStateCommand.Request request = CameraCommand.GetCameraStateCommand.Request.newBuilder()
|
CameraCommand.GetCameraStateCommand.Request request = CameraCommand.GetCameraStateCommand.Request.newBuilder()
|
||||||
.setHeader(EdgeCommonUtil.buildRequest(edgeCommonVO.getDeviceId()))
|
.setHeader(EdgeCommonUtil.buildRequest(edgeCommonVO.getDeviceId()))
|
||||||
.build();
|
.build();
|
||||||
return executeGrpcCall(() -> stub.getStatus(request)).getState();
|
CameraCommand.GetCameraStateCommand.Feedback feedback = executeGrpcCall(() -> stub.getStatus(request));
|
||||||
|
requireSuccess(feedback.getHeader(), "获取相机状态");
|
||||||
|
return feedback.getState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -94,6 +109,7 @@ public class EdgeCameraServiceImpl implements EdgeCameraService, EdgeStreamServi
|
|||||||
.setHeader(EdgeCommonUtil.buildRequest(edgeCommonVO.getDeviceId()))
|
.setHeader(EdgeCommonUtil.buildRequest(edgeCommonVO.getDeviceId()))
|
||||||
.build();
|
.build();
|
||||||
Common.CommandHeader.Feedback header = executeGrpcCall(() -> stub.startCamera(request)).getHeader();
|
Common.CommandHeader.Feedback header = executeGrpcCall(() -> stub.startCamera(request)).getHeader();
|
||||||
|
requireSuccess(header, "开启相机");
|
||||||
return JSON.toJSONString(header);
|
return JSON.toJSONString(header);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,7 +119,9 @@ public class EdgeCameraServiceImpl implements EdgeCameraService, EdgeStreamServi
|
|||||||
CameraCommand.StopCameraCommand.Request request = CameraCommand.StopCameraCommand.Request.newBuilder()
|
CameraCommand.StopCameraCommand.Request request = CameraCommand.StopCameraCommand.Request.newBuilder()
|
||||||
.setHeader(EdgeCommonUtil.buildRequest(edgeCommonVO.getDeviceId()))
|
.setHeader(EdgeCommonUtil.buildRequest(edgeCommonVO.getDeviceId()))
|
||||||
.build();
|
.build();
|
||||||
|
abortWorkflowRecordings(edgeCommonVO);
|
||||||
Common.CommandHeader.Feedback header = executeGrpcCall(() -> stub.stopCamera(request)).getHeader();
|
Common.CommandHeader.Feedback header = executeGrpcCall(() -> stub.stopCamera(request)).getHeader();
|
||||||
|
requireSuccess(header, "关闭相机");
|
||||||
return JSON.toJSONString(header);
|
return JSON.toJSONString(header);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,20 +136,23 @@ public class EdgeCameraServiceImpl implements EdgeCameraService, EdgeStreamServi
|
|||||||
.setHeader(EdgeCommonUtil.buildRequest(edgeCommonVO.getDeviceId()))
|
.setHeader(EdgeCommonUtil.buildRequest(edgeCommonVO.getDeviceId()))
|
||||||
.build();
|
.build();
|
||||||
CameraCommand.GetRGBImageCommand.Feedback feedback = executeGrpcCall(() -> stub.getRGBImage(request));
|
CameraCommand.GetRGBImageCommand.Feedback feedback = executeGrpcCall(() -> stub.getRGBImage(request));
|
||||||
|
requireSuccess(feedback.getHeader(), "获取相机图片");
|
||||||
CameraCommand.FrameData frame = feedback.getColorFrame();
|
CameraCommand.FrameData frame = feedback.getColorFrame();
|
||||||
if (!feedback.hasColorFrame() || frame.getWidth() <= 0 || frame.getHeight() <= 0) {
|
if (!feedback.hasColorFrame() || frame.getWidth() <= 0 || frame.getHeight() <= 0) {
|
||||||
throw new GlobalException("响应图片数据无效");
|
throw new GlobalException("获取相机图片失败: 响应图片数据无效");
|
||||||
}
|
}
|
||||||
String filePath = saveImage(frame.getData(), frame.getWidth(), frame.getHeight());
|
String filePath = saveColorImage(frame);
|
||||||
try {
|
try {
|
||||||
MultipartFile file = convertFileToMultipartFile(filePath);
|
MultipartFile file = convertFileToMultipartFile(filePath);
|
||||||
return sysFileService.uploadFile(file, FileType.IMAGE.code());
|
String imageUrl = sysFileService.uploadFile(file, FileType.IMAGE.code());
|
||||||
|
if (StrUtil.isBlank(imageUrl)) {
|
||||||
|
throw new GlobalException("获取相机图片失败: 文件上传未返回图片地址");
|
||||||
|
}
|
||||||
|
return imageUrl;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new GlobalException("图片上传失败: " + e.getMessage());
|
throw new GlobalException("获取相机图片失败: " + e.getMessage());
|
||||||
} finally {
|
} finally {
|
||||||
deleteTempFile(filePath);
|
deleteTempFile(filePath);
|
||||||
// todo 暂时不关闭摄像头
|
|
||||||
// stop(robotId, deviceId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,6 +207,70 @@ public class EdgeCameraServiceImpl implements EdgeCameraService, EdgeStreamServi
|
|||||||
return JSON.toJSONString(header);
|
return JSON.toJSONString(header);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String startWorkflowRecording(EdgeCommonVO edgeCommonVO) {
|
||||||
|
String recordingId = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
File outputFile;
|
||||||
|
try {
|
||||||
|
outputFile = File.createTempFile("cmvr-recording-" + recordingId + "-", ".mp4");
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw new GlobalException("创建录像临时文件失败: " + exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkflowRecordingSession session = new WorkflowRecordingSession(
|
||||||
|
recordingId, edgeCommonVO.getRobotId(), edgeCommonVO.getDeviceId(), outputFile);
|
||||||
|
workflowRecordings.put(recordingId, session);
|
||||||
|
try {
|
||||||
|
session.start();
|
||||||
|
return recordingId;
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
workflowRecordings.remove(recordingId);
|
||||||
|
session.abort();
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public File stopWorkflowRecording(EdgeCommonVO edgeCommonVO, String recordingId) {
|
||||||
|
WorkflowRecordingSession session = workflowRecordings.remove(recordingId);
|
||||||
|
if (session == null) {
|
||||||
|
throw new GlobalException("录像会话不存在或已结束: " + recordingId);
|
||||||
|
}
|
||||||
|
if (!session.matches(edgeCommonVO.getRobotId(), edgeCommonVO.getDeviceId())) {
|
||||||
|
session.abort();
|
||||||
|
throw new GlobalException("开始录像和结束录像使用的机器人或摄像头不一致");
|
||||||
|
}
|
||||||
|
return session.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void abortWorkflowRecordings(EdgeCommonVO edgeCommonVO) {
|
||||||
|
workflowRecordings.forEach((recordingId, session) -> {
|
||||||
|
if (session.matches(edgeCommonVO.getRobotId(), edgeCommonVO.getDeviceId())
|
||||||
|
&& workflowRecordings.remove(recordingId, session)) {
|
||||||
|
log.info("关闭相机并终止关联的工作流录像: recordingId={}, robotId={}, deviceId={}",
|
||||||
|
recordingId, session.robotId, session.deviceId);
|
||||||
|
session.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelay = 60_000L)
|
||||||
|
public void cleanExpiredWorkflowRecordings() {
|
||||||
|
workflowRecordings.forEach((recordingId, session) -> {
|
||||||
|
if (session.isExpired() && workflowRecordings.remove(recordingId, session)) {
|
||||||
|
log.warn("清理超时的工作流录像会话: recordingId={}, robotId={}, deviceId={}",
|
||||||
|
recordingId, session.robotId, session.deviceId);
|
||||||
|
session.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
public void closeWorkflowRecordings() {
|
||||||
|
workflowRecordings.forEach((recordingId, session) -> session.abort());
|
||||||
|
workflowRecordings.clear();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getRGBDImages(EdgeCommonVO edgeCommonVO) {
|
public String getRGBDImages(EdgeCommonVO edgeCommonVO) {
|
||||||
if (!checkStatus(edgeCommonVO)) {
|
if (!checkStatus(edgeCommonVO)) {
|
||||||
@ -483,6 +568,228 @@ public class EdgeCameraServiceImpl implements EdgeCameraService, EdgeStreamServi
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class WorkflowRecordingSession {
|
||||||
|
private final String recordingId;
|
||||||
|
private final String robotId;
|
||||||
|
private final String deviceId;
|
||||||
|
private final File outputFile;
|
||||||
|
private final long startedAtMillis = System.currentTimeMillis();
|
||||||
|
private final AtomicBoolean stopped = new AtomicBoolean(false);
|
||||||
|
private final AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||||
|
private final AtomicReference<StreamObserver<CameraCommand.GetRGBImageStreamCommand.Request>> requestObserver =
|
||||||
|
new AtomicReference<>();
|
||||||
|
|
||||||
|
private FileOutputStream outputStream;
|
||||||
|
private H265Fmp4Transcoder transcoder;
|
||||||
|
private long firstFrameReadyAtMillis;
|
||||||
|
|
||||||
|
private WorkflowRecordingSession(String recordingId, String robotId, String deviceId, File outputFile) {
|
||||||
|
this.recordingId = recordingId;
|
||||||
|
this.robotId = robotId;
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
this.outputFile = outputFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void start() {
|
||||||
|
EdgeCommonVO target = new EdgeCommonVO();
|
||||||
|
target.setRobotId(robotId);
|
||||||
|
target.setDeviceId(deviceId);
|
||||||
|
CameraCommand.CameraState cameraState = status(target);
|
||||||
|
if (cameraState.getIsError()) {
|
||||||
|
throw new GlobalException("Camera is in error state");
|
||||||
|
}
|
||||||
|
if (!cameraState.getIsOpened() || !cameraState.getIsStreaming()) {
|
||||||
|
EdgeCameraServiceImpl.this.start(target);
|
||||||
|
cameraState = status(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
outputStream = new FileOutputStream(outputFile);
|
||||||
|
transcoder = new H265Fmp4Transcoder(
|
||||||
|
"recording-" + recordingId,
|
||||||
|
cameraState.getFps(),
|
||||||
|
chunk -> writeChunk(chunk),
|
||||||
|
throwable -> {
|
||||||
|
recordFailure(throwable);
|
||||||
|
cancelStream();
|
||||||
|
});
|
||||||
|
transcoder.start();
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw new GlobalException("初始化录像失败: " + exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
CameraServiceGrpc.CameraServiceStub stub = grpcServiceManager.getGrpcClient(
|
||||||
|
robotId, CameraServiceGrpc.CameraServiceStub.class);
|
||||||
|
StreamObserver<CameraCommand.GetRGBImageStreamCommand.Feedback> responseObserver =
|
||||||
|
new StreamObserver<CameraCommand.GetRGBImageStreamCommand.Feedback>() {
|
||||||
|
@Override
|
||||||
|
public void onNext(CameraCommand.GetRGBImageStreamCommand.Feedback value) {
|
||||||
|
if (!value.getHeader().getSuccess()) {
|
||||||
|
recordFailure(new GlobalException(value.getHeader().getErrorMessage()));
|
||||||
|
cancelStream();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
CameraCommand.FrameData frame = value.getColorFrame();
|
||||||
|
transcoder.write(frame.getData().toByteArray(), frame.getIsKeyFrame(), frame.getCodec());
|
||||||
|
} catch (IOException exception) {
|
||||||
|
recordFailure(exception);
|
||||||
|
cancelStream();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Throwable throwable) {
|
||||||
|
if (!stopped.get() && Status.fromThrowable(throwable).getCode() != Status.Code.CANCELLED) {
|
||||||
|
recordFailure(throwable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCompleted() {
|
||||||
|
if (!stopped.get()) {
|
||||||
|
recordFailure(new GlobalException("摄像头视频流提前结束"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
StreamObserver<CameraCommand.GetRGBImageStreamCommand.Request> observer =
|
||||||
|
stub.getRGBImageStream(responseObserver);
|
||||||
|
requestObserver.set(observer);
|
||||||
|
observer.onNext(CameraCommand.GetRGBImageStreamCommand.Request.newBuilder()
|
||||||
|
.setHeader(EdgeCommonUtil.buildRequest(deviceId))
|
||||||
|
.setEof(false)
|
||||||
|
.build());
|
||||||
|
awaitFirstRecordedFrame();
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
recordFailure(exception);
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void awaitFirstRecordedFrame() {
|
||||||
|
try {
|
||||||
|
if (!transcoder.awaitFirstFrame(RECORDING_FIRST_FRAME_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||||
|
throw new GlobalException("启动录像失败: 10秒内未收到可写入的视频帧");
|
||||||
|
}
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new GlobalException("等待录像首帧时被中断");
|
||||||
|
}
|
||||||
|
Throwable transcoderFailure = failure.get();
|
||||||
|
if (transcoderFailure != null) {
|
||||||
|
throw new GlobalException("启动录像失败: " + transcoderFailure.getMessage());
|
||||||
|
}
|
||||||
|
firstFrameReadyAtMillis = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matches(String expectedRobotId, String expectedDeviceId) {
|
||||||
|
return robotId.equals(expectedRobotId) && deviceId.equals(expectedDeviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isExpired() {
|
||||||
|
return System.currentTimeMillis() - startedAtMillis > RECORDING_MAX_AGE_MILLIS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private File finish() {
|
||||||
|
if (!stopped.compareAndSet(false, true)) {
|
||||||
|
throw new GlobalException("录像会话已结束: " + recordingId);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
cancelStream();
|
||||||
|
if (transcoder != null) {
|
||||||
|
transcoder.close();
|
||||||
|
try {
|
||||||
|
if (!transcoder.awaitTermination(RECORDING_FINALIZE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||||
|
throw new GlobalException("录像文件封装超时");
|
||||||
|
}
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new GlobalException("等待录像文件封装时被中断");
|
||||||
|
} finally {
|
||||||
|
closeOutputStream();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
closeOutputStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
Throwable sessionFailure = failure.get();
|
||||||
|
if (sessionFailure != null) {
|
||||||
|
throw new GlobalException("录像失败: " + sessionFailure.getMessage());
|
||||||
|
}
|
||||||
|
if (!outputFile.isFile() || outputFile.length() == 0L) {
|
||||||
|
throw new GlobalException("录像文件为空,请确认摄像头视频流正常");
|
||||||
|
}
|
||||||
|
log.info("Workflow recording finalized: recordingId={}, robotId={}, deviceId={}, "
|
||||||
|
+ "wallDurationMs={}, mediaTimelineMs={}, writtenFrames={}, fileBytes={}",
|
||||||
|
recordingId, robotId, deviceId,
|
||||||
|
firstFrameReadyAtMillis == 0L ? 0L : System.currentTimeMillis() - firstFrameReadyAtMillis,
|
||||||
|
Math.max(0L, transcoder.getLastWrittenTimestampMicros() / 1_000L),
|
||||||
|
transcoder.getWrittenFrameCount(), outputFile.length());
|
||||||
|
return outputFile;
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
deleteOutputFile();
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void abort() {
|
||||||
|
stopped.set(true);
|
||||||
|
cancelStream();
|
||||||
|
if (transcoder != null) {
|
||||||
|
transcoder.close();
|
||||||
|
try {
|
||||||
|
transcoder.awaitTermination(RECORDING_FINALIZE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeOutputStream();
|
||||||
|
deleteOutputFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeChunk(byte[] chunk) {
|
||||||
|
try {
|
||||||
|
outputStream.write(chunk);
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw new CameraStreamWriteException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordFailure(Throwable throwable) {
|
||||||
|
failure.compareAndSet(null, throwable);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cancelStream() {
|
||||||
|
StreamObserver<CameraCommand.GetRGBImageStreamCommand.Request> observer = requestObserver.getAndSet(null);
|
||||||
|
if (observer != null) {
|
||||||
|
try {
|
||||||
|
observer.onError(Status.CANCELLED.withDescription("workflow recording stopped").asRuntimeException());
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeOutputStream() {
|
||||||
|
if (outputStream != null) {
|
||||||
|
try {
|
||||||
|
outputStream.close();
|
||||||
|
} catch (IOException exception) {
|
||||||
|
recordFailure(exception);
|
||||||
|
} finally {
|
||||||
|
outputStream = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteOutputFile() {
|
||||||
|
if (outputFile.exists() && !outputFile.delete()) {
|
||||||
|
log.warn("删除录像临时文件失败: {}", outputFile.getAbsolutePath());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
private interface GrpcCall<T> {
|
private interface GrpcCall<T> {
|
||||||
T call() throws Exception;
|
T call() throws Exception;
|
||||||
@ -496,6 +803,15 @@ public class EdgeCameraServiceImpl implements EdgeCameraService, EdgeStreamServi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void requireSuccess(Common.CommandHeader.Feedback header, String operation) {
|
||||||
|
if (header == null || !header.getSuccess()) {
|
||||||
|
String detail = header == null || StrUtil.isBlank(header.getErrorMessage())
|
||||||
|
? "机器人未返回成功状态"
|
||||||
|
: header.getErrorMessage();
|
||||||
|
throw new GlobalException(operation + "失败: " + detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String rgbToBase64Bmp(byte[] rgbData, int width, int height) throws IOException {
|
private String rgbToBase64Bmp(byte[] rgbData, int width, int height) throws IOException {
|
||||||
int rowSize = (width * 3 + 3) & ~3; // BMP 行对齐
|
int rowSize = (width * 3 + 3) & ~3; // BMP 行对齐
|
||||||
int imageSize = rowSize * height;
|
int imageSize = rowSize * height;
|
||||||
@ -580,6 +896,42 @@ public class EdgeCameraServiceImpl implements EdgeCameraService, EdgeStreamServi
|
|||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String saveColorImage(CameraCommand.FrameData frame) {
|
||||||
|
int pixelCount = Math.multiplyExact(frame.getWidth(), frame.getHeight());
|
||||||
|
byte[] source = frame.getData().toByteArray();
|
||||||
|
switch (frame.getType()) {
|
||||||
|
case U8C1:
|
||||||
|
requireFrameBytes(source, pixelCount, frame.getType());
|
||||||
|
return saveImage(ByteString.copyFrom(source, 0, pixelCount),
|
||||||
|
frame.getWidth(), frame.getHeight(), true);
|
||||||
|
case U8C3:
|
||||||
|
int colorBytes = Math.multiplyExact(pixelCount, 3);
|
||||||
|
requireFrameBytes(source, colorBytes, frame.getType());
|
||||||
|
return saveImage(ByteString.copyFrom(source, 0, colorBytes),
|
||||||
|
frame.getWidth(), frame.getHeight());
|
||||||
|
case U16C3:
|
||||||
|
int sourceBytes = Math.multiplyExact(pixelCount, 6);
|
||||||
|
requireFrameBytes(source, sourceBytes, frame.getType());
|
||||||
|
byte[] eightBitColor = new byte[Math.multiplyExact(pixelCount, 3)];
|
||||||
|
for (int targetIndex = 0, sourceIndex = 0;
|
||||||
|
targetIndex < eightBitColor.length;
|
||||||
|
targetIndex++, sourceIndex += Short.BYTES) {
|
||||||
|
// Camera frames use little-endian unsigned 16-bit channels. Keep the high byte for JPEG.
|
||||||
|
eightBitColor[targetIndex] = source[sourceIndex + 1];
|
||||||
|
}
|
||||||
|
return saveImage(ByteString.copyFrom(eightBitColor), frame.getWidth(), frame.getHeight());
|
||||||
|
default:
|
||||||
|
throw new GlobalException("获取相机图片失败: 不支持的彩色帧类型 " + frame.getType());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireFrameBytes(byte[] source, int requiredBytes, CameraCommand.FrameData.FrameType type) {
|
||||||
|
if (source.length < requiredBytes) {
|
||||||
|
throw new GlobalException("获取相机图片失败: " + type + " 图片数据长度不足,期望至少 "
|
||||||
|
+ requiredBytes + " 字节,实际 " + source.length + " 字节");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String saveImage(ByteString pixelData, int width, int height, boolean isDepth) {
|
private String saveImage(ByteString pixelData, int width, int height, boolean isDepth) {
|
||||||
File tempDir = new File(System.getProperty("java.io.tmpdir"));
|
File tempDir = new File(System.getProperty("java.io.tmpdir"));
|
||||||
FileUtil.mkdir(tempDir);
|
FileUtil.mkdir(tempDir);
|
||||||
|
|||||||
@ -1,19 +1,37 @@
|
|||||||
package com.cmvr.edge.client.service.impl;
|
package com.cmvr.edge.client.service.impl;
|
||||||
|
|
||||||
|
import cmvr.api.Common;
|
||||||
import cmvr.api.MicPhoneServiceGrpc;
|
import cmvr.api.MicPhoneServiceGrpc;
|
||||||
import cmvr.api.MicrophoneCommand;
|
import cmvr.api.MicrophoneCommand;
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
import com.alibaba.fastjson2.JSON;
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import com.cmvr.common.exception.GlobalException;
|
||||||
import com.cmvr.edge.client.manage.GrpcServiceManager;
|
import com.cmvr.edge.client.manage.GrpcServiceManager;
|
||||||
import com.cmvr.edge.client.model.EdgeCommonVO;
|
import com.cmvr.edge.client.model.EdgeCommonVO;
|
||||||
import com.cmvr.edge.client.model.microphone.EdgeMicrophoneVolumeVO;
|
import com.cmvr.edge.client.model.microphone.EdgeMicrophoneVolumeVO;
|
||||||
import com.cmvr.edge.client.service.EdgeMicrophoneService;
|
import com.cmvr.edge.client.service.EdgeMicrophoneService;
|
||||||
import com.cmvr.edge.client.utils.EdgeCommonUtil;
|
import com.cmvr.edge.client.utils.EdgeCommonUtil;
|
||||||
|
import io.grpc.Status;
|
||||||
|
import io.grpc.stub.ClientCallStreamObserver;
|
||||||
|
import io.grpc.stub.ClientResponseObserver;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.RandomAccessFile;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 边缘麦克风服务实现类
|
* 边缘麦克风服务实现类
|
||||||
* 用于与边缘麦克风进行gRPC通信,控制麦克风录音和状态
|
* 用于与边缘麦克风进行gRPC通信,控制麦克风录音和状态
|
||||||
@ -22,6 +40,19 @@ import org.springframework.stereotype.Service;
|
|||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class EdgeMicrophoneServiceImpl implements EdgeMicrophoneService {
|
public class EdgeMicrophoneServiceImpl implements EdgeMicrophoneService {
|
||||||
|
private static final int WAV_HEADER_BYTES = 44;
|
||||||
|
private static final int PCM_BITS_PER_SAMPLE = 16;
|
||||||
|
private static final int DEFAULT_SAMPLE_RATE = 48_000;
|
||||||
|
private static final int DEFAULT_CHANNELS = 1;
|
||||||
|
private static final String PCM_CODEC = "PCM_S16LE";
|
||||||
|
private static final int NORMALIZED_PEAK_AMPLITUDE = 26_000;
|
||||||
|
private static final double MAX_RECORDING_GAIN = 8.0D;
|
||||||
|
private static final int PCM_PROCESS_BUFFER_BYTES = 64 * 1024;
|
||||||
|
private static final String RECORDING_STREAM_CONFLICT =
|
||||||
|
"cannot start streaming while microphone recording is active";
|
||||||
|
private static final long RECORDING_MAX_AGE_MILLIS = TimeUnit.HOURS.toMillis(6L);
|
||||||
|
private static final long FIRST_AUDIO_FRAME_TIMEOUT_SECONDS = 5L;
|
||||||
|
private static final int RECORDING_STOP_CONFIRM_ATTEMPTS = 5;
|
||||||
/**
|
/**
|
||||||
* 日志记录器
|
* 日志记录器
|
||||||
* 用于记录服务运行过程中的各种状态和错误信息
|
* 用于记录服务运行过程中的各种状态和错误信息
|
||||||
@ -34,6 +65,9 @@ public class EdgeMicrophoneServiceImpl implements EdgeMicrophoneService {
|
|||||||
*/
|
*/
|
||||||
private final GrpcServiceManager grpcServiceManager;
|
private final GrpcServiceManager grpcServiceManager;
|
||||||
|
|
||||||
|
private final Map<String, WorkflowAudioRecordingSession> workflowRecordings = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, String> workflowRecordingTargets = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取麦克风当前状态
|
* 获取麦克风当前状态
|
||||||
* 通过gRPC调用获取麦克风的运行状态,包括是否正在录音、音量等信息
|
* 通过gRPC调用获取麦克风的运行状态,包括是否正在录音、音量等信息
|
||||||
@ -41,16 +75,10 @@ public class EdgeMicrophoneServiceImpl implements EdgeMicrophoneService {
|
|||||||
@Override
|
@Override
|
||||||
public String getStatus(EdgeCommonVO edgeCommonVO) {
|
public String getStatus(EdgeCommonVO edgeCommonVO) {
|
||||||
try {
|
try {
|
||||||
MicPhoneServiceGrpc.MicPhoneServiceBlockingStub stub = grpcServiceManager.getGrpcClient(edgeCommonVO.getRobotId(), MicPhoneServiceGrpc.MicPhoneServiceBlockingStub.class);
|
return JSON.toJSONString(getMicrophoneState(edgeCommonVO));
|
||||||
MicrophoneCommand.GetMicStateCommand.Request request = MicrophoneCommand.GetMicStateCommand.Request.newBuilder()
|
|
||||||
.setHeader(EdgeCommonUtil.buildRequest(edgeCommonVO.getDeviceId()))
|
|
||||||
.build();
|
|
||||||
// 直接返回完整的 Feedback
|
|
||||||
MicrophoneCommand.GetMicStateCommand.Feedback feedback = stub.getStatus(request);
|
|
||||||
return JSON.toJSONString(feedback.getState());
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("获取麦克风状态失败", e);
|
logger.error("获取麦克风状态失败", e);
|
||||||
throw new RuntimeException("获取麦克风状态失败", e);
|
throw operationFailure("获取麦克风状态", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,24 +88,19 @@ public class EdgeMicrophoneServiceImpl implements EdgeMicrophoneService {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public String startRecord(EdgeCommonVO edgeCommonVO) {
|
public String startRecord(EdgeCommonVO edgeCommonVO) {
|
||||||
String deviceId = edgeCommonVO.getDeviceId();
|
|
||||||
try {
|
try {
|
||||||
// 获取gRPC客户端存根
|
if (workflowRecordingTargets.containsKey(
|
||||||
MicPhoneServiceGrpc.MicPhoneServiceBlockingStub stub = grpcServiceManager.getGrpcClient(edgeCommonVO.getRobotId(), MicPhoneServiceGrpc.MicPhoneServiceBlockingStub.class);
|
recordingTargetKey(edgeCommonVO.getRobotId(), edgeCommonVO.getDeviceId()))) {
|
||||||
String filePath = StrUtil.format("{}/{}_{}.wav", "/home/share/assets/video", deviceId, System.currentTimeMillis());
|
throw new GlobalException("该机器人麦克风正在被工作流录音占用");
|
||||||
|
}
|
||||||
// 构建请求消息
|
String filePath = StrUtil.format("{}/{}_{}.wav", "/home/share/assets/video",
|
||||||
MicrophoneCommand.StartMicRecordingCommand.Request request = MicrophoneCommand.StartMicRecordingCommand.Request.newBuilder()
|
edgeCommonVO.getDeviceId(),
|
||||||
.setHeader(EdgeCommonUtil.buildRequest(deviceId))
|
UUID.randomUUID().toString().replace("-", ""));
|
||||||
.setFilePath(filePath)
|
startTerminalRecording(edgeCommonVO, filePath);
|
||||||
.build();
|
|
||||||
|
|
||||||
// 发送请求
|
|
||||||
stub.startRecord(request);
|
|
||||||
return filePath;
|
return filePath;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("开始录音失败", e);
|
logger.error("开始录音失败", e);
|
||||||
throw new RuntimeException("开始录音失败", e);
|
throw operationFailure("开始录音", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,23 +111,98 @@ public class EdgeMicrophoneServiceImpl implements EdgeMicrophoneService {
|
|||||||
@Override
|
@Override
|
||||||
public String stopRecord(EdgeCommonVO edgeCommonVO) {
|
public String stopRecord(EdgeCommonVO edgeCommonVO) {
|
||||||
try {
|
try {
|
||||||
// 获取gRPC客户端存根
|
abortWorkflowRecordings(edgeCommonVO);
|
||||||
MicPhoneServiceGrpc.MicPhoneServiceBlockingStub stub = grpcServiceManager.getGrpcClient(edgeCommonVO.getRobotId(), MicPhoneServiceGrpc.MicPhoneServiceBlockingStub.class);
|
return JSON.toJSONString(stopTerminalRecording(edgeCommonVO));
|
||||||
|
|
||||||
// 构建请求消息
|
|
||||||
MicrophoneCommand.StopMicRecordingCommand.Request request = MicrophoneCommand.StopMicRecordingCommand.Request.newBuilder()
|
|
||||||
.setHeader(EdgeCommonUtil.buildRequest(edgeCommonVO.getDeviceId()))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// 发送请求
|
|
||||||
MicrophoneCommand.StopMicRecordingCommand.Feedback feedback = stub.stopRecord(request);
|
|
||||||
return JSON.toJSONString(feedback.getHeader());
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("停止录音失败", e);
|
logger.error("停止录音失败", e);
|
||||||
throw new RuntimeException("停止录音失败", e);
|
throw operationFailure("结束录音", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String startWorkflowRecording(EdgeCommonVO edgeCommonVO) {
|
||||||
|
String recordingId = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
String targetKey = recordingTargetKey(edgeCommonVO.getRobotId(), edgeCommonVO.getDeviceId());
|
||||||
|
String activeRecordingId = workflowRecordingTargets.putIfAbsent(targetKey, recordingId);
|
||||||
|
if (activeRecordingId != null) {
|
||||||
|
throw new GlobalException("该机器人麦克风已有进行中的录音,请先结束当前录音");
|
||||||
|
}
|
||||||
|
File outputFile;
|
||||||
|
try {
|
||||||
|
outputFile = File.createTempFile("cmvr-audio-" + recordingId + "-", ".wav");
|
||||||
|
} catch (IOException exception) {
|
||||||
|
workflowRecordingTargets.remove(targetKey, recordingId);
|
||||||
|
throw new GlobalException("创建录音临时文件失败: " + exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkflowAudioRecordingSession session = new WorkflowAudioRecordingSession(
|
||||||
|
recordingId, edgeCommonVO.getRobotId(), edgeCommonVO.getDeviceId(), outputFile);
|
||||||
|
workflowRecordings.put(recordingId, session);
|
||||||
|
try {
|
||||||
|
prepareWorkflowStreaming(edgeCommonVO);
|
||||||
|
session.start();
|
||||||
|
return recordingId;
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
workflowRecordings.remove(recordingId, session);
|
||||||
|
workflowRecordingTargets.remove(targetKey, recordingId);
|
||||||
|
session.abort();
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public File stopWorkflowRecording(EdgeCommonVO edgeCommonVO, String recordingId) {
|
||||||
|
WorkflowAudioRecordingSession session = workflowRecordings.remove(recordingId);
|
||||||
|
if (session == null) {
|
||||||
|
throw new GlobalException("录音会话不存在或已结束: " + recordingId);
|
||||||
|
}
|
||||||
|
if (!session.matches(edgeCommonVO.getRobotId(), edgeCommonVO.getDeviceId())) {
|
||||||
|
workflowRecordingTargets.remove(session.targetKey(), recordingId);
|
||||||
|
session.abort();
|
||||||
|
throw new GlobalException("开始录音和结束录音使用的机器人或麦克风不一致");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
session.prepareToFinish();
|
||||||
|
return session.finish();
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
session.abort();
|
||||||
|
throw exception;
|
||||||
|
} finally {
|
||||||
|
workflowRecordingTargets.remove(session.targetKey(), recordingId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void abortWorkflowRecordings(EdgeCommonVO edgeCommonVO) {
|
||||||
|
workflowRecordings.forEach((recordingId, session) -> {
|
||||||
|
if (session.matches(edgeCommonVO.getRobotId(), edgeCommonVO.getDeviceId())
|
||||||
|
&& workflowRecordings.remove(recordingId, session)) {
|
||||||
|
workflowRecordingTargets.remove(session.targetKey(), recordingId);
|
||||||
|
logger.info("停止麦克风并终止关联的工作流录音: recordingId={}, robotId={}, deviceId={}",
|
||||||
|
recordingId, session.robotId, session.deviceId);
|
||||||
|
session.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelay = 60_000L)
|
||||||
|
public void cleanExpiredWorkflowRecordings() {
|
||||||
|
workflowRecordings.forEach((recordingId, session) -> {
|
||||||
|
if (session.isExpired() && workflowRecordings.remove(recordingId, session)) {
|
||||||
|
workflowRecordingTargets.remove(session.targetKey(), recordingId);
|
||||||
|
logger.warn("清理超时的工作流录音会话: recordingId={}, robotId={}, deviceId={}",
|
||||||
|
recordingId, session.robotId, session.deviceId);
|
||||||
|
session.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
public void closeWorkflowRecordings() {
|
||||||
|
workflowRecordings.forEach((recordingId, session) -> session.abort());
|
||||||
|
workflowRecordings.clear();
|
||||||
|
workflowRecordingTargets.clear();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 暂停录音
|
* 暂停录音
|
||||||
* 通过gRPC调用暂停麦克风的当前录音
|
* 通过gRPC调用暂停麦克风的当前录音
|
||||||
@ -198,4 +296,470 @@ public class EdgeMicrophoneServiceImpl implements EdgeMicrophoneService {
|
|||||||
throw new RuntimeException("获取麦克风音量失败", e);
|
throw new RuntimeException("获取麦克风音量失败", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void requireSuccess(Common.CommandHeader.Feedback header, String operation) {
|
||||||
|
if (header == null || !header.getSuccess()) {
|
||||||
|
String detail = header == null || StrUtil.isBlank(header.getErrorMessage())
|
||||||
|
? "机器人未返回成功状态"
|
||||||
|
: header.getErrorMessage();
|
||||||
|
throw new GlobalException(operation + "失败: " + detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private MicrophoneCommand.MicState getMicrophoneState(EdgeCommonVO edgeCommonVO) {
|
||||||
|
MicPhoneServiceGrpc.MicPhoneServiceBlockingStub stub = grpcServiceManager.getGrpcClient(
|
||||||
|
edgeCommonVO.getRobotId(), MicPhoneServiceGrpc.MicPhoneServiceBlockingStub.class);
|
||||||
|
MicrophoneCommand.GetMicStateCommand.Request request =
|
||||||
|
MicrophoneCommand.GetMicStateCommand.Request.newBuilder()
|
||||||
|
.setHeader(EdgeCommonUtil.buildRequest(edgeCommonVO.getDeviceId()))
|
||||||
|
.build();
|
||||||
|
MicrophoneCommand.GetMicStateCommand.Feedback feedback = stub.getStatus(request);
|
||||||
|
requireSuccess(feedback.getHeader(), "获取麦克风状态");
|
||||||
|
return feedback.getState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void prepareWorkflowStreaming(EdgeCommonVO edgeCommonVO) {
|
||||||
|
MicrophoneCommand.MicState state = getMicrophoneState(edgeCommonVO);
|
||||||
|
if (!state.getIsRecording()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!StrUtil.containsIgnoreCase(state.getErrorMessage(), RECORDING_STREAM_CONFLICT)) {
|
||||||
|
throw new GlobalException("麦克风正在执行文件录音,请先结束当前录音再启动工作流");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn("工作流启动前发现麦克风仍处于文件录音状态,将先停止遗留录音: robotId={}, deviceId={}, error={}",
|
||||||
|
edgeCommonVO.getRobotId(), edgeCommonVO.getDeviceId(), state.getErrorMessage());
|
||||||
|
stopTerminalRecording(edgeCommonVO);
|
||||||
|
|
||||||
|
for (int attempt = 0; attempt < RECORDING_STOP_CONFIRM_ATTEMPTS; attempt++) {
|
||||||
|
state = getMicrophoneState(edgeCommonVO);
|
||||||
|
if (!state.getIsRecording()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
TimeUnit.MILLISECONDS.sleep(100L);
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new GlobalException("等待麦克风停止文件录音时被中断");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new GlobalException("麦克风文件录音未能停止,无法启动工作流录音");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startTerminalRecording(EdgeCommonVO edgeCommonVO, String filePath) {
|
||||||
|
MicPhoneServiceGrpc.MicPhoneServiceBlockingStub stub = grpcServiceManager.getGrpcClient(
|
||||||
|
edgeCommonVO.getRobotId(), MicPhoneServiceGrpc.MicPhoneServiceBlockingStub.class);
|
||||||
|
MicrophoneCommand.StartMicRecordingCommand.Request request =
|
||||||
|
MicrophoneCommand.StartMicRecordingCommand.Request.newBuilder()
|
||||||
|
.setHeader(EdgeCommonUtil.buildRequest(edgeCommonVO.getDeviceId()))
|
||||||
|
.setFilePath(filePath)
|
||||||
|
.build();
|
||||||
|
MicrophoneCommand.StartMicRecordingCommand.Feedback feedback = stub.startRecord(request);
|
||||||
|
requireSuccess(feedback.getHeader(), "开始录音");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Common.CommandHeader.Feedback stopTerminalRecording(EdgeCommonVO edgeCommonVO) {
|
||||||
|
MicPhoneServiceGrpc.MicPhoneServiceBlockingStub stub = grpcServiceManager.getGrpcClient(
|
||||||
|
edgeCommonVO.getRobotId(), MicPhoneServiceGrpc.MicPhoneServiceBlockingStub.class);
|
||||||
|
MicrophoneCommand.StopMicRecordingCommand.Request request =
|
||||||
|
MicrophoneCommand.StopMicRecordingCommand.Request.newBuilder()
|
||||||
|
.setHeader(EdgeCommonUtil.buildRequest(edgeCommonVO.getDeviceId()))
|
||||||
|
.build();
|
||||||
|
Common.CommandHeader.Feedback header = stub.stopRecord(request).getHeader();
|
||||||
|
requireSuccess(header, "结束录音");
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String recordingTargetKey(String robotId, String deviceId) {
|
||||||
|
return String.valueOf(robotId) + '\0' + String.valueOf(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private GlobalException operationFailure(String operation, Exception exception) {
|
||||||
|
if (exception instanceof GlobalException globalException) {
|
||||||
|
return globalException;
|
||||||
|
}
|
||||||
|
return new GlobalException(operation + "失败: " + exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class WorkflowAudioRecordingSession {
|
||||||
|
private final String recordingId;
|
||||||
|
private final String robotId;
|
||||||
|
private final String deviceId;
|
||||||
|
private final File outputFile;
|
||||||
|
private final long startedAtMillis = System.currentTimeMillis();
|
||||||
|
private final Object fileLock = new Object();
|
||||||
|
private final AtomicBoolean stopped = new AtomicBoolean(false);
|
||||||
|
private final AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||||
|
private final CountDownLatch firstAudioFrame = new CountDownLatch(1);
|
||||||
|
private final AtomicReference<ClientCallStreamObserver<MicrophoneCommand.StreamMicAudioCommand.Request>>
|
||||||
|
requestObserver = new AtomicReference<>();
|
||||||
|
|
||||||
|
private RandomAccessFile output;
|
||||||
|
private long pcmBytes;
|
||||||
|
private long sampleCount;
|
||||||
|
private long sampleSum;
|
||||||
|
private long nonZeroSamples;
|
||||||
|
private int peakAmplitude;
|
||||||
|
private int sampleRate;
|
||||||
|
private int channels;
|
||||||
|
|
||||||
|
private WorkflowAudioRecordingSession(String recordingId, String robotId, String deviceId, File outputFile) {
|
||||||
|
this.recordingId = recordingId;
|
||||||
|
this.robotId = robotId;
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
this.outputFile = outputFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void start() {
|
||||||
|
try {
|
||||||
|
output = new RandomAccessFile(outputFile, "rw");
|
||||||
|
output.setLength(0L);
|
||||||
|
output.write(new byte[WAV_HEADER_BYTES]);
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw new GlobalException("初始化录音文件失败: " + exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
MicPhoneServiceGrpc.MicPhoneServiceStub stub = grpcServiceManager.getGrpcClient(
|
||||||
|
robotId, MicPhoneServiceGrpc.MicPhoneServiceStub.class);
|
||||||
|
MicrophoneCommand.StreamMicAudioCommand.Request request =
|
||||||
|
MicrophoneCommand.StreamMicAudioCommand.Request.newBuilder()
|
||||||
|
.setHeader(EdgeCommonUtil.buildRequest(deviceId))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
stub.streamAudio(request,
|
||||||
|
new ClientResponseObserver<MicrophoneCommand.StreamMicAudioCommand.Request,
|
||||||
|
MicrophoneCommand.StreamMicAudioCommand.Feedback>() {
|
||||||
|
@Override
|
||||||
|
public void beforeStart(ClientCallStreamObserver<MicrophoneCommand.StreamMicAudioCommand.Request> stream) {
|
||||||
|
if (stopped.get()) {
|
||||||
|
stream.cancel("workflow recording already stopped", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestObserver.set(stream);
|
||||||
|
if (stopped.get() && requestObserver.compareAndSet(stream, null)) {
|
||||||
|
stream.cancel("workflow recording already stopped", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNext(MicrophoneCommand.StreamMicAudioCommand.Feedback feedback) {
|
||||||
|
if (stopped.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (feedback.hasHeader() && !feedback.getHeader().getSuccess()) {
|
||||||
|
fail(new GlobalException("录音音频流失败: " + feedback.getHeader().getErrorMessage()));
|
||||||
|
firstAudioFrame.countDown();
|
||||||
|
cancelStream("microphone feedback failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (feedback.hasAudio()) {
|
||||||
|
writeAudio(feedback.getAudio());
|
||||||
|
firstAudioFrame.countDown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Throwable throwable) {
|
||||||
|
if (!stopped.get() && Status.fromThrowable(throwable).getCode() != Status.Code.CANCELLED) {
|
||||||
|
fail(throwable);
|
||||||
|
}
|
||||||
|
firstAudioFrame.countDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCompleted() {
|
||||||
|
if (!stopped.get()) {
|
||||||
|
fail(new GlobalException("麦克风音频流提前结束"));
|
||||||
|
}
|
||||||
|
firstAudioFrame.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
awaitFirstAudioFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void awaitFirstAudioFrame() {
|
||||||
|
try {
|
||||||
|
if (!firstAudioFrame.await(FIRST_AUDIO_FRAME_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||||
|
throw new GlobalException("启动录音失败: 5秒内未收到麦克风音频数据");
|
||||||
|
}
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new GlobalException("等待麦克风音频数据时被中断");
|
||||||
|
}
|
||||||
|
Throwable streamFailure = failure.get();
|
||||||
|
if (streamFailure != null) {
|
||||||
|
throw new GlobalException("启动录音失败: " + streamFailure.getMessage());
|
||||||
|
}
|
||||||
|
if (pcmBytes == 0L) {
|
||||||
|
throw new GlobalException("启动录音失败: 麦克风首帧没有有效 PCM 数据");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeAudio(Common.AudioData audio) {
|
||||||
|
if (audio.getFormat() != Common.AudioData.AudioFormat.PCM) {
|
||||||
|
fail(new GlobalException("不支持的录音音频格式: " + audio.getFormat()));
|
||||||
|
cancelStream("unsupported audio format");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (StrUtil.isNotBlank(audio.getCodec()) && !PCM_CODEC.equalsIgnoreCase(audio.getCodec())) {
|
||||||
|
fail(new GlobalException("不支持的录音音频编码: " + audio.getCodec()));
|
||||||
|
cancelStream("unsupported audio codec");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
byte[] pcm = audio.getData().toByteArray();
|
||||||
|
if (pcm.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((pcm.length & 1) != 0) {
|
||||||
|
fail(new GlobalException("录音 PCM 数据长度不是 16 位采样的整数倍"));
|
||||||
|
cancelStream("invalid pcm data");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int frameSampleRate = audio.getSampleRate() > 0 ? audio.getSampleRate() : DEFAULT_SAMPLE_RATE;
|
||||||
|
int frameChannels = audio.getChannels() > 0 ? audio.getChannels() : DEFAULT_CHANNELS;
|
||||||
|
synchronized (fileLock) {
|
||||||
|
if (stopped.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sampleRate == 0) {
|
||||||
|
sampleRate = frameSampleRate;
|
||||||
|
channels = frameChannels;
|
||||||
|
} else if (sampleRate != frameSampleRate || channels != frameChannels) {
|
||||||
|
fail(new GlobalException("录音过程中音频采样格式发生变化"));
|
||||||
|
cancelStream("audio format changed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
output.write(pcm);
|
||||||
|
pcmBytes += pcm.length;
|
||||||
|
updateSignalStats(pcm);
|
||||||
|
} catch (IOException exception) {
|
||||||
|
fail(exception);
|
||||||
|
cancelStream("write audio failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void prepareToFinish() {
|
||||||
|
if (!stopped.compareAndSet(false, true)) {
|
||||||
|
throw new GlobalException("录音会话已结束: " + recordingId);
|
||||||
|
}
|
||||||
|
cancelStream("workflow recording stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
private File finish() {
|
||||||
|
synchronized (fileLock) {
|
||||||
|
Throwable recordingFailure = failure.get();
|
||||||
|
if (recordingFailure != null) {
|
||||||
|
closeFile();
|
||||||
|
deleteOutputFile();
|
||||||
|
throw new GlobalException("录音失败: " + recordingFailure.getMessage());
|
||||||
|
}
|
||||||
|
if (pcmBytes == 0L) {
|
||||||
|
closeFile();
|
||||||
|
deleteOutputFile();
|
||||||
|
throw new GlobalException("录音文件为空,请确认麦克风音频流正常");
|
||||||
|
}
|
||||||
|
if (nonZeroSamples == 0L) {
|
||||||
|
closeFile();
|
||||||
|
deleteOutputFile();
|
||||||
|
throw new GlobalException("录音数据全为静音,请检查机器人麦克风采集状态和输入设备");
|
||||||
|
}
|
||||||
|
logger.info("工作流录音采集完成: recordingId={}, robotId={}, deviceId={}, pcmBytes={}, "
|
||||||
|
+ "nonZeroSamples={}, peakAmplitude={}, sampleRate={}, channels={}",
|
||||||
|
recordingId, robotId, deviceId, pcmBytes, nonZeroSamples, peakAmplitude,
|
||||||
|
sampleRate, channels);
|
||||||
|
try {
|
||||||
|
RecordingSignalAdjustment adjustment = normalizePcmSignal(
|
||||||
|
output, pcmBytes, sampleCount, sampleSum);
|
||||||
|
if (adjustment.sourcePeak() == 0) {
|
||||||
|
closeFile();
|
||||||
|
deleteOutputFile();
|
||||||
|
throw new GlobalException("录音数据仅包含固定偏置,没有采集到有效声音");
|
||||||
|
}
|
||||||
|
logger.info("工作流录音电平处理完成: recordingId={}, dcOffset={}, sourcePeak={}, "
|
||||||
|
+ "normalizedPeak={}, gain={}",
|
||||||
|
recordingId, adjustment.dcOffset(), adjustment.sourcePeak(),
|
||||||
|
adjustment.normalizedPeak(), adjustment.gain());
|
||||||
|
writeWavHeader(output, pcmBytes, sampleRate, channels);
|
||||||
|
closeFile();
|
||||||
|
return outputFile;
|
||||||
|
} catch (IOException exception) {
|
||||||
|
closeFile();
|
||||||
|
deleteOutputFile();
|
||||||
|
throw new GlobalException("封装 WAV 录音失败: " + exception.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateSignalStats(byte[] pcm) {
|
||||||
|
for (int index = 0; index + 1 < pcm.length; index += 2) {
|
||||||
|
int sample = (short) ((pcm[index] & 0xff) | (pcm[index + 1] << 8));
|
||||||
|
if (sample != 0) {
|
||||||
|
nonZeroSamples++;
|
||||||
|
}
|
||||||
|
sampleCount++;
|
||||||
|
sampleSum += sample;
|
||||||
|
peakAmplitude = Math.max(peakAmplitude, Math.abs(sample));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void abort() {
|
||||||
|
stopped.set(true);
|
||||||
|
cancelStream("workflow recording aborted");
|
||||||
|
synchronized (fileLock) {
|
||||||
|
closeFile();
|
||||||
|
deleteOutputFile();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matches(String expectedRobotId, String expectedDeviceId) {
|
||||||
|
return StrUtil.equals(robotId, expectedRobotId) && StrUtil.equals(deviceId, expectedDeviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String targetKey() {
|
||||||
|
return recordingTargetKey(robotId, deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isExpired() {
|
||||||
|
return System.currentTimeMillis() - startedAtMillis > RECORDING_MAX_AGE_MILLIS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fail(Throwable throwable) {
|
||||||
|
failure.compareAndSet(null, throwable);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cancelStream(String reason) {
|
||||||
|
ClientCallStreamObserver<MicrophoneCommand.StreamMicAudioCommand.Request> observer =
|
||||||
|
requestObserver.getAndSet(null);
|
||||||
|
if (observer != null) {
|
||||||
|
observer.cancel(reason, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeFile() {
|
||||||
|
if (output != null) {
|
||||||
|
try {
|
||||||
|
output.close();
|
||||||
|
} catch (IOException exception) {
|
||||||
|
fail(exception);
|
||||||
|
} finally {
|
||||||
|
output = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteOutputFile() {
|
||||||
|
if (outputFile.exists() && !outputFile.delete()) {
|
||||||
|
outputFile.deleteOnExit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeWavHeader(RandomAccessFile output, long pcmBytes, int sampleRate, int channels)
|
||||||
|
throws IOException {
|
||||||
|
if (pcmBytes > 0xffff_ffffL - 36L) {
|
||||||
|
throw new IOException("录音文件超过 WAV 4GB 限制");
|
||||||
|
}
|
||||||
|
int blockAlign = channels * PCM_BITS_PER_SAMPLE / 8;
|
||||||
|
long byteRate = (long) sampleRate * blockAlign;
|
||||||
|
output.seek(0L);
|
||||||
|
output.writeBytes("RIFF");
|
||||||
|
writeLittleEndianInt(output, pcmBytes + 36L);
|
||||||
|
output.writeBytes("WAVE");
|
||||||
|
output.writeBytes("fmt ");
|
||||||
|
writeLittleEndianInt(output, 16L);
|
||||||
|
writeLittleEndianShort(output, 1);
|
||||||
|
writeLittleEndianShort(output, channels);
|
||||||
|
writeLittleEndianInt(output, sampleRate);
|
||||||
|
writeLittleEndianInt(output, byteRate);
|
||||||
|
writeLittleEndianShort(output, blockAlign);
|
||||||
|
writeLittleEndianShort(output, PCM_BITS_PER_SAMPLE);
|
||||||
|
output.writeBytes("data");
|
||||||
|
writeLittleEndianInt(output, pcmBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RecordingSignalAdjustment normalizePcmSignal(
|
||||||
|
RandomAccessFile output, long pcmBytes, long sampleCount, long sampleSum) throws IOException {
|
||||||
|
if (pcmBytes <= 0L || sampleCount <= 0L) {
|
||||||
|
return new RecordingSignalAdjustment(0, 0, 0, 1.0D);
|
||||||
|
}
|
||||||
|
|
||||||
|
int dcOffset = (int) Math.round(sampleSum / (double) sampleCount);
|
||||||
|
int sourcePeak = scanCenteredPeak(output, pcmBytes, dcOffset);
|
||||||
|
if (sourcePeak == 0) {
|
||||||
|
return new RecordingSignalAdjustment(dcOffset, 0, 0, 1.0D);
|
||||||
|
}
|
||||||
|
double gain = Math.min(MAX_RECORDING_GAIN,
|
||||||
|
NORMALIZED_PEAK_AMPLITUDE / (double) sourcePeak);
|
||||||
|
int normalizedPeak = rewritePcmSignal(output, pcmBytes, dcOffset, gain);
|
||||||
|
return new RecordingSignalAdjustment(dcOffset, sourcePeak, normalizedPeak, gain);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int scanCenteredPeak(RandomAccessFile output, long pcmBytes, int dcOffset) throws IOException {
|
||||||
|
byte[] buffer = new byte[PCM_PROCESS_BUFFER_BYTES];
|
||||||
|
long remaining = pcmBytes;
|
||||||
|
int peak = 0;
|
||||||
|
output.seek(WAV_HEADER_BYTES);
|
||||||
|
while (remaining > 0L) {
|
||||||
|
int bytesToRead = (int) Math.min(buffer.length, remaining);
|
||||||
|
int bytesRead = output.read(buffer, 0, bytesToRead);
|
||||||
|
if (bytesRead < 0) {
|
||||||
|
throw new IOException("录音 PCM 数据提前结束");
|
||||||
|
}
|
||||||
|
for (int index = 0; index + 1 < bytesRead; index += 2) {
|
||||||
|
int sample = (short) ((buffer[index] & 0xff) | (buffer[index + 1] << 8));
|
||||||
|
peak = Math.max(peak, Math.abs(sample - dcOffset));
|
||||||
|
}
|
||||||
|
remaining -= bytesRead;
|
||||||
|
}
|
||||||
|
return peak;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int rewritePcmSignal(RandomAccessFile output, long pcmBytes, int dcOffset, double gain)
|
||||||
|
throws IOException {
|
||||||
|
byte[] buffer = new byte[PCM_PROCESS_BUFFER_BYTES];
|
||||||
|
long remaining = pcmBytes;
|
||||||
|
long position = WAV_HEADER_BYTES;
|
||||||
|
int peak = 0;
|
||||||
|
while (remaining > 0L) {
|
||||||
|
int bytesToRead = (int) Math.min(buffer.length, remaining);
|
||||||
|
output.seek(position);
|
||||||
|
int bytesRead = output.read(buffer, 0, bytesToRead);
|
||||||
|
if (bytesRead < 0) {
|
||||||
|
throw new IOException("录音 PCM 数据提前结束");
|
||||||
|
}
|
||||||
|
for (int index = 0; index + 1 < bytesRead; index += 2) {
|
||||||
|
int sample = (short) ((buffer[index] & 0xff) | (buffer[index + 1] << 8));
|
||||||
|
int adjusted = (int) Math.round((sample - dcOffset) * gain);
|
||||||
|
adjusted = Math.max(Short.MIN_VALUE, Math.min(Short.MAX_VALUE, adjusted));
|
||||||
|
peak = Math.max(peak, Math.abs(adjusted));
|
||||||
|
buffer[index] = (byte) (adjusted & 0xff);
|
||||||
|
buffer[index + 1] = (byte) ((adjusted >>> 8) & 0xff);
|
||||||
|
}
|
||||||
|
output.seek(position);
|
||||||
|
output.write(buffer, 0, bytesRead);
|
||||||
|
position += bytesRead;
|
||||||
|
remaining -= bytesRead;
|
||||||
|
}
|
||||||
|
return peak;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record RecordingSignalAdjustment(
|
||||||
|
int dcOffset, int sourcePeak, int normalizedPeak, double gain) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeLittleEndianInt(RandomAccessFile output, long value) throws IOException {
|
||||||
|
output.write((int) (value & 0xff));
|
||||||
|
output.write((int) ((value >>> 8) & 0xff));
|
||||||
|
output.write((int) ((value >>> 16) & 0xff));
|
||||||
|
output.write((int) ((value >>> 24) & 0xff));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeLittleEndianShort(RandomAccessFile output, int value) throws IOException {
|
||||||
|
output.write(value & 0xff);
|
||||||
|
output.write((value >>> 8) & 0xff);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -2,6 +2,9 @@ package com.cmvr.edge.client.service.impl;
|
|||||||
|
|
||||||
import cmvr.api.Common;
|
import cmvr.api.Common;
|
||||||
import cmvr.api.ArmServiceGrpc;
|
import cmvr.api.ArmServiceGrpc;
|
||||||
|
import cmvr.api.ArmCommand;
|
||||||
|
import cmvr.api.AgvCommand;
|
||||||
|
import cmvr.msgs.AgvUtils;
|
||||||
import cmvr.api.SystemCommand;
|
import cmvr.api.SystemCommand;
|
||||||
import cmvr.api.SystemServiceGrpc;
|
import cmvr.api.SystemServiceGrpc;
|
||||||
import com.alibaba.fastjson2.JSON;
|
import com.alibaba.fastjson2.JSON;
|
||||||
@ -9,6 +12,7 @@ import com.cmvr.device.domain.DeDeviceRegistration;
|
|||||||
import com.cmvr.edge.client.manage.GrpcServiceManager;
|
import com.cmvr.edge.client.manage.GrpcServiceManager;
|
||||||
import com.cmvr.edge.client.model.system.EdgeSystemJsonCommandVO;
|
import com.cmvr.edge.client.model.system.EdgeSystemJsonCommandVO;
|
||||||
import com.cmvr.edge.client.model.system.EdgeSystemUpdateParamsVO;
|
import com.cmvr.edge.client.model.system.EdgeSystemUpdateParamsVO;
|
||||||
|
import com.cmvr.edge.client.model.system.EdgeActionQueueVO;
|
||||||
import com.cmvr.edge.client.service.EdgeSystemService;
|
import com.cmvr.edge.client.service.EdgeSystemService;
|
||||||
import com.cmvr.edge.client.utils.EdgeCommonUtil;
|
import com.cmvr.edge.client.utils.EdgeCommonUtil;
|
||||||
import com.cmvr.common.exception.GlobalException;
|
import com.cmvr.common.exception.GlobalException;
|
||||||
@ -21,6 +25,10 @@ import org.springframework.stereotype.Service;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 边缘系统服务实现类
|
* 边缘系统服务实现类
|
||||||
@ -69,6 +77,159 @@ public class EdgeSystemServiceImpl implements EdgeSystemService {
|
|||||||
return stub.executeJsonCommand(grpcRequest);
|
return stub.executeJsonCommand(grpcRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SystemCommand.ActionQueueCommand.Feedback executeActionQueue(EdgeActionQueueVO request) {
|
||||||
|
if (request == null || request.getRobotId() == null || request.getRobotId().trim().isEmpty()) {
|
||||||
|
throw new GlobalException("Robot ID cannot be empty");
|
||||||
|
}
|
||||||
|
if (request.getSteps() == null || request.getSteps().isEmpty()) {
|
||||||
|
throw new GlobalException("Action queue must contain at least one step");
|
||||||
|
}
|
||||||
|
SystemCommand.GetSystemInfoCommand.Feedback systemInfo = getSystemInfo(request.getRobotId());
|
||||||
|
if (systemInfo == null || !systemInfo.hasHeader() || !systemInfo.getHeader().getSuccess()) {
|
||||||
|
throw new GlobalException("Unable to read edge action service information");
|
||||||
|
}
|
||||||
|
String serviceInstanceId = systemInfo.getActionServiceInstanceId();
|
||||||
|
if (serviceInstanceId == null || serviceInstanceId.isEmpty()) {
|
||||||
|
throw new GlobalException("Edge action service instance ID is unavailable");
|
||||||
|
}
|
||||||
|
int totalTimeoutMs = request.getTotalTimeoutMs() == null ? 0 : Math.max(request.getTotalTimeoutMs(), 0);
|
||||||
|
String actionId = request.getActionId() == null || request.getActionId().trim().isEmpty()
|
||||||
|
? UUID.randomUUID().toString() : request.getActionId().trim();
|
||||||
|
SystemCommand.ActionQueueCommand.Request.Builder builder = SystemCommand.ActionQueueCommand.Request.newBuilder()
|
||||||
|
.setActionId(actionId)
|
||||||
|
.setExpectedServiceInstanceId(serviceInstanceId)
|
||||||
|
.setTotalTimeoutMs(totalTimeoutMs);
|
||||||
|
Set<String> stepIds = new HashSet<>();
|
||||||
|
for (int index = 0; index < request.getSteps().size(); index++) {
|
||||||
|
EdgeActionQueueVO.Step step = request.getSteps().get(index);
|
||||||
|
String stepId = step.getStepId() == null || step.getStepId().trim().isEmpty()
|
||||||
|
? "step_" + (index + 1) : step.getStepId().trim();
|
||||||
|
if (!stepIds.add(stepId)) {
|
||||||
|
throw new GlobalException("Duplicate action queue step ID: " + stepId);
|
||||||
|
}
|
||||||
|
builder.addSteps(buildActionStep(step, stepId));
|
||||||
|
}
|
||||||
|
SystemServiceGrpc.SystemServiceBlockingStub stub = grpcServiceManager.getGrpcClient(
|
||||||
|
request.getRobotId(), SystemServiceGrpc.SystemServiceBlockingStub.class);
|
||||||
|
long deadlineMs = totalTimeoutMs > 0 ? totalTimeoutMs + 5000L : 65000L;
|
||||||
|
return stub.withDeadlineAfter(deadlineMs, TimeUnit.MILLISECONDS).executeActionQueue(builder.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private SystemCommand.ActionStep buildActionStep(EdgeActionQueueVO.Step step, String stepId) {
|
||||||
|
if (step == null || step.getType() == null) {
|
||||||
|
throw new GlobalException("Action queue step type cannot be empty");
|
||||||
|
}
|
||||||
|
com.alibaba.fastjson2.JSONObject params = step.getParams() == null
|
||||||
|
? new com.alibaba.fastjson2.JSONObject() : step.getParams();
|
||||||
|
SystemCommand.ActionStep.Builder builder = SystemCommand.ActionStep.newBuilder().setStepId(stepId);
|
||||||
|
if (step.getTimeoutMs() != null && step.getTimeoutMs() > 0) builder.setTimeoutMs(step.getTimeoutMs());
|
||||||
|
switch (step.getType().trim().toUpperCase()) {
|
||||||
|
case "ARM_MOVE_J":
|
||||||
|
List<Double> positions = params.getList("positions", Double.class);
|
||||||
|
if (positions == null || positions.isEmpty()) {
|
||||||
|
throw new GlobalException("Arm joint positions cannot be empty");
|
||||||
|
}
|
||||||
|
builder.setArmMoveJ(ArmCommand.MoveJ.Request.newBuilder()
|
||||||
|
.setHeader(requiredDeviceHeader(step))
|
||||||
|
.setTarget(ArmCommand.JointPositionCommand.newBuilder()
|
||||||
|
.addAllPosition(positions))
|
||||||
|
.setOptions(buildArmOptions(params)));
|
||||||
|
break;
|
||||||
|
case "ARM_MOVE_L":
|
||||||
|
builder.setArmMoveL(ArmCommand.MoveL.Request.newBuilder()
|
||||||
|
.setHeader(requiredDeviceHeader(step))
|
||||||
|
.setTarget(ArmCommand.CartesianPose.newBuilder()
|
||||||
|
.setX(params.getDoubleValue("x")).setY(params.getDoubleValue("y"))
|
||||||
|
.setZ(params.getDoubleValue("z")).setRx(params.getDoubleValue("rx"))
|
||||||
|
.setRy(params.getDoubleValue("ry")).setRz(params.getDoubleValue("rz")))
|
||||||
|
.setFrame(parseArmFrame(params.getString("frame")))
|
||||||
|
.setOptions(buildArmOptions(params)));
|
||||||
|
break;
|
||||||
|
case "AGV_NAVIGATE_TO_POSE":
|
||||||
|
builder.setAgvNavigateToPose(AgvCommand.AgvNavigateToPoseCommand.Request.newBuilder()
|
||||||
|
.setHeader(requiredDeviceHeader(step))
|
||||||
|
.setPose(AgvUtils.AgvPose2d.newBuilder().setX(params.getDoubleValue("x"))
|
||||||
|
.setY(params.getDoubleValue("y")).setTheta(params.getDoubleValue("theta")))
|
||||||
|
.setOptions(buildAgvOptions(params)));
|
||||||
|
break;
|
||||||
|
case "AGV_NAVIGATE_TO_STATION":
|
||||||
|
builder.setAgvNavigateToStation(AgvCommand.AgvNavigateToStationCommand.Request.newBuilder()
|
||||||
|
.setHeader(requiredDeviceHeader(step)).setStationId(requiredText(params, "stationId"))
|
||||||
|
.setOptions(buildAgvOptions(params)));
|
||||||
|
break;
|
||||||
|
case "AGV_FOLLOW_PATH":
|
||||||
|
AgvCommand.AgvFollowPathCommand.Request.Builder path = AgvCommand.AgvFollowPathCommand.Request.newBuilder()
|
||||||
|
.setHeader(requiredDeviceHeader(step)).setOptions(buildAgvOptions(params));
|
||||||
|
com.alibaba.fastjson2.JSONArray segments = params.getJSONArray("path");
|
||||||
|
if (segments == null || segments.isEmpty()) throw new GlobalException("AGV path cannot be empty");
|
||||||
|
for (int i = 0; i < segments.size(); i++) {
|
||||||
|
com.alibaba.fastjson2.JSONObject segment = segments.getJSONObject(i);
|
||||||
|
if (segment == null) throw new GlobalException("AGV path segment cannot be empty");
|
||||||
|
path.addPath(AgvUtils.AgvPathSegment.newBuilder()
|
||||||
|
.setSourceStation(requiredText(segment, "sourceStation"))
|
||||||
|
.setTargetStation(requiredText(segment, "targetStation")));
|
||||||
|
}
|
||||||
|
builder.setAgvFollowPath(path);
|
||||||
|
break;
|
||||||
|
case "DELAY":
|
||||||
|
int durationMs = params.getIntValue("durationMs");
|
||||||
|
if (durationMs < 0) throw new GlobalException("Delay duration cannot be negative");
|
||||||
|
builder.setDelay(SystemCommand.DelayAction.newBuilder().setDurationMs(durationMs));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new GlobalException("Unsupported action queue step type: " + step.getType());
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Common.CommandHeader.Request requiredDeviceHeader(EdgeActionQueueVO.Step step) {
|
||||||
|
if (step.getDeviceId() == null || step.getDeviceId().trim().isEmpty()) {
|
||||||
|
throw new GlobalException("Device ID cannot be empty for step " + step.getStepId());
|
||||||
|
}
|
||||||
|
return EdgeCommonUtil.buildRequest(step.getDeviceId().trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ArmCommand.MotionOptions buildArmOptions(com.alibaba.fastjson2.JSONObject params) {
|
||||||
|
return ArmCommand.MotionOptions.newBuilder()
|
||||||
|
.setVelocity(params.getDoubleValue("velocity"))
|
||||||
|
.setAcceleration(params.getDoubleValue("acceleration"))
|
||||||
|
.setBlendRadius(params.getDoubleValue("blendRadius"))
|
||||||
|
.setJerk(params.getDoubleValue("jerk"))
|
||||||
|
.setAsynchronous(false)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgvUtils.AgvMotionOptions buildAgvOptions(com.alibaba.fastjson2.JSONObject params) {
|
||||||
|
return AgvUtils.AgvMotionOptions.newBuilder()
|
||||||
|
.setMaxSpeed(params.getDoubleValue("maxSpeed"))
|
||||||
|
.setMaxAngularSpeed(params.getDoubleValue("maxAngularSpeed"))
|
||||||
|
.setMaxAcceleration(params.getDoubleValue("maxAcceleration"))
|
||||||
|
.setMaxAngularAcceleration(params.getDoubleValue("maxAngularAcceleration"))
|
||||||
|
.setReachDistance(params.getDoubleValue("reachDistance"))
|
||||||
|
.setReachAngle(params.getDoubleValue("reachAngle"))
|
||||||
|
.setSpeedRatio(params.getDoubleValue("speedRatio"))
|
||||||
|
.setWaitTimeoutMs(params.getIntValue("waitTimeoutMs"))
|
||||||
|
.setPollIntervalMs(params.getIntValue("pollIntervalMs"))
|
||||||
|
.setAsynchronous(false)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ArmCommand.ArmFrameType parseArmFrame(String value) {
|
||||||
|
if (value == null || value.trim().isEmpty()) return ArmCommand.ArmFrameType.ARM_FRAME_BASE;
|
||||||
|
try {
|
||||||
|
return ArmCommand.ArmFrameType.valueOf("ARM_FRAME_" + value.trim().toUpperCase());
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
throw new GlobalException("Unsupported arm frame: " + value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requiredText(com.alibaba.fastjson2.JSONObject params, String name) {
|
||||||
|
String value = params.getString(name);
|
||||||
|
if (value == null || value.trim().isEmpty()) throw new GlobalException(name + " cannot be empty");
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
private Common.ConfigParam buildParam(EdgeSystemUpdateParamsVO.Param param) {
|
private Common.ConfigParam buildParam(EdgeSystemUpdateParamsVO.Param param) {
|
||||||
if (param == null || param.getName() == null || param.getName().trim().isEmpty()) {
|
if (param == null || param.getName() == null || param.getName().trim().isEmpty()) {
|
||||||
throw new GlobalException("Parameter name cannot be empty");
|
throw new GlobalException("Parameter name cannot be empty");
|
||||||
|
|||||||
@ -14,7 +14,9 @@ import java.io.OutputStream;
|
|||||||
import java.util.ArrayDeque;
|
import java.util.ArrayDeque;
|
||||||
import java.util.Deque;
|
import java.util.Deque;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -32,10 +34,13 @@ public final class H265Fmp4Transcoder implements AutoCloseable {
|
|||||||
private final LiveEncodedVideoInputStream inputStream;
|
private final LiveEncodedVideoInputStream inputStream;
|
||||||
private final MediaChunkOutputStream outputStream;
|
private final MediaChunkOutputStream outputStream;
|
||||||
private final AtomicBoolean closed = new AtomicBoolean(false);
|
private final AtomicBoolean closed = new AtomicBoolean(false);
|
||||||
|
private final CountDownLatch firstFrameWritten = new CountDownLatch(1);
|
||||||
private final Thread worker;
|
private final Thread worker;
|
||||||
|
|
||||||
private volatile FFmpegFrameGrabber grabber;
|
private volatile FFmpegFrameGrabber grabber;
|
||||||
private volatile FFmpegFrameRecorder recorder;
|
private volatile FFmpegFrameRecorder recorder;
|
||||||
|
private volatile long writtenFrameCount;
|
||||||
|
private volatile long lastWrittenTimestampMicros = -1L;
|
||||||
|
|
||||||
public H265Fmp4Transcoder(String streamName,
|
public H265Fmp4Transcoder(String streamName,
|
||||||
int configuredFrameRate,
|
int configuredFrameRate,
|
||||||
@ -66,6 +71,19 @@ public final class H265Fmp4Transcoder implements AutoCloseable {
|
|||||||
inputStream.offer(encodedFrame, keyFrame, codec);
|
inputStream.offer(encodedFrame, keyFrame, codec);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Waits until the transcoder has written its first decoded frame to MP4. */
|
||||||
|
public boolean awaitFirstFrame(long timeout, TimeUnit unit) throws InterruptedException {
|
||||||
|
return firstFrameWritten.await(timeout, unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getWrittenFrameCount() {
|
||||||
|
return writtenFrameCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getLastWrittenTimestampMicros() {
|
||||||
|
return lastWrittenTimestampMicros;
|
||||||
|
}
|
||||||
|
|
||||||
private void transcode() {
|
private void transcode() {
|
||||||
try {
|
try {
|
||||||
InputCodec inputCodec = inputStream.awaitInputCodec();
|
InputCodec inputCodec = inputStream.awaitInputCodec();
|
||||||
@ -99,7 +117,6 @@ public final class H265Fmp4Transcoder implements AutoCloseable {
|
|||||||
sourceFrameRate = Math.max(5D, Math.min(sourceFrameRate, 60D));
|
sourceFrameRate = Math.max(5D, Math.min(sourceFrameRate, 60D));
|
||||||
double outputFrameRate = Math.min(sourceFrameRate, 25D);
|
double outputFrameRate = Math.min(sourceFrameRate, 25D);
|
||||||
int gopSize = Math.max(1, (int) Math.round(outputFrameRate * 0.4D));
|
int gopSize = Math.max(1, (int) Math.round(outputFrameRate * 0.4D));
|
||||||
long sourceFrameDurationMicros = Math.max(1L, Math.round(1_000_000D / sourceFrameRate));
|
|
||||||
long outputFrameDurationMicros = Math.max(1L, Math.round(1_000_000D / outputFrameRate));
|
long outputFrameDurationMicros = Math.max(1L, Math.round(1_000_000D / outputFrameRate));
|
||||||
log.info("Camera stream {} uses {} source fps, {} output fps and a {} frame GOP",
|
log.info("Camera stream {} uses {} source fps, {} output fps and a {} frame GOP",
|
||||||
streamName, sourceFrameRate, outputFrameRate, gopSize);
|
streamName, sourceFrameRate, outputFrameRate, gopSize);
|
||||||
@ -119,17 +136,22 @@ public final class H265Fmp4Transcoder implements AutoCloseable {
|
|||||||
recorder.start();
|
recorder.start();
|
||||||
outputStream.publishAvailable();
|
outputStream.publishAvailable();
|
||||||
|
|
||||||
long sourceFrameIndex = 0L;
|
long firstFrameMonotonicNanos = System.nanoTime();
|
||||||
long nextOutputTimestampMicros = 0L;
|
long previousTimestampMicros = -1L;
|
||||||
do {
|
do {
|
||||||
long sourceTimestampMicros = sourceFrameIndex * sourceFrameDurationMicros;
|
long timestampMicros = timelineTimestampMicros(
|
||||||
if (sourceTimestampMicros + sourceFrameDurationMicros / 2 >= nextOutputTimestampMicros) {
|
firstFrameMonotonicNanos, System.nanoTime(), previousTimestampMicros,
|
||||||
recorder.setTimestamp(nextOutputTimestampMicros);
|
outputFrameDurationMicros);
|
||||||
recorder.record(frame);
|
if (timestampMicros < 0L) {
|
||||||
nextOutputTimestampMicros += outputFrameDurationMicros;
|
continue;
|
||||||
outputStream.publishAvailable();
|
|
||||||
}
|
}
|
||||||
sourceFrameIndex++;
|
recorder.setTimestamp(timestampMicros);
|
||||||
|
recorder.record(frame);
|
||||||
|
previousTimestampMicros = timestampMicros;
|
||||||
|
writtenFrameCount++;
|
||||||
|
lastWrittenTimestampMicros = timestampMicros;
|
||||||
|
firstFrameWritten.countDown();
|
||||||
|
outputStream.publishAvailable();
|
||||||
} while (!closed.get() && (frame = grabber.grabImage()) != null);
|
} while (!closed.get() && (frame = grabber.grabImage()) != null);
|
||||||
} catch (Throwable throwable) {
|
} catch (Throwable throwable) {
|
||||||
if (!closed.get()) {
|
if (!closed.get()) {
|
||||||
@ -143,9 +165,23 @@ public final class H265Fmp4Transcoder implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
inputStream.close();
|
inputStream.close();
|
||||||
closed.set(true);
|
closed.set(true);
|
||||||
|
firstFrameWritten.countDown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static long timelineTimestampMicros(long firstFrameNanos, long currentFrameNanos,
|
||||||
|
long previousTimestampMicros, long frameDurationMicros) {
|
||||||
|
long elapsedMicros = TimeUnit.NANOSECONDS.toMicros(
|
||||||
|
Math.max(0L, currentFrameNanos - firstFrameNanos));
|
||||||
|
if (previousTimestampMicros < 0L) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
long safeFrameDuration = Math.max(1L, frameDurationMicros);
|
||||||
|
long alignedTimestamp = ((elapsedMicros + safeFrameDuration / 2L) / safeFrameDuration)
|
||||||
|
* safeFrameDuration;
|
||||||
|
return alignedTimestamp > previousTimestampMicros ? alignedTimestamp : -1L;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
if (!closed.compareAndSet(false, true)) {
|
if (!closed.compareAndSet(false, true)) {
|
||||||
@ -155,6 +191,15 @@ public final class H265Fmp4Transcoder implements AutoCloseable {
|
|||||||
worker.interrupt();
|
worker.interrupt();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for FFmpeg to flush and release the output container after close().
|
||||||
|
*/
|
||||||
|
public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException {
|
||||||
|
long timeoutMillis = Math.max(1L, unit.toMillis(timeout));
|
||||||
|
worker.join(timeoutMillis);
|
||||||
|
return !worker.isAlive();
|
||||||
|
}
|
||||||
|
|
||||||
private void releaseNativeResources() {
|
private void releaseNativeResources() {
|
||||||
FFmpegFrameRecorder currentRecorder = recorder;
|
FFmpegFrameRecorder currentRecorder = recorder;
|
||||||
recorder = null;
|
recorder = null;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -170,6 +170,37 @@ public final class SystemServiceGrpc {
|
|||||||
return getStopAllMethod;
|
return getStopAllMethod;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static volatile io.grpc.MethodDescriptor<cmvr.api.SystemCommand.ActionQueueCommand.Request,
|
||||||
|
cmvr.api.SystemCommand.ActionQueueCommand.Feedback> getExecuteActionQueueMethod;
|
||||||
|
|
||||||
|
@io.grpc.stub.annotations.RpcMethod(
|
||||||
|
fullMethodName = SERVICE_NAME + '/' + "ExecuteActionQueue",
|
||||||
|
requestType = cmvr.api.SystemCommand.ActionQueueCommand.Request.class,
|
||||||
|
responseType = cmvr.api.SystemCommand.ActionQueueCommand.Feedback.class,
|
||||||
|
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
|
||||||
|
public static io.grpc.MethodDescriptor<cmvr.api.SystemCommand.ActionQueueCommand.Request,
|
||||||
|
cmvr.api.SystemCommand.ActionQueueCommand.Feedback> getExecuteActionQueueMethod() {
|
||||||
|
io.grpc.MethodDescriptor<cmvr.api.SystemCommand.ActionQueueCommand.Request, cmvr.api.SystemCommand.ActionQueueCommand.Feedback> getExecuteActionQueueMethod;
|
||||||
|
if ((getExecuteActionQueueMethod = SystemServiceGrpc.getExecuteActionQueueMethod) == null) {
|
||||||
|
synchronized (SystemServiceGrpc.class) {
|
||||||
|
if ((getExecuteActionQueueMethod = SystemServiceGrpc.getExecuteActionQueueMethod) == null) {
|
||||||
|
SystemServiceGrpc.getExecuteActionQueueMethod = getExecuteActionQueueMethod =
|
||||||
|
io.grpc.MethodDescriptor.<cmvr.api.SystemCommand.ActionQueueCommand.Request, cmvr.api.SystemCommand.ActionQueueCommand.Feedback>newBuilder()
|
||||||
|
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
|
||||||
|
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "ExecuteActionQueue"))
|
||||||
|
.setSampledToLocalTracing(true)
|
||||||
|
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||||
|
cmvr.api.SystemCommand.ActionQueueCommand.Request.getDefaultInstance()))
|
||||||
|
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||||
|
cmvr.api.SystemCommand.ActionQueueCommand.Feedback.getDefaultInstance()))
|
||||||
|
.setSchemaDescriptor(new SystemServiceMethodDescriptorSupplier("ExecuteActionQueue"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getExecuteActionQueueMethod;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new async stub that supports all call types for the service
|
* Creates a new async stub that supports all call types for the service
|
||||||
*/
|
*/
|
||||||
@ -253,6 +284,13 @@ public final class SystemServiceGrpc {
|
|||||||
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getStopAllMethod(), responseObserver);
|
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getStopAllMethod(), responseObserver);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public void executeActionQueue(cmvr.api.SystemCommand.ActionQueueCommand.Request request,
|
||||||
|
io.grpc.stub.StreamObserver<cmvr.api.SystemCommand.ActionQueueCommand.Feedback> responseObserver) {
|
||||||
|
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getExecuteActionQueueMethod(), responseObserver);
|
||||||
|
}
|
||||||
|
|
||||||
@java.lang.Override public final io.grpc.ServerServiceDefinition bindService() {
|
@java.lang.Override public final io.grpc.ServerServiceDefinition bindService() {
|
||||||
return io.grpc.ServerServiceDefinition.builder(getServiceDescriptor())
|
return io.grpc.ServerServiceDefinition.builder(getServiceDescriptor())
|
||||||
.addMethod(
|
.addMethod(
|
||||||
@ -290,6 +328,13 @@ public final class SystemServiceGrpc {
|
|||||||
cmvr.api.SystemCommand.StopAllCommand.Request,
|
cmvr.api.SystemCommand.StopAllCommand.Request,
|
||||||
cmvr.api.SystemCommand.StopAllCommand.Feedback>(
|
cmvr.api.SystemCommand.StopAllCommand.Feedback>(
|
||||||
this, METHODID_STOP_ALL)))
|
this, METHODID_STOP_ALL)))
|
||||||
|
.addMethod(
|
||||||
|
getExecuteActionQueueMethod(),
|
||||||
|
io.grpc.stub.ServerCalls.asyncUnaryCall(
|
||||||
|
new MethodHandlers<
|
||||||
|
cmvr.api.SystemCommand.ActionQueueCommand.Request,
|
||||||
|
cmvr.api.SystemCommand.ActionQueueCommand.Feedback>(
|
||||||
|
this, METHODID_EXECUTE_ACTION_QUEUE)))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -347,6 +392,14 @@ public final class SystemServiceGrpc {
|
|||||||
io.grpc.stub.ClientCalls.asyncUnaryCall(
|
io.grpc.stub.ClientCalls.asyncUnaryCall(
|
||||||
getChannel().newCall(getStopAllMethod(), getCallOptions()), request, responseObserver);
|
getChannel().newCall(getStopAllMethod(), getCallOptions()), request, responseObserver);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public void executeActionQueue(cmvr.api.SystemCommand.ActionQueueCommand.Request request,
|
||||||
|
io.grpc.stub.StreamObserver<cmvr.api.SystemCommand.ActionQueueCommand.Feedback> responseObserver) {
|
||||||
|
io.grpc.stub.ClientCalls.asyncUnaryCall(
|
||||||
|
getChannel().newCall(getExecuteActionQueueMethod(), getCallOptions()), request, responseObserver);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -397,6 +450,13 @@ public final class SystemServiceGrpc {
|
|||||||
return io.grpc.stub.ClientCalls.blockingUnaryCall(
|
return io.grpc.stub.ClientCalls.blockingUnaryCall(
|
||||||
getChannel(), getStopAllMethod(), getCallOptions(), request);
|
getChannel(), getStopAllMethod(), getCallOptions(), request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public cmvr.api.SystemCommand.ActionQueueCommand.Feedback executeActionQueue(cmvr.api.SystemCommand.ActionQueueCommand.Request request) {
|
||||||
|
return io.grpc.stub.ClientCalls.blockingUnaryCall(
|
||||||
|
getChannel(), getExecuteActionQueueMethod(), getCallOptions(), request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -452,6 +512,14 @@ public final class SystemServiceGrpc {
|
|||||||
return io.grpc.stub.ClientCalls.futureUnaryCall(
|
return io.grpc.stub.ClientCalls.futureUnaryCall(
|
||||||
getChannel().newCall(getStopAllMethod(), getCallOptions()), request);
|
getChannel().newCall(getStopAllMethod(), getCallOptions()), request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public com.google.common.util.concurrent.ListenableFuture<cmvr.api.SystemCommand.ActionQueueCommand.Feedback> executeActionQueue(
|
||||||
|
cmvr.api.SystemCommand.ActionQueueCommand.Request request) {
|
||||||
|
return io.grpc.stub.ClientCalls.futureUnaryCall(
|
||||||
|
getChannel().newCall(getExecuteActionQueueMethod(), getCallOptions()), request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final int METHODID_GET_SYSTEM_INFO = 0;
|
private static final int METHODID_GET_SYSTEM_INFO = 0;
|
||||||
@ -459,6 +527,7 @@ public final class SystemServiceGrpc {
|
|||||||
private static final int METHODID_GET_DEVICE_LIST = 2;
|
private static final int METHODID_GET_DEVICE_LIST = 2;
|
||||||
private static final int METHODID_UPDATE_PARAMS = 3;
|
private static final int METHODID_UPDATE_PARAMS = 3;
|
||||||
private static final int METHODID_STOP_ALL = 4;
|
private static final int METHODID_STOP_ALL = 4;
|
||||||
|
private static final int METHODID_EXECUTE_ACTION_QUEUE = 5;
|
||||||
|
|
||||||
private static final class MethodHandlers<Req, Resp> implements
|
private static final class MethodHandlers<Req, Resp> implements
|
||||||
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
|
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
|
||||||
@ -497,6 +566,10 @@ public final class SystemServiceGrpc {
|
|||||||
serviceImpl.stopAll((cmvr.api.SystemCommand.StopAllCommand.Request) request,
|
serviceImpl.stopAll((cmvr.api.SystemCommand.StopAllCommand.Request) request,
|
||||||
(io.grpc.stub.StreamObserver<cmvr.api.SystemCommand.StopAllCommand.Feedback>) responseObserver);
|
(io.grpc.stub.StreamObserver<cmvr.api.SystemCommand.StopAllCommand.Feedback>) responseObserver);
|
||||||
break;
|
break;
|
||||||
|
case METHODID_EXECUTE_ACTION_QUEUE:
|
||||||
|
serviceImpl.executeActionQueue((cmvr.api.SystemCommand.ActionQueueCommand.Request) request,
|
||||||
|
(io.grpc.stub.StreamObserver<cmvr.api.SystemCommand.ActionQueueCommand.Feedback>) responseObserver);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new AssertionError();
|
throw new AssertionError();
|
||||||
}
|
}
|
||||||
@ -563,6 +636,7 @@ public final class SystemServiceGrpc {
|
|||||||
.addMethod(getGetDeviceListMethod())
|
.addMethod(getGetDeviceListMethod())
|
||||||
.addMethod(getUpdateParamsMethod())
|
.addMethod(getUpdateParamsMethod())
|
||||||
.addMethod(getStopAllMethod())
|
.addMethod(getStopAllMethod())
|
||||||
|
.addMethod(getExecuteActionQueueMethod())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,7 +24,7 @@ public final class SystemServiceOuterClass {
|
|||||||
static {
|
static {
|
||||||
java.lang.String[] descriptorData = {
|
java.lang.String[] descriptorData = {
|
||||||
"\n\035cmvr/api/system_service.proto\022\010cmvr.ap" +
|
"\n\035cmvr/api/system_service.proto\022\010cmvr.ap" +
|
||||||
"i\032\035cmvr/api/system_command.proto2\364\003\n\rSys" +
|
"i\032\035cmvr/api/system_command.proto2\331\004\n\rSys" +
|
||||||
"temService\022b\n\rGetSystemInfo\022&.cmvr.api.G" +
|
"temService\022b\n\rGetSystemInfo\022&.cmvr.api.G" +
|
||||||
"etSystemInfoCommand.Request\032\'.cmvr.api.G" +
|
"etSystemInfoCommand.Request\032\'.cmvr.api.G" +
|
||||||
"etSystemInfoCommand.Feedback\"\000\022h\n\017GetSys" +
|
"etSystemInfoCommand.Feedback\"\000\022h\n\017GetSys" +
|
||||||
@ -37,7 +37,10 @@ public final class SystemServiceOuterClass {
|
|||||||
"and.Request\032&.cmvr.api.UpdateParamsComma" +
|
"and.Request\032&.cmvr.api.UpdateParamsComma" +
|
||||||
"nd.Feedback\"\000\022P\n\007StopAll\022 .cmvr.api.Stop" +
|
"nd.Feedback\"\000\022P\n\007StopAll\022 .cmvr.api.Stop" +
|
||||||
"AllCommand.Request\032!.cmvr.api.StopAllCom" +
|
"AllCommand.Request\032!.cmvr.api.StopAllCom" +
|
||||||
"mand.Feedback\"\000b\006proto3"
|
"mand.Feedback\"\000\022c\n\022ExecuteActionQueue\022$." +
|
||||||
|
"cmvr.api.ActionQueueCommand.Request\032%.cm" +
|
||||||
|
"vr.api.ActionQueueCommand.Feedback\"\000b\006pr" +
|
||||||
|
"oto3"
|
||||||
};
|
};
|
||||||
descriptor = com.google.protobuf.Descriptors.FileDescriptor
|
descriptor = com.google.protobuf.Descriptors.FileDescriptor
|
||||||
.internalBuildGeneratedFileFrom(descriptorData,
|
.internalBuildGeneratedFileFrom(descriptorData,
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
syntax = "proto3";
|
syntax = "proto3";
|
||||||
|
|
||||||
|
import "cmvr/api/agv_command.proto";
|
||||||
|
import "cmvr/api/arm_command.proto";
|
||||||
import "cmvr/api/common.proto";
|
import "cmvr/api/common.proto";
|
||||||
|
|
||||||
package cmvr.api;
|
package cmvr.api;
|
||||||
@ -103,6 +105,10 @@ message GetSystemInfoCommand {
|
|||||||
string os = 5;
|
string os = 5;
|
||||||
string kernel_version = 6;
|
string kernel_version = 6;
|
||||||
string architecture = 7;
|
string architecture = 7;
|
||||||
|
|
||||||
|
// Changes whenever the in-process ActionQueue idempotency ledger is
|
||||||
|
// recreated. Clients bind submissions and retries to this value.
|
||||||
|
string action_service_instance_id = 8;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,3 +147,97 @@ message StopAllCommand {
|
|||||||
CommandHeader.Feedback header = 1;
|
CommandHeader.Feedback header = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final outcome of one ActionQueue execution.
|
||||||
|
enum ActionResultCode {
|
||||||
|
ACTION_RESULT_CODE_UNSPECIFIED = 0;
|
||||||
|
ACTION_RESULT_CODE_COMPLETED = 1;
|
||||||
|
ACTION_RESULT_CODE_FAILED = 2;
|
||||||
|
ACTION_RESULT_CODE_CANCELED = 3;
|
||||||
|
ACTION_RESULT_CODE_TIMED_OUT = 4;
|
||||||
|
ACTION_RESULT_CODE_REJECTED = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Describes whether this RPC admitted a new action or observed an existing
|
||||||
|
// idempotency record. Clients must not infer this from an error string.
|
||||||
|
enum ActionDeduplicationStatus {
|
||||||
|
ACTION_DEDUPLICATION_STATUS_UNSPECIFIED = 0;
|
||||||
|
ACTION_DEDUPLICATION_STATUS_ACCEPTED_NEW = 1;
|
||||||
|
ACTION_DEDUPLICATION_STATUS_JOINED_IN_FLIGHT = 2;
|
||||||
|
ACTION_DEDUPLICATION_STATUS_CACHED_RESULT = 3;
|
||||||
|
ACTION_DEDUPLICATION_STATUS_RESULT_EVICTED = 4;
|
||||||
|
ACTION_DEDUPLICATION_STATUS_LEDGER_EXHAUSTED = 5;
|
||||||
|
ACTION_DEDUPLICATION_STATUS_ACTION_ID_CONFLICT = 6;
|
||||||
|
ACTION_DEDUPLICATION_STATUS_SERVICE_INSTANCE_MISMATCH = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge-local delay between two device commands.
|
||||||
|
message DelayAction {
|
||||||
|
// Delay duration in milliseconds. The server applies a bounded maximum.
|
||||||
|
uint32 duration_ms = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One finite, synchronous command in an ActionQueue request.
|
||||||
|
message ActionStep {
|
||||||
|
// Client-provided identifier used for diagnostics. It must be unique within
|
||||||
|
// one ActionQueue request.
|
||||||
|
string step_id = 1;
|
||||||
|
|
||||||
|
// Per-step timeout in milliseconds. Zero inherits the remaining action
|
||||||
|
// timeout or the server default.
|
||||||
|
uint32 timeout_ms = 2;
|
||||||
|
|
||||||
|
// Tags are grouped by domain so compatible commands can be added without
|
||||||
|
// renumbering existing alternatives: Arm 10-19, AGV 20-29, built-ins 90+.
|
||||||
|
oneof command {
|
||||||
|
MoveJ.Request arm_move_j = 10;
|
||||||
|
MoveL.Request arm_move_l = 11;
|
||||||
|
|
||||||
|
AgvNavigateToPoseCommand.Request agv_navigate_to_pose = 20;
|
||||||
|
AgvNavigateToStationCommand.Request agv_navigate_to_station = 21;
|
||||||
|
AgvFollowPathCommand.Request agv_follow_path = 22;
|
||||||
|
|
||||||
|
DelayAction delay = 90;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically submits a complete command sequence for edge-local serial
|
||||||
|
// execution. Device motion alternatives must use synchronous execution.
|
||||||
|
message ActionQueueCommand {
|
||||||
|
message Request {
|
||||||
|
// Client-generated globally unique idempotency key. During one Action
|
||||||
|
// service instance, retrying an identical accepted request with the
|
||||||
|
// same action_id does not dispatch its steps a second time. Recent
|
||||||
|
// terminal results can be returned; older accepted IDs are rejected
|
||||||
|
// fail-closed after their result is evicted. Deduplication is not
|
||||||
|
// persisted across an edge-service restart; the required instance
|
||||||
|
// epoch below prevents an old retry from being replayed after restart.
|
||||||
|
string action_id = 1;
|
||||||
|
repeated ActionStep steps = 2;
|
||||||
|
|
||||||
|
// Total queue-wait plus execution timeout in milliseconds. Zero uses a
|
||||||
|
// bounded server default.
|
||||||
|
uint32 total_timeout_ms = 3;
|
||||||
|
|
||||||
|
// Required instance epoch obtained from GetSystemInfo. A mismatch
|
||||||
|
// means the process-local deduplication ledger was recreated, so the
|
||||||
|
// server rejects the request instead of risking a replay.
|
||||||
|
string expected_service_instance_id = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Feedback {
|
||||||
|
CommandHeader.Feedback header = 1;
|
||||||
|
string action_id = 2;
|
||||||
|
|
||||||
|
// Number of steps which completed successfully before the final result.
|
||||||
|
uint32 completed_steps = 3;
|
||||||
|
|
||||||
|
// Present only when a particular step caused failure, cancellation,
|
||||||
|
// timeout, or rejection. Presence distinguishes index zero from no
|
||||||
|
// failed step.
|
||||||
|
optional uint32 failed_step_index = 4;
|
||||||
|
ActionResultCode result = 5;
|
||||||
|
string service_instance_id = 6;
|
||||||
|
ActionDeduplicationStatus deduplication_status = 7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -13,4 +13,6 @@ service SystemService {
|
|||||||
rpc UpdateParams(UpdateParamsCommand.Request) returns (UpdateParamsCommand.Feedback) {}
|
rpc UpdateParams(UpdateParamsCommand.Request) returns (UpdateParamsCommand.Feedback) {}
|
||||||
|
|
||||||
rpc StopAll(StopAllCommand.Request) returns (StopAllCommand.Feedback) {}
|
rpc StopAll(StopAllCommand.Request) returns (StopAllCommand.Feedback) {}
|
||||||
|
|
||||||
|
rpc ExecuteActionQueue(ActionQueueCommand.Request) returns (ActionQueueCommand.Feedback) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import java.io.InputStream;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@ -134,7 +135,7 @@ public class MinioService {
|
|||||||
.bucket(bucketName)
|
.bucket(bucketName)
|
||||||
.object(path + "/" + objectName)
|
.object(path + "/" + objectName)
|
||||||
.stream(stream, file.length(), -1) // 传入文件流
|
.stream(stream, file.length(), -1) // 传入文件流
|
||||||
.contentType("application/octet-stream") // 可以根据需要修改文件类型
|
.contentType(resolveContentType(file))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// 上传文件到 MinIO
|
// 上传文件到 MinIO
|
||||||
@ -147,6 +148,24 @@ public class MinioService {
|
|||||||
return objectName;
|
return objectName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String resolveContentType(File file) {
|
||||||
|
try {
|
||||||
|
String detected = Files.probeContentType(file.toPath());
|
||||||
|
if (StringUtils.isNotBlank(detected)) {
|
||||||
|
return detected;
|
||||||
|
}
|
||||||
|
} catch (Exception exception) {
|
||||||
|
log.debug("无法自动识别文件媒体类型: {}", file.getName(), exception);
|
||||||
|
}
|
||||||
|
String name = file.getName().toLowerCase(Locale.ROOT);
|
||||||
|
if (name.endsWith(".wav")) return "audio/wav";
|
||||||
|
if (name.endsWith(".mp3")) return "audio/mpeg";
|
||||||
|
if (name.endsWith(".mp4")) return "video/mp4";
|
||||||
|
if (name.endsWith(".jpg") || name.endsWith(".jpeg")) return "image/jpeg";
|
||||||
|
if (name.endsWith(".png")) return "image/png";
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文件上传
|
* 文件上传
|
||||||
*
|
*
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.cmvr.inspection.domain;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import io.swagger.annotations.ApiModel;
|
import io.swagger.annotations.ApiModel;
|
||||||
import io.swagger.annotations.ApiModelProperty;
|
import io.swagger.annotations.ApiModelProperty;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
@ -46,6 +47,14 @@ public class InspectionTaskLog extends BaseEntity
|
|||||||
@ApiModelProperty("检查项id")
|
@ApiModelProperty("检查项id")
|
||||||
private String itemId;
|
private String itemId;
|
||||||
|
|
||||||
|
/** 当前绑定项的执行位置,从 1 开始。 */
|
||||||
|
@TableField(exist = false)
|
||||||
|
private Integer itemOrder;
|
||||||
|
|
||||||
|
/** 同一检测项在任务中第几次出现。 */
|
||||||
|
@TableField(exist = false)
|
||||||
|
private Integer itemOccurrence;
|
||||||
|
|
||||||
@Excel(name = "节点ID")
|
@Excel(name = "节点ID")
|
||||||
@ApiModelProperty("节点ID")
|
@ApiModelProperty("节点ID")
|
||||||
private String nodeId;
|
private String nodeId;
|
||||||
|
|||||||
@ -55,6 +55,10 @@ public class InspectionTaskInstanceVo extends BaseEntity
|
|||||||
@ApiModelProperty("机器人名称")
|
@ApiModelProperty("机器人名称")
|
||||||
private String robotName;
|
private String robotName;
|
||||||
|
|
||||||
|
/** 主要机器人的永久身份,用于兼容旧任务的执行默认值 */
|
||||||
|
@ApiModelProperty("主要机器人的永久身份ID")
|
||||||
|
private String robotIdentity;
|
||||||
|
|
||||||
/** 地图ID */
|
/** 地图ID */
|
||||||
@Excel(name = "地图ID")
|
@Excel(name = "地图ID")
|
||||||
@ApiModelProperty("地图ID")
|
@ApiModelProperty("地图ID")
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.cmvr.inspection.domain.vo;
|
package com.cmvr.inspection.domain.vo;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import com.cmvr.test.model.vo.ResourceRuntimeAssignmentVO;
|
import com.cmvr.test.model.vo.ResourceRuntimeAssignmentVO;
|
||||||
import io.swagger.annotations.ApiModel;
|
import io.swagger.annotations.ApiModel;
|
||||||
import io.swagger.annotations.ApiModelProperty;
|
import io.swagger.annotations.ApiModelProperty;
|
||||||
@ -17,4 +18,7 @@ public class InspectionTaskStartVO {
|
|||||||
|
|
||||||
@ApiModelProperty("机器人角色运行分配")
|
@ApiModelProperty("机器人角色运行分配")
|
||||||
private List<ResourceRuntimeAssignmentVO> runtimeAssignments = new ArrayList<>();
|
private List<ResourceRuntimeAssignmentVO> runtimeAssignments = new ArrayList<>();
|
||||||
|
|
||||||
|
@ApiModelProperty("按检测项和出现序号组织的本次运行参数")
|
||||||
|
private JSONObject runParams = new JSONObject();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
package com.cmvr.inspection.listener;
|
package com.cmvr.inspection.listener;
|
||||||
|
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import com.cmvr.common.utils.DateUtils;
|
import com.cmvr.common.utils.DateUtils;
|
||||||
import com.cmvr.framework.websocket.service.MessagePushService;
|
import com.cmvr.framework.websocket.service.MessagePushService;
|
||||||
import com.cmvr.device.service.InspectionAlertListenService;
|
import com.cmvr.device.service.InspectionAlertListenService;
|
||||||
@ -15,6 +16,7 @@ import com.cmvr.inspection.service.IInspectionResultService;
|
|||||||
import com.cmvr.test.flow.runtime.event.FlowExecutionEvent;
|
import com.cmvr.test.flow.runtime.event.FlowExecutionEvent;
|
||||||
import com.cmvr.test.flow.runtime.event.FlowExecutionListener;
|
import com.cmvr.test.flow.runtime.event.FlowExecutionListener;
|
||||||
import com.cmvr.test.flow.runtime.event.FlowExecutionModuleCodes;
|
import com.cmvr.test.flow.runtime.event.FlowExecutionModuleCodes;
|
||||||
|
import com.cmvr.test.flow.runtime.operator.FlowMediaParamResolver;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@ -71,13 +73,14 @@ public class InspectionFlowExecutionListener implements FlowExecutionListener {
|
|||||||
// 1.节点完成才记录当前item下node
|
// 1.节点完成才记录当前item下node
|
||||||
if (StrUtil.isNotBlank(itemId) && StrUtil.isNotBlank(nodeId)
|
if (StrUtil.isNotBlank(itemId) && StrUtil.isNotBlank(nodeId)
|
||||||
&& FlowExecutionEvent.EventType.NODE_COMPLETED == event.getEventType()) {
|
&& FlowExecutionEvent.EventType.NODE_COMPLETED == event.getEventType()) {
|
||||||
itemNodeExecutMap.computeIfAbsent(itemExecutionKey(instId, itemId),
|
itemNodeExecutMap.computeIfAbsent(itemExecutionKey(instId, itemId, event.getItemOrder()),
|
||||||
k -> java.util.concurrent.ConcurrentHashMap.newKeySet()).add(nodeId);
|
k -> java.util.concurrent.ConcurrentHashMap.newKeySet()).add(nodeId);
|
||||||
}
|
}
|
||||||
// 任意事件:当前任务绑定item(item一启动就入Map,所以统计已完成item要-1)
|
// 任意事件:当前任务绑定item(item一启动就入Map,所以统计已完成item要-1)
|
||||||
if (StrUtil.isNotBlank(instId) && StrUtil.isNotBlank(itemId)) {
|
if (StrUtil.isNotBlank(instId) && StrUtil.isNotBlank(itemId)) {
|
||||||
taskItemExecutMap.computeIfAbsent(instId,
|
taskItemExecutMap.computeIfAbsent(instId,
|
||||||
k -> java.util.concurrent.ConcurrentHashMap.newKeySet()).add(itemId);
|
k -> java.util.concurrent.ConcurrentHashMap.newKeySet())
|
||||||
|
.add(itemExecutionKey(instId, itemId, event.getItemOrder()));
|
||||||
}
|
}
|
||||||
|
|
||||||
InspectionTaskInstance taskInstance = findTaskInstance(instId);
|
InspectionTaskInstance taskInstance = findTaskInstance(instId);
|
||||||
@ -133,11 +136,14 @@ public class InspectionFlowExecutionListener implements FlowExecutionListener {
|
|||||||
|
|
||||||
// 当前item已完成节点数
|
// 当前item已完成节点数
|
||||||
Set<String> finishedNodeSet = itemNodeExecutMap.getOrDefault(
|
Set<String> finishedNodeSet = itemNodeExecutMap.getOrDefault(
|
||||||
itemExecutionKey(instId, itemId), new HashSet<>());
|
itemExecutionKey(instId, itemId, event.getItemOrder()), new HashSet<>());
|
||||||
int finishedNode = finishedNodeSet.size();
|
int finishedNode = finishedNodeSet.size();
|
||||||
int totalNode = event.getNodeCount() == 0 ? 1 : event.getNodeCount();
|
int totalNode = event.getNodeCount() == 0 ? 1 : event.getNodeCount();
|
||||||
|
|
||||||
// 已完整跑完点位占比
|
// 已完整跑完点位占比
|
||||||
|
if (event.getItemOrder() != null && event.getItemOrder() > 0) {
|
||||||
|
finishedItemNum = event.getItemOrder() - 1;
|
||||||
|
}
|
||||||
double itemTotalRatio = finishedItemNum / (double) waypointCount;
|
double itemTotalRatio = finishedItemNum / (double) waypointCount;
|
||||||
// 当前运行item内部节点占比
|
// 当前运行item内部节点占比
|
||||||
double innerRatio = finishedNode / (double) totalNode / waypointCount;
|
double innerRatio = finishedNode / (double) totalNode / waypointCount;
|
||||||
@ -153,16 +159,37 @@ public class InspectionFlowExecutionListener implements FlowExecutionListener {
|
|||||||
String logId = UUID.randomUUID().toString().replace("-", "");
|
String logId = UUID.randomUUID().toString().replace("-", "");
|
||||||
// 四舍五入保留2位小数
|
// 四舍五入保留2位小数
|
||||||
progress = Math.round(progress * 100) / 100.0;
|
progress = Math.round(progress * 100) / 100.0;
|
||||||
|
JSONObject outputParams = event.getOutputParams();
|
||||||
|
String imageUrl = FlowMediaParamResolver.lastUrl(outputParams, "imageUrl");
|
||||||
|
String videoUrl = FlowMediaParamResolver.lastUrl(outputParams, "videoUrl");
|
||||||
|
String audioUrl = FlowMediaParamResolver.lastUrl(outputParams, "audioUrl");
|
||||||
|
int logType = InspectionLogTypeEnum.TEXT.getCode();
|
||||||
|
String mediaUrl = null;
|
||||||
|
if (StrUtil.isNotBlank(imageUrl)) {
|
||||||
|
logType = InspectionLogTypeEnum.IMAGE.getCode();
|
||||||
|
mediaUrl = imageUrl;
|
||||||
|
} else if (StrUtil.isNotBlank(videoUrl)) {
|
||||||
|
logType = InspectionLogTypeEnum.VIDEO.getCode();
|
||||||
|
mediaUrl = videoUrl;
|
||||||
|
} else if (StrUtil.isNotBlank(audioUrl)) {
|
||||||
|
logType = InspectionLogTypeEnum.AUDIO.getCode();
|
||||||
|
mediaUrl = audioUrl;
|
||||||
|
}
|
||||||
// 保存日志到数据库
|
// 保存日志到数据库
|
||||||
InspectionTaskLog taskLog = InspectionTaskLog.builder()
|
InspectionTaskLog taskLog = InspectionTaskLog.builder()
|
||||||
.id(logId)
|
.id(logId)
|
||||||
.taskInstanceId(taskInstance.getId())
|
.taskInstanceId(taskInstance.getId())
|
||||||
.taskId(taskInstance.getTaskId())
|
.taskId(taskInstance.getTaskId())
|
||||||
.itemId(itemId)
|
.itemId(itemId)
|
||||||
|
.itemOrder(event.getItemOrder())
|
||||||
|
.itemOccurrence(event.getItemOccurrence())
|
||||||
.nodeId(nodeId)
|
.nodeId(nodeId)
|
||||||
.nodeName(event.getNodeName())
|
.nodeName(event.getNodeName())
|
||||||
.logType(InspectionLogTypeEnum.TEXT.getCode())
|
.logType(logType)
|
||||||
.logContent("节点【" + event.getNodeName() + "】" + info)
|
.logContent("节点【" + event.getNodeName() + "】" + info)
|
||||||
|
.mediaUrl(mediaUrl)
|
||||||
|
.extraInfo(outputParams == null || outputParams.isEmpty()
|
||||||
|
? null : outputParams.toJSONString())
|
||||||
.status(targetStatus)
|
.status(targetStatus)
|
||||||
.progress(progress)
|
.progress(progress)
|
||||||
.logTime(now)
|
.logTime(now)
|
||||||
@ -185,7 +212,7 @@ public class InspectionFlowExecutionListener implements FlowExecutionListener {
|
|||||||
/** 任务结束统一清理缓存 */
|
/** 任务结束统一清理缓存 */
|
||||||
private void clearCache(String taskInsId) {
|
private void clearCache(String taskInsId) {
|
||||||
Set<String> itemList = taskItemExecutMap.getOrDefault(taskInsId, new HashSet<>());
|
Set<String> itemList = taskItemExecutMap.getOrDefault(taskInsId, new HashSet<>());
|
||||||
itemList.forEach(itemId -> itemNodeExecutMap.remove(itemExecutionKey(taskInsId, itemId)));
|
itemList.forEach(itemNodeExecutMap::remove);
|
||||||
taskItemExecutMap.remove(taskInsId);
|
taskItemExecutMap.remove(taskInsId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,6 +220,11 @@ public class InspectionFlowExecutionListener implements FlowExecutionListener {
|
|||||||
return taskInsId + "|" + itemId;
|
return taskInsId + "|" + itemId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String itemExecutionKey(String taskInsId, String itemId, Integer itemOrder) {
|
||||||
|
return itemOrder != null && itemOrder > 0 ? taskInsId + "|" + itemOrder
|
||||||
|
: itemExecutionKey(taskInsId, itemId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工作流是异步启动的,首个节点事件可能略早于巡检实例写入task_ins_id,短暂重试消除该竞态。
|
* 工作流是异步启动的,首个节点事件可能略早于巡检实例写入task_ins_id,短暂重试消除该竞态。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -6,10 +6,16 @@ import com.cmvr.test.flow.runtime.engine.RobotDeviceAssignmentValidator;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class InspectionRobotDeviceAssignmentValidator implements RobotDeviceAssignmentValidator {
|
public class InspectionRobotDeviceAssignmentValidator implements RobotDeviceAssignmentValidator {
|
||||||
|
|
||||||
|
private static final int MICROPHONE_DEVICE_KIND = 9;
|
||||||
|
private static final String RECORDING_STREAM_CONFLICT =
|
||||||
|
"cannot start streaming while microphone recording is active";
|
||||||
|
|
||||||
private final InspectionRobotDeviceMapper deviceMapper;
|
private final InspectionRobotDeviceMapper deviceMapper;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -18,9 +24,28 @@ public class InspectionRobotDeviceAssignmentValidator implements RobotDeviceAssi
|
|||||||
if (devices.isEmpty()) {
|
if (devices.isEmpty()) {
|
||||||
throw new GlobalException("设备[" + deviceId + "]不属于机器人[" + robotId + "]");
|
throw new GlobalException("设备[" + deviceId + "]不属于机器人[" + robotId + "]");
|
||||||
}
|
}
|
||||||
Integer actualKind = devices.get(0).getDeviceKind();
|
var device = devices.get(0);
|
||||||
|
Integer actualKind = device.getDeviceKind();
|
||||||
if (expectedDeviceKind != null && !expectedDeviceKind.equals(actualKind)) {
|
if (expectedDeviceKind != null && !expectedDeviceKind.equals(actualKind)) {
|
||||||
throw new GlobalException("设备[" + deviceId + "]的类型与流程设备用途不匹配");
|
throw new GlobalException("设备[" + deviceId + "]的类型与流程设备用途不匹配");
|
||||||
}
|
}
|
||||||
|
if (!"1".equals(device.getOnlineStatus())) {
|
||||||
|
throw new GlobalException("设备[" + deviceId + "]当前离线,请重新选择在线设备");
|
||||||
|
}
|
||||||
|
if (!"1".equals(device.getEnabled())) {
|
||||||
|
throw new GlobalException("设备[" + deviceId + "]当前未启用");
|
||||||
|
}
|
||||||
|
boolean recoverableMicrophoneConflict = Integer.valueOf(MICROPHONE_DEVICE_KIND).equals(actualKind)
|
||||||
|
&& device.getErrorMessage() != null
|
||||||
|
&& device.getErrorMessage().toLowerCase(Locale.ROOT).contains(RECORDING_STREAM_CONFLICT);
|
||||||
|
if ("1".equals(device.getHasError()) && !recoverableMicrophoneConflict) {
|
||||||
|
String detail = device.getErrorMessage() == null || device.getErrorMessage().isBlank()
|
||||||
|
? "设备上报异常"
|
||||||
|
: device.getErrorMessage();
|
||||||
|
throw new GlobalException("设备[" + deviceId + "]当前异常: " + detail);
|
||||||
|
}
|
||||||
|
if (Integer.valueOf(3).equals(device.getHealthStatus()) && !recoverableMicrophoneConflict) {
|
||||||
|
throw new GlobalException("设备[" + deviceId + "]当前处于故障状态");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,12 @@ package com.cmvr.inspection.service.impl;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import com.alibaba.fastjson2.JSONObject;
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
import com.cmvr.common.exception.ServiceException;
|
import com.cmvr.common.exception.ServiceException;
|
||||||
import com.cmvr.common.utils.SecurityUtils;
|
import com.cmvr.common.utils.SecurityUtils;
|
||||||
import com.cmvr.inspection.domain.InspectionRobot;
|
import com.cmvr.inspection.domain.InspectionRobot;
|
||||||
@ -18,6 +21,9 @@ import com.cmvr.test.flow.control.FlowControlService;
|
|||||||
import com.cmvr.test.flow.runtime.engine.FlowTaskRuntimeService;
|
import com.cmvr.test.flow.runtime.engine.FlowTaskRuntimeService;
|
||||||
import com.cmvr.test.flow.runtime.event.FlowExecutionModuleCodes;
|
import com.cmvr.test.flow.runtime.event.FlowExecutionModuleCodes;
|
||||||
import com.cmvr.test.model.vo.TeTaskExecuteNormalVO;
|
import com.cmvr.test.model.vo.TeTaskExecuteNormalVO;
|
||||||
|
import com.cmvr.test.model.vo.TeTaskOrchestraVO;
|
||||||
|
import com.cmvr.test.model.vo.ResourceRuntimeAssignmentVO;
|
||||||
|
import com.cmvr.test.model.vo.RobotRuntimeMemberVO;
|
||||||
import com.cmvr.test.service.ITeTaskOrchestrationService;
|
import com.cmvr.test.service.ITeTaskOrchestrationService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.context.annotation.Lazy;
|
import org.springframework.context.annotation.Lazy;
|
||||||
@ -27,6 +33,7 @@ import com.cmvr.inspection.mapper.InspectionTaskInstanceMapper;
|
|||||||
import com.cmvr.inspection.domain.InspectionTaskInstance;
|
import com.cmvr.inspection.domain.InspectionTaskInstance;
|
||||||
import com.cmvr.inspection.domain.vo.InspectionTaskStartVO;
|
import com.cmvr.inspection.domain.vo.InspectionTaskStartVO;
|
||||||
import com.cmvr.inspection.service.IInspectionTaskInstanceService;
|
import com.cmvr.inspection.service.IInspectionTaskInstanceService;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 巡检任务执行实例Service业务层处理
|
* 巡检任务执行实例Service业务层处理
|
||||||
@ -160,18 +167,60 @@ public class InspectionTaskInstanceServiceImpl extends ServiceImpl<InspectionTas
|
|||||||
update.setUpdateTime(DateUtils.getNowDate());
|
update.setUpdateTime(DateUtils.getNowDate());
|
||||||
update.setUpdateBy(SecurityUtils.getUsername());
|
update.setUpdateBy(SecurityUtils.getUsername());
|
||||||
InspectionTask inspectionTask = inspectionTaskService.selectInspectionTaskById(instance.getTaskId());
|
InspectionTask inspectionTask = inspectionTaskService.selectInspectionTaskById(instance.getTaskId());
|
||||||
InspectionRobot robot = inspectionRobotMapper.selectInspectionRobotById(instance.getRobotId());
|
if (inspectionTask == null || StrUtil.isBlank(inspectionTask.getTaskConfigId())) {
|
||||||
if (robot == null) {
|
throw new ServiceException("巡检任务没有关联流程编排");
|
||||||
throw new ServiceException("巡检任务实例关联的机器人不存在");
|
|
||||||
}
|
}
|
||||||
|
List<ResourceRuntimeAssignmentVO> runtimeAssignments = startVO == null
|
||||||
|
? null : startVO.getRuntimeAssignments();
|
||||||
|
normalizeRuntimeRobotIdentities(runtimeAssignments);
|
||||||
|
String robotId = normalizeRobotIdentity(startVO == null ? null : startVO.getRobotId());
|
||||||
|
if (StrUtil.isBlank(robotId)) {
|
||||||
|
robotId = firstAssignedRobotId(runtimeAssignments);
|
||||||
|
}
|
||||||
|
if (StrUtil.isBlank(robotId) && StrUtil.isNotBlank(instance.getRobotId())) {
|
||||||
|
InspectionRobot legacyRobot = inspectionRobotMapper.selectInspectionRobotById(instance.getRobotId());
|
||||||
|
if (legacyRobot == null || StrUtil.isBlank(legacyRobot.getRobotId())) {
|
||||||
|
throw new ServiceException("巡检任务实例关联的旧机器人不存在");
|
||||||
|
}
|
||||||
|
robotId = legacyRobot.getRobotId();
|
||||||
|
}
|
||||||
|
InspectionRobot primaryRobot = findRobotByIdentity(robotId);
|
||||||
|
if (primaryRobot != null) {
|
||||||
|
update.setRobotId(primaryRobot.getId());
|
||||||
|
}
|
||||||
|
List<String> orderedItemIds = inspectionTaskService.selectWaypointsByTaskId(instance.getTaskId()).stream()
|
||||||
|
.map(com.cmvr.inspection.domain.InspectionWaypoint::getDetectItemId)
|
||||||
|
.filter(StrUtil::isNotBlank)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
if (orderedItemIds.isEmpty()) {
|
||||||
|
throw new ServiceException("巡检任务没有绑定可执行的巡检点位");
|
||||||
|
}
|
||||||
|
teTaskOrchestrationService.insertTeTaskOrchestration(TeTaskOrchestraVO.builder()
|
||||||
|
.taskId(inspectionTask.getTaskConfigId())
|
||||||
|
.itemIds(orderedItemIds)
|
||||||
|
.build());
|
||||||
JSONObject runParams = new JSONObject();
|
JSONObject runParams = new JSONObject();
|
||||||
|
JSONObject requestedRunParams = startVO == null ? null : startVO.getRunParams();
|
||||||
|
Map<String, Integer> itemOccurrences = new HashMap<>();
|
||||||
// 获取所有的检测项id
|
// 获取所有的检测项id
|
||||||
teTaskOrchestrationService.queryByTaskId(inspectionTask.getTaskConfigId()).forEach(item -> runParams.put(item.getItemId(), new JSONObject()));
|
teTaskOrchestrationService.queryByTaskId(inspectionTask.getTaskConfigId()).forEach(item -> {
|
||||||
|
int occurrence = itemOccurrences.merge(item.getItemId(), 1, Integer::sum);
|
||||||
|
JSONObject submitted = requestedRunParams == null ? null
|
||||||
|
: requestedRunParams.getJSONObject(item.getItemId() + ":" + occurrence);
|
||||||
|
if (submitted == null && requestedRunParams != null) {
|
||||||
|
submitted = requestedRunParams.getJSONObject(item.getItemId());
|
||||||
|
}
|
||||||
|
JSONObject itemParams = submitted == null ? new JSONObject() : new JSONObject(submitted);
|
||||||
|
itemParams.put("taskInstanceId", instance.getId());
|
||||||
|
itemParams.put("inspectionTaskId", inspectionTask.getId());
|
||||||
|
itemParams.put("mapId", inspectionTask.getMapId());
|
||||||
|
runParams.put(item.getItemId() + ":" + occurrence, itemParams);
|
||||||
|
});
|
||||||
// 调用真正的执行
|
// 调用真正的执行
|
||||||
String insId = flowTaskRuntimeService.executeTask(TeTaskExecuteNormalVO.builder()
|
String insId = flowTaskRuntimeService.executeTask(TeTaskExecuteNormalVO.builder()
|
||||||
.taskId(inspectionTask.getTaskConfigId())
|
.taskId(inspectionTask.getTaskConfigId())
|
||||||
.robotId(startVO != null && startVO.getRobotId() != null ? startVO.getRobotId() : robot.getRobotId())
|
.robotId(robotId)
|
||||||
.runtimeAssignments(startVO == null ? null : startVO.getRuntimeAssignments())
|
.runtimeAssignments(runtimeAssignments)
|
||||||
.moduleCode(FlowExecutionModuleCodes.INSPECTION)
|
.moduleCode(FlowExecutionModuleCodes.INSPECTION)
|
||||||
.runParams(runParams)
|
.runParams(runParams)
|
||||||
.build());
|
.build());
|
||||||
@ -179,6 +228,55 @@ public class InspectionTaskInstanceServiceImpl extends ServiceImpl<InspectionTas
|
|||||||
return inspectionTaskInstanceMapper.updateInspectionTaskInstance(update);
|
return inspectionTaskInstanceMapper.updateInspectionTaskInstance(update);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void normalizeRuntimeRobotIdentities(List<ResourceRuntimeAssignmentVO> assignments) {
|
||||||
|
if (assignments == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (ResourceRuntimeAssignmentVO assignment : assignments) {
|
||||||
|
if (assignment == null || assignment.getMembers() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (RobotRuntimeMemberVO member : assignment.getMembers()) {
|
||||||
|
if (member != null) {
|
||||||
|
member.setRobotId(normalizeRobotIdentity(member.getRobotId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeRobotIdentity(String value) {
|
||||||
|
if (StrUtil.isBlank(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
InspectionRobot robot = inspectionRobotMapper.selectInspectionRobotById(value);
|
||||||
|
return robot != null && StrUtil.isNotBlank(robot.getRobotId()) ? robot.getRobotId() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String firstAssignedRobotId(List<ResourceRuntimeAssignmentVO> assignments) {
|
||||||
|
if (assignments == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (ResourceRuntimeAssignmentVO assignment : assignments) {
|
||||||
|
if (assignment == null || assignment.getMembers() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (RobotRuntimeMemberVO member : assignment.getMembers()) {
|
||||||
|
if (member != null && StrUtil.isNotBlank(member.getRobotId())) {
|
||||||
|
return member.getRobotId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private InspectionRobot findRobotByIdentity(String robotId) {
|
||||||
|
if (StrUtil.isBlank(robotId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<InspectionRobot> robots = inspectionRobotMapper.selectByRobotId(robotId);
|
||||||
|
return robots == null || robots.isEmpty() ? null : robots.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 暂停任务实例
|
* 暂停任务实例
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import com.cmvr.test.service.ITeTaskConfigInfoService;
|
|||||||
import com.cmvr.test.service.ITeTaskOrchestrationService;
|
import com.cmvr.test.service.ITeTaskOrchestrationService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import com.cmvr.common.utils.DateUtils;
|
import com.cmvr.common.utils.DateUtils;
|
||||||
import com.cmvr.common.utils.SecurityUtils;
|
import com.cmvr.common.utils.SecurityUtils;
|
||||||
import com.cmvr.inspection.mapper.InspectionTaskMapper;
|
import com.cmvr.inspection.mapper.InspectionTaskMapper;
|
||||||
@ -144,11 +145,22 @@ public class InspectionTaskServiceImpl extends ServiceImpl<InspectionTaskMapper,
|
|||||||
* @return 结果
|
* @return 结果
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public int bindWaypoints(String taskId, String[] waypointIds)
|
public int bindWaypoints(String taskId, String[] waypointIds)
|
||||||
{
|
{
|
||||||
|
inspectionTaskWaypointMapper.deleteTaskWaypointByTaskId(taskId);
|
||||||
|
InspectionTask inspectionTask = inspectionTaskMapper.selectInspectionTaskById(taskId);
|
||||||
|
if (inspectionTask == null) {
|
||||||
|
throw new IllegalArgumentException("巡检任务不存在: " + taskId);
|
||||||
|
}
|
||||||
// 先删除旧的关联关系
|
// 先删除旧的关联关系
|
||||||
inspectionTaskWaypointMapper.deleteTaskWaypointByTaskId(taskId);
|
inspectionTaskWaypointMapper.deleteTaskWaypointByTaskId(taskId);
|
||||||
if (waypointIds == null || waypointIds.length == 0) {
|
if (waypointIds == null || waypointIds.length == 0) {
|
||||||
|
inspectionTask.setWaypointCount(0);
|
||||||
|
inspectionTaskMapper.updateInspectionTask(inspectionTask);
|
||||||
|
if (inspectionTask.getTaskConfigId() != null) {
|
||||||
|
teTaskOrchestrationService.deleteByTaskId(inspectionTask.getTaskConfigId());
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
// 批量插入新的关联关系
|
// 批量插入新的关联关系
|
||||||
@ -162,14 +174,24 @@ public class InspectionTaskServiceImpl extends ServiceImpl<InspectionTaskMapper,
|
|||||||
}
|
}
|
||||||
inspectionTaskWaypointMapper.batchInsertTaskWaypoint(list);
|
inspectionTaskWaypointMapper.batchInsertTaskWaypoint(list);
|
||||||
// 查询巡检任务,并修改点位数量
|
// 查询巡检任务,并修改点位数量
|
||||||
InspectionTask inspectionTask = inspectionTaskMapper.selectInspectionTaskById(taskId);
|
|
||||||
inspectionTask.setWaypointCount(waypointIds.length);
|
inspectionTask.setWaypointCount(waypointIds.length);
|
||||||
inspectionTaskMapper.updateInspectionTask(inspectionTask);
|
inspectionTaskMapper.updateInspectionTask(inspectionTask);
|
||||||
// 查询点位
|
// 查询点位
|
||||||
List<InspectionWaypoint> waypoints = inspectionTaskWaypointMapper.selectWaypointsByTaskId(taskId);
|
List<InspectionWaypoint> waypoints = inspectionTaskWaypointMapper.selectWaypointsByTaskId(taskId);
|
||||||
// 同步任务编排
|
// 同步任务编排
|
||||||
TeTaskOrchestraVO teTaskOrchestraVO = TeTaskOrchestraVO.builder().taskId(inspectionTask.getTaskConfigId()).itemIds(waypoints.stream().map(InspectionWaypoint::getDetectItemId).collect(Collectors.toList())).build();
|
List<String> detectItemIds = waypoints.stream()
|
||||||
|
.map(InspectionWaypoint::getDetectItemId)
|
||||||
|
.filter(itemId -> itemId != null && !itemId.isEmpty())
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
if (!detectItemIds.isEmpty() && inspectionTask.getTaskConfigId() != null) {
|
||||||
|
TeTaskOrchestraVO teTaskOrchestraVO = TeTaskOrchestraVO.builder()
|
||||||
|
.taskId(inspectionTask.getTaskConfigId())
|
||||||
|
.itemIds(detectItemIds)
|
||||||
|
.build();
|
||||||
teTaskOrchestrationService.insertTeTaskOrchestration(teTaskOrchestraVO);
|
teTaskOrchestrationService.insertTeTaskOrchestration(teTaskOrchestraVO);
|
||||||
|
} else if (inspectionTask.getTaskConfigId() != null) {
|
||||||
|
teTaskOrchestrationService.deleteByTaskId(inspectionTask.getTaskConfigId());
|
||||||
|
}
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,8 +202,18 @@ public class InspectionTaskServiceImpl extends ServiceImpl<InspectionTaskMapper,
|
|||||||
* @return 结果
|
* @return 结果
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public int unbindWaypoints(String taskId)
|
public int unbindWaypoints(String taskId)
|
||||||
{
|
{
|
||||||
return inspectionTaskWaypointMapper.deleteTaskWaypointByTaskId(taskId);
|
InspectionTask inspectionTask = inspectionTaskMapper.selectInspectionTaskById(taskId);
|
||||||
|
int deleted = inspectionTaskWaypointMapper.deleteTaskWaypointByTaskId(taskId);
|
||||||
|
if (inspectionTask != null) {
|
||||||
|
inspectionTask.setWaypointCount(0);
|
||||||
|
inspectionTaskMapper.updateInspectionTask(inspectionTask);
|
||||||
|
if (inspectionTask.getTaskConfigId() != null) {
|
||||||
|
teTaskOrchestrationService.deleteByTaskId(inspectionTask.getTaskConfigId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||||||
<result property="taskCode" column="task_code" />
|
<result property="taskCode" column="task_code" />
|
||||||
<result property="robotId" column="robot_id" />
|
<result property="robotId" column="robot_id" />
|
||||||
<result property="robotName" column="robot_name" />
|
<result property="robotName" column="robot_name" />
|
||||||
|
<result property="robotIdentity" column="robot_identity" />
|
||||||
<result property="mapId" column="robot_current_map_id" />
|
<result property="mapId" column="robot_current_map_id" />
|
||||||
<result property="mapName" column="map_name" />
|
<result property="mapName" column="map_name" />
|
||||||
<result property="status" column="status" />
|
<result property="status" column="status" />
|
||||||
@ -129,7 +130,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||||||
<select id="selectInspectionTaskInstanceVoList" parameterType="InspectionTaskInstanceQuery" resultMap="InspectionTaskInstanceVoResult">
|
<select id="selectInspectionTaskInstanceVoList" parameterType="InspectionTaskInstanceQuery" resultMap="InspectionTaskInstanceVoResult">
|
||||||
select ti.id, ti.create_by, ti.create_time, ti.update_by, ti.update_time, ti.remark,ti.waypoint_count,
|
select ti.id, ti.create_by, ti.create_time, ti.update_by, ti.update_time, ti.remark,ti.waypoint_count,
|
||||||
ti.task_id, t.task_name, t.task_code,
|
ti.task_id, t.task_name, t.task_code,
|
||||||
ti.robot_id, r.robot_name,
|
ti.robot_id, r.robot_name, r.robot_id as robot_identity,
|
||||||
r.current_map_id as robot_current_map_id, m.map_name,
|
r.current_map_id as robot_current_map_id, m.map_name,
|
||||||
ti.status, ti.start_time, ti.end_time, ti.task_ins_id,
|
ti.status, ti.start_time, ti.end_time, ti.task_ins_id,
|
||||||
u1.nick_name as create_by_name, u2.nick_name as update_by_name
|
u1.nick_name as create_by_name, u2.nick_name as update_by_name
|
||||||
@ -168,7 +169,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||||||
<select id="selectInspectionTaskInstanceVoById" parameterType="String" resultMap="InspectionTaskInstanceVoResult">
|
<select id="selectInspectionTaskInstanceVoById" parameterType="String" resultMap="InspectionTaskInstanceVoResult">
|
||||||
select ti.id, ti.create_by, ti.create_time, ti.update_by, ti.update_time, ti.remark,
|
select ti.id, ti.create_by, ti.create_time, ti.update_by, ti.update_time, ti.remark,
|
||||||
ti.task_id, t.task_name, t.task_code,
|
ti.task_id, t.task_name, t.task_code,
|
||||||
ti.robot_id, r.robot_name,
|
ti.robot_id, r.robot_name, r.robot_id as robot_identity,
|
||||||
r.current_map_id as robot_current_map_id, m.map_name,
|
r.current_map_id as robot_current_map_id, m.map_name,
|
||||||
ti.status, ti.start_time, ti.end_time, ti.task_ins_id
|
ti.status, ti.start_time, ti.end_time, ti.task_ins_id
|
||||||
from inspection_task_instance ti
|
from inspection_task_instance ti
|
||||||
|
|||||||
@ -12,11 +12,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||||||
|
|
||||||
<select id="selectWaypointsByTaskId" parameterType="String" resultType="InspectionWaypoint">
|
<select id="selectWaypointsByTaskId" parameterType="String" resultType="InspectionWaypoint">
|
||||||
select w.id, w.create_by, w.create_time, w.update_by, w.update_time, w.remark,
|
select w.id, w.create_by, w.create_time, w.update_by, w.update_time, w.remark,
|
||||||
w.waypoint_name, w.enabled, w.sort_order, w.detect_item_id
|
w.waypoint_name, w.enabled, tw.sort_order as sort_order, w.detect_item_id, w.map_id
|
||||||
from inspection_task_waypoint tw
|
from inspection_task_waypoint tw
|
||||||
inner join inspection_waypoint w on tw.waypoint_id = w.id
|
inner join inspection_waypoint w on tw.waypoint_id = w.id
|
||||||
where tw.task_id = #{taskId}
|
where tw.task_id = #{taskId}
|
||||||
order by tw.sort_order
|
order by tw.sort_order, tw.waypoint_id
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="selectTaskIdsByWaypointId" parameterType="String" resultType="String">
|
<select id="selectTaskIdsByWaypointId" parameterType="String" resultType="String">
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import java.util.Map;
|
|||||||
@Getter
|
@Getter
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public enum ActionEnum {
|
public enum ActionEnum {
|
||||||
|
SYSTEM_EXECUTE_ACTION_QUEUE("EDGE", "SYSTEM_EXECUTE_ACTION_QUEUE", "Execute edge action queue"),
|
||||||
SPEAKER_PLAYAUDIO("EDGE", "SPEAKER_PLAYAUDIO", "Play audio"),
|
SPEAKER_PLAYAUDIO("EDGE", "SPEAKER_PLAYAUDIO", "Play audio"),
|
||||||
|
|
||||||
// 平台控制类
|
// 平台控制类
|
||||||
|
|||||||
@ -43,6 +43,12 @@ public class TaskContext {
|
|||||||
*/
|
*/
|
||||||
private String itemId;
|
private String itemId;
|
||||||
|
|
||||||
|
/** 当前检测项在任务中的实际执行位置。 */
|
||||||
|
private Integer itemOrder;
|
||||||
|
|
||||||
|
/** 当前检测项在任务中的重复出现序号。 */
|
||||||
|
private Integer itemOccurrence;
|
||||||
|
|
||||||
/** 机器人永久唯一 ID,用于执行路由和互斥。 */
|
/** 机器人永久唯一 ID,用于执行路由和互斥。 */
|
||||||
private String robotId;
|
private String robotId;
|
||||||
|
|
||||||
|
|||||||
@ -97,6 +97,8 @@ public class TaskInstHolder {
|
|||||||
.taskId(taskId)
|
.taskId(taskId)
|
||||||
.moduleCode(moduleCode)
|
.moduleCode(moduleCode)
|
||||||
.itemId(itemId)
|
.itemId(itemId)
|
||||||
|
.itemOrder(ctx != null ? ctx.getItemOrder() : null)
|
||||||
|
.itemOccurrence(ctx != null ? ctx.getItemOccurrence() : null)
|
||||||
.status(TaskStatusEnum.SUCCESS)
|
.status(TaskStatusEnum.SUCCESS)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
@ -133,6 +135,8 @@ public class TaskInstHolder {
|
|||||||
.taskId(taskId)
|
.taskId(taskId)
|
||||||
.moduleCode(moduleCode)
|
.moduleCode(moduleCode)
|
||||||
.itemId(itemId)
|
.itemId(itemId)
|
||||||
|
.itemOrder(ctx != null ? ctx.getItemOrder() : null)
|
||||||
|
.itemOccurrence(ctx != null ? ctx.getItemOccurrence() : null)
|
||||||
.status(TaskStatusEnum.FAILED)
|
.status(TaskStatusEnum.FAILED)
|
||||||
.errorMessage(message)
|
.errorMessage(message)
|
||||||
.build());
|
.build());
|
||||||
@ -167,6 +171,7 @@ public class TaskInstHolder {
|
|||||||
eventPublisher.publishEvent(FlowExecutionEvent.builder()
|
eventPublisher.publishEvent(FlowExecutionEvent.builder()
|
||||||
.eventType(FlowExecutionEvent.EventType.TASK_PAUSED)
|
.eventType(FlowExecutionEvent.EventType.TASK_PAUSED)
|
||||||
.instId(instId).taskId(ctx.getTaskId()).itemId(ctx.getItemId())
|
.instId(instId).taskId(ctx.getTaskId()).itemId(ctx.getItemId())
|
||||||
|
.itemOrder(ctx.getItemOrder()).itemOccurrence(ctx.getItemOccurrence())
|
||||||
.moduleCode(ctx.getModuleCode())
|
.moduleCode(ctx.getModuleCode())
|
||||||
.status(status).build());
|
.status(status).build());
|
||||||
break;
|
break;
|
||||||
@ -174,6 +179,7 @@ public class TaskInstHolder {
|
|||||||
eventPublisher.publishEvent(FlowExecutionEvent.builder()
|
eventPublisher.publishEvent(FlowExecutionEvent.builder()
|
||||||
.eventType(FlowExecutionEvent.EventType.TASK_STOPPED)
|
.eventType(FlowExecutionEvent.EventType.TASK_STOPPED)
|
||||||
.instId(instId).taskId(ctx.getTaskId()).itemId(ctx.getItemId())
|
.instId(instId).taskId(ctx.getTaskId()).itemId(ctx.getItemId())
|
||||||
|
.itemOrder(ctx.getItemOrder()).itemOccurrence(ctx.getItemOccurrence())
|
||||||
.moduleCode(ctx.getModuleCode())
|
.moduleCode(ctx.getModuleCode())
|
||||||
.status(status).build());
|
.status(status).build());
|
||||||
break;
|
break;
|
||||||
@ -191,6 +197,7 @@ public class TaskInstHolder {
|
|||||||
eventPublisher.publishEvent(FlowExecutionEvent.builder()
|
eventPublisher.publishEvent(FlowExecutionEvent.builder()
|
||||||
.eventType(FlowExecutionEvent.EventType.TASK_RESUMED)
|
.eventType(FlowExecutionEvent.EventType.TASK_RESUMED)
|
||||||
.instId(instId).taskId(ctx.getTaskId()).itemId(ctx.getItemId())
|
.instId(instId).taskId(ctx.getTaskId()).itemId(ctx.getItemId())
|
||||||
|
.itemOrder(ctx.getItemOrder()).itemOccurrence(ctx.getItemOccurrence())
|
||||||
.moduleCode(ctx.getModuleCode())
|
.moduleCode(ctx.getModuleCode())
|
||||||
.status(TaskStatusEnum.RUNNING).build());
|
.status(TaskStatusEnum.RUNNING).build());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,60 @@
|
|||||||
|
package com.cmvr.test.flow.runtime.engine;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.cmvr.test.model.vo.ResourceRuntimeAssignmentVO;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Selects task-level runtime assignments for one detection item. */
|
||||||
|
public final class FlowRuntimeAssignmentScope {
|
||||||
|
|
||||||
|
private FlowRuntimeAssignmentScope() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<ResourceRuntimeAssignmentVO> forItem(
|
||||||
|
List<ResourceRuntimeAssignmentVO> assignments, String itemId, int itemOccurrence) {
|
||||||
|
if (assignments == null || assignments.isEmpty()) {
|
||||||
|
return assignments == null ? new ArrayList<>() : assignments;
|
||||||
|
}
|
||||||
|
boolean containsScopedAssignments = assignments.stream()
|
||||||
|
.anyMatch(item -> item != null && StrUtil.isNotBlank(item.getItemId()));
|
||||||
|
if (!containsScopedAssignments) {
|
||||||
|
return assignments;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, ResourceRuntimeAssignmentVO> selectedByRole = new LinkedHashMap<>();
|
||||||
|
assignments.stream()
|
||||||
|
.filter(item -> item != null && StrUtil.isBlank(item.getItemId()))
|
||||||
|
.forEach(item -> selectedByRole.put(effectiveRoleKey(item), copyForFlow(item)));
|
||||||
|
assignments.stream()
|
||||||
|
.filter(item -> matches(item, itemId, itemOccurrence))
|
||||||
|
.forEach(item -> selectedByRole.put(effectiveRoleKey(item), copyForFlow(item)));
|
||||||
|
return new ArrayList<>(selectedByRole.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean matches(ResourceRuntimeAssignmentVO assignment, String itemId, int itemOccurrence) {
|
||||||
|
if (assignment == null || !StrUtil.equals(assignment.getItemId(), itemId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return assignment.getItemOccurrence() == null
|
||||||
|
|| assignment.getItemOccurrence() == itemOccurrence;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String effectiveRoleKey(ResourceRuntimeAssignmentVO assignment) {
|
||||||
|
return StrUtil.isNotBlank(assignment.getSourceRoleKey())
|
||||||
|
? assignment.getSourceRoleKey() : assignment.getRoleKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResourceRuntimeAssignmentVO copyForFlow(ResourceRuntimeAssignmentVO source) {
|
||||||
|
ResourceRuntimeAssignmentVO copy = new ResourceRuntimeAssignmentVO();
|
||||||
|
copy.setRoleKey(effectiveRoleKey(source));
|
||||||
|
copy.setSourceRoleKey(source.getSourceRoleKey());
|
||||||
|
copy.setItemId(source.getItemId());
|
||||||
|
copy.setItemOccurrence(source.getItemOccurrence());
|
||||||
|
copy.setMembers(source.getMembers());
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -48,6 +48,8 @@ public class FlowRuntimeDefinitionValidator {
|
|||||||
assignments.forEach(item -> assignmentByRole.put(item.getRoleKey(), item));
|
assignments.forEach(item -> assignmentByRole.put(item.getRoleKey(), item));
|
||||||
}
|
}
|
||||||
Map<String, Set<String>> slotKeysByRole = new HashMap<>();
|
Map<String, Set<String>> slotKeysByRole = new HashMap<>();
|
||||||
|
Map<String, Boolean> physicalDeviceReuse = new HashMap<>();
|
||||||
|
Map<String, String> physicalDeviceUseName = new HashMap<>();
|
||||||
|
|
||||||
for (int i = 0; i < roles.size(); i++) {
|
for (int i = 0; i < roles.size(); i++) {
|
||||||
JSONObject role = roles.getJSONObject(i);
|
JSONObject role = roles.getJSONObject(i);
|
||||||
@ -79,10 +81,26 @@ public class FlowRuntimeDefinitionValidator {
|
|||||||
throw new GlobalException("设备用途[" + slot.getString("displayName") + "]未分配真实设备");
|
throw new GlobalException("设备用途[" + slot.getString("displayName") + "]未分配真实设备");
|
||||||
}
|
}
|
||||||
validatePhysicalDevice(member, deviceId, slot.getInteger("deviceKind"));
|
validatePhysicalDevice(member, deviceId, slot.getInteger("deviceKind"));
|
||||||
|
if (member != null && StrUtil.isNotBlank(member.getRobotId()) && StrUtil.isNotBlank(deviceId)) {
|
||||||
|
String physicalKey = member.getRobotId() + ":" + deviceId;
|
||||||
|
boolean allowReuse = Boolean.TRUE.equals(slot.getBoolean("allowReuse"));
|
||||||
|
Boolean existingAllowReuse = physicalDeviceReuse.get(physicalKey);
|
||||||
|
if (existingAllowReuse != null && (!existingAllowReuse || !allowReuse)) {
|
||||||
|
throw new GlobalException("设备用途[" + physicalDeviceUseName.get(physicalKey)
|
||||||
|
+ "]和[" + slot.getString("displayName") + "]不能使用同一个真实设备");
|
||||||
|
}
|
||||||
|
physicalDeviceReuse.put(physicalKey, allowReuse);
|
||||||
|
physicalDeviceUseName.putIfAbsent(physicalKey, slot.getString("displayName"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
slotKeysByRole.put(roleKey, slotKeys);
|
slotKeysByRole.put(roleKey, slotKeys);
|
||||||
}
|
}
|
||||||
|
for (String assignedRoleKey : assignmentByRole.keySet()) {
|
||||||
|
if (!slotKeysByRole.containsKey(assignedRoleKey)) {
|
||||||
|
throw new GlobalException("运行安排引用了流程中不存在的机器人角色:" + assignedRoleKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (int i = 0; i < nodes.size(); i++) {
|
for (int i = 0; i < nodes.size(); i++) {
|
||||||
JSONObject node = nodes.getJSONObject(i);
|
JSONObject node = nodes.getJSONObject(i);
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@ -36,20 +38,28 @@ public class FlowTaskEngine {
|
|||||||
JSONObject runParams = context.getRunParams();
|
JSONObject runParams = context.getRunParams();
|
||||||
|
|
||||||
int totalItemCount = items.size();
|
int totalItemCount = items.size();
|
||||||
|
Map<String, Integer> itemOccurrences = new HashMap<>();
|
||||||
|
|
||||||
for (int i = 0; i < totalItemCount; i++) {
|
for (int i = 0; i < totalItemCount; i++) {
|
||||||
TeQueryTaskDetailDTO item = items.get(i);
|
TeQueryTaskDetailDTO item = items.get(i);
|
||||||
// 等待当前 item 完成后再执行下一个
|
// 等待当前 item 完成后再执行下一个
|
||||||
CountDownLatch latch = new CountDownLatch(1);
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
String itemId = item.getDetectItemId();
|
String itemId = item.getDetectItemId();
|
||||||
|
int itemOccurrence = itemOccurrences.merge(itemId, 1, Integer::sum);
|
||||||
|
int itemOrder = i + 1;
|
||||||
context.setItemId(itemId);
|
context.setItemId(itemId);
|
||||||
|
context.setItemOrder(itemOrder);
|
||||||
|
context.setItemOccurrence(itemOccurrence);
|
||||||
|
|
||||||
TaskNodeExecuteMessage message = new TaskNodeExecuteMessage();
|
TaskNodeExecuteMessage message = new TaskNodeExecuteMessage();
|
||||||
message.setInstId(instId);
|
message.setInstId(instId);
|
||||||
message.setTaskId(taskId);
|
message.setTaskId(taskId);
|
||||||
message.setRobotId(robotId);
|
message.setRobotId(robotId);
|
||||||
message.setRuntimeAssignments(context.getRuntimeAssignments());
|
message.setRuntimeAssignments(FlowRuntimeAssignmentScope.forItem(
|
||||||
|
context.getRuntimeAssignments(), itemId, itemOccurrence));
|
||||||
message.setItemId(itemId);
|
message.setItemId(itemId);
|
||||||
|
message.setItemOrder(itemOrder);
|
||||||
|
message.setItemOccurrence(itemOccurrence);
|
||||||
if (runMode.equals(RunModeEnum.VI_PROJECT)) {
|
if (runMode.equals(RunModeEnum.VI_PROJECT)) {
|
||||||
message.setLoopDetail(item.getSchemeInfo());
|
message.setLoopDetail(item.getSchemeInfo());
|
||||||
}
|
}
|
||||||
@ -57,7 +67,11 @@ public class FlowTaskEngine {
|
|||||||
int pendingItemCount = totalItemCount - i - 1;
|
int pendingItemCount = totalItemCount - i - 1;
|
||||||
message.setPendingItemCount(pendingItemCount);
|
message.setPendingItemCount(pendingItemCount);
|
||||||
|
|
||||||
JSONObject jsonObject = runParams == null ? null : runParams.getJSONObject(itemId);
|
JSONObject jsonObject = runParams == null ? null
|
||||||
|
: runParams.getJSONObject(itemId + ":" + itemOccurrence);
|
||||||
|
if (jsonObject == null && runParams != null) {
|
||||||
|
jsonObject = runParams.getJSONObject(itemId);
|
||||||
|
}
|
||||||
if (!runMode.equals(RunModeEnum.TRIAL)) {
|
if (!runMode.equals(RunModeEnum.TRIAL)) {
|
||||||
context.setRunParams(jsonObject);
|
context.setRunParams(jsonObject);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,8 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -147,8 +149,16 @@ public class FlowTaskRuntimeEntry implements FlowTaskRuntimeService {
|
|||||||
String robotId = target.getRobotId();
|
String robotId = target.getRobotId();
|
||||||
assertResourcesAvailable(resources);
|
assertResourcesAvailable(resources);
|
||||||
// 查询任务详情(检测项信息)
|
// 查询任务详情(检测项信息)
|
||||||
List<TeQueryTaskDetailDTO> details = taskOrchestrationService.queryTaskDetail(taskId);
|
List<TeQueryTaskDetailDTO> details = new ArrayList<>(taskOrchestrationService.queryTaskDetail(taskId));
|
||||||
details.forEach(detail -> runtimeDefinitionValidator.validate(detail.getFlowData(), resources.assignments()));
|
details.sort(Comparator.comparing(TeQueryTaskDetailDTO::getOrderNum,
|
||||||
|
Comparator.nullsLast(Integer::compareTo)));
|
||||||
|
Map<String, Integer> validationOccurrences = new HashMap<>();
|
||||||
|
details.forEach(detail -> {
|
||||||
|
int occurrence = validationOccurrences.merge(detail.getDetectItemId(), 1, Integer::sum);
|
||||||
|
runtimeDefinitionValidator.validate(detail.getFlowData(),
|
||||||
|
FlowRuntimeAssignmentScope.forItem(
|
||||||
|
resources.assignments(), detail.getDetectItemId(), occurrence));
|
||||||
|
});
|
||||||
int count = (int) details.stream().filter(dto -> ObjUtil.equals(dto.getIsDeploy(), "1")).count();
|
int count = (int) details.stream().filter(dto -> ObjUtil.equals(dto.getIsDeploy(), "1")).count();
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
throw new GlobalException("当前任务存在未发布的检测项!");
|
throw new GlobalException("当前任务存在未发布的检测项!");
|
||||||
|
|||||||
@ -51,6 +51,12 @@ public class FlowExecutionEvent {
|
|||||||
*/
|
*/
|
||||||
private String itemId;
|
private String itemId;
|
||||||
|
|
||||||
|
/** 当前检测项在本次任务中的实际执行位置,从 1 开始。 */
|
||||||
|
private Integer itemOrder;
|
||||||
|
|
||||||
|
/** 同一检测项在本次任务中第几次出现,从 1 开始。 */
|
||||||
|
private Integer itemOccurrence;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前节点ID,节点级事件时有值。
|
* 当前节点ID,节点级事件时有值。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -57,6 +57,8 @@ public class FlowAfterInterceptor extends AbstractFlowMsgPreInterceptor{
|
|||||||
.taskId(message.getTaskId())
|
.taskId(message.getTaskId())
|
||||||
.moduleCode(moduleCode)
|
.moduleCode(moduleCode)
|
||||||
.itemId(message.getItemId())
|
.itemId(message.getItemId())
|
||||||
|
.itemOrder(message.getItemOrder())
|
||||||
|
.itemOccurrence(message.getItemOccurrence())
|
||||||
.nodeId(message.getNodeId())
|
.nodeId(message.getNodeId())
|
||||||
.nodeCount(message.getGraph().allNodeIds().size())
|
.nodeCount(message.getGraph().allNodeIds().size())
|
||||||
.nodeType(message.getNodeType())
|
.nodeType(message.getNodeType())
|
||||||
|
|||||||
@ -40,6 +40,8 @@ public class FlowBeforeInterceptor extends AbstractFlowMsgPreInterceptor{
|
|||||||
.taskId(message.getTaskId())
|
.taskId(message.getTaskId())
|
||||||
.moduleCode(moduleCode)
|
.moduleCode(moduleCode)
|
||||||
.itemId(message.getItemId())
|
.itemId(message.getItemId())
|
||||||
|
.itemOrder(message.getItemOrder())
|
||||||
|
.itemOccurrence(message.getItemOccurrence())
|
||||||
.nodeId(message.getNodeId())
|
.nodeId(message.getNodeId())
|
||||||
.nodeCount(message.getGraph().allNodeIds().size())
|
.nodeCount(message.getGraph().allNodeIds().size())
|
||||||
.nodeName(message.getNodeName())
|
.nodeName(message.getNodeName())
|
||||||
|
|||||||
@ -30,6 +30,12 @@ public class TaskNodeExecuteMessage {
|
|||||||
*/
|
*/
|
||||||
private String itemId;
|
private String itemId;
|
||||||
|
|
||||||
|
/** 当前检测项在本次任务中的实际执行位置,从 1 开始。 */
|
||||||
|
private int itemOrder;
|
||||||
|
|
||||||
|
/** 同一检测项在本次任务中第几次出现,从 1 开始。 */
|
||||||
|
private int itemOccurrence;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 节点ID
|
* 节点ID
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -0,0 +1,40 @@
|
|||||||
|
package com.cmvr.test.flow.runtime.operator;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Resolves media parameters from both legacy scalar and current array outputs. */
|
||||||
|
public final class FlowMediaParamResolver {
|
||||||
|
|
||||||
|
private FlowMediaParamResolver() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String lastUrl(JSONObject params, String name) {
|
||||||
|
List<String> urls = urls(params, name);
|
||||||
|
return urls.isEmpty() ? null : urls.get(urls.size() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> urls(JSONObject params, String name) {
|
||||||
|
Object value = params == null ? null : params.get(name);
|
||||||
|
if (value instanceof List<?> values) {
|
||||||
|
List<String> urls = new ArrayList<>();
|
||||||
|
for (Object item : values) {
|
||||||
|
String url = normalizeUrl(item);
|
||||||
|
if (url != null && !"null".equalsIgnoreCase(url)) {
|
||||||
|
urls.add(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
String url = normalizeUrl(value);
|
||||||
|
return url == null ? Collections.emptyList() : List.of(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeUrl(Object value) {
|
||||||
|
return value == null ? null : StringUtils.trimToNull(String.valueOf(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
package com.cmvr.test.flow.runtime.operator.edge;
|
||||||
|
|
||||||
|
import cmvr.api.SystemCommand;
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import com.alibaba.fastjson2.JSONArray;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import com.cmvr.common.exception.GlobalException;
|
||||||
|
import com.cmvr.edge.client.model.system.EdgeActionQueueVO;
|
||||||
|
import com.cmvr.edge.client.service.EdgeSystemService;
|
||||||
|
import com.cmvr.test.enums.ActionEnum;
|
||||||
|
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
|
||||||
|
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class EdgeActionQueueOperateService implements EdgeOperateService {
|
||||||
|
|
||||||
|
private final EdgeSystemService edgeSystemService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supports(ActionEnum action) {
|
||||||
|
return ActionEnum.SYSTEM_EXECUTE_ACTION_QUEUE.equals(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TaskNodeExecuteResult execute(TaskNodeExecuteMessage message) {
|
||||||
|
JSONObject input = message.getInputParams() == null ? new JSONObject() : message.getInputParams();
|
||||||
|
String robotId = StringUtils.defaultIfBlank(message.getRobotId(), input.getString("robotId"));
|
||||||
|
if (StringUtils.isBlank(robotId)) throw new GlobalException("Robot ID cannot be empty");
|
||||||
|
JSONArray steps = toSteps(input.get("steps"));
|
||||||
|
if (steps.isEmpty()) throw new GlobalException("Action queue must contain at least one step");
|
||||||
|
|
||||||
|
EdgeActionQueueVO request = new EdgeActionQueueVO();
|
||||||
|
request.setRobotId(robotId);
|
||||||
|
request.setActionId(StringUtils.trimToNull(input.getString("actionId")));
|
||||||
|
request.setTotalTimeoutMs(input.getInteger("totalTimeoutMs"));
|
||||||
|
List<EdgeActionQueueVO.Step> requestSteps = new ArrayList<>();
|
||||||
|
for (int i = 0; i < steps.size(); i++) {
|
||||||
|
JSONObject source = steps.getJSONObject(i);
|
||||||
|
EdgeActionQueueVO.Step step = new EdgeActionQueueVO.Step();
|
||||||
|
step.setStepId(StringUtils.defaultIfBlank(source.getString("stepId"), "step_" + (i + 1)));
|
||||||
|
step.setType(source.getString("type"));
|
||||||
|
step.setTimeoutMs(source.getInteger("timeoutMs"));
|
||||||
|
step.setParams(source.getJSONObject("params"));
|
||||||
|
String deviceId = source.getString("deviceId");
|
||||||
|
String bindingKey = source.getString("deviceBindingKey");
|
||||||
|
if (StringUtils.isNotBlank(bindingKey) && message.getResolvedDeviceBindings() != null) {
|
||||||
|
deviceId = message.getResolvedDeviceBindings().getString(bindingKey);
|
||||||
|
}
|
||||||
|
step.setDeviceId(deviceId);
|
||||||
|
requestSteps.add(step);
|
||||||
|
}
|
||||||
|
request.setSteps(requestSteps);
|
||||||
|
|
||||||
|
SystemCommand.ActionQueueCommand.Feedback feedback = edgeSystemService.executeActionQueue(request);
|
||||||
|
if (feedback == null || !feedback.hasHeader()) {
|
||||||
|
throw new GlobalException("Action queue response is missing status information");
|
||||||
|
}
|
||||||
|
if (!feedback.getHeader().getSuccess()
|
||||||
|
|| feedback.getResult() != SystemCommand.ActionResultCode.ACTION_RESULT_CODE_COMPLETED) {
|
||||||
|
String failedAt = feedback.hasFailedStepIndex()
|
||||||
|
? ", failed step index: " + feedback.getFailedStepIndex() : "";
|
||||||
|
throw new GlobalException(StringUtils.defaultIfBlank(feedback.getHeader().getErrorMessage(),
|
||||||
|
"Action queue execution failed") + failedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject output = new JSONObject();
|
||||||
|
output.put("success", true);
|
||||||
|
output.put("actionId", feedback.getActionId());
|
||||||
|
output.put("completedSteps", feedback.getCompletedSteps());
|
||||||
|
output.put("result", feedback.getResult().name());
|
||||||
|
output.put("serviceInstanceId", feedback.getServiceInstanceId());
|
||||||
|
output.put("deduplicationStatus", feedback.getDeduplicationStatus().name());
|
||||||
|
return TaskNodeExecuteResult.success(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JSONArray toSteps(Object raw) {
|
||||||
|
if (raw instanceof JSONArray) return (JSONArray) raw;
|
||||||
|
if (raw instanceof List<?>) return new JSONArray((List<?>) raw);
|
||||||
|
if (raw instanceof String && StringUtils.isNotBlank((String) raw)) {
|
||||||
|
try {
|
||||||
|
return JSON.parseArray((String) raw);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw new GlobalException("Action queue steps must be a JSON array");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new JSONArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import com.cmvr.edge.client.model.EdgeCommonVO;
|
|||||||
import com.cmvr.edge.client.service.EdgeCameraService;
|
import com.cmvr.edge.client.service.EdgeCameraService;
|
||||||
import com.cmvr.system.service.ISysFileService;
|
import com.cmvr.system.service.ISysFileService;
|
||||||
import com.cmvr.test.enums.ActionEnum;
|
import com.cmvr.test.enums.ActionEnum;
|
||||||
|
import com.cmvr.test.flow.builder.FlowEdge;
|
||||||
import com.cmvr.test.flow.builder.FlowNodeWrapper;
|
import com.cmvr.test.flow.builder.FlowNodeWrapper;
|
||||||
import com.cmvr.test.flow.context.TaskInstHolder;
|
import com.cmvr.test.flow.context.TaskInstHolder;
|
||||||
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
|
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
|
||||||
@ -17,7 +18,13 @@ import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.io.File;
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Queue;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@ -32,15 +39,6 @@ public class EdgeCameraOperateService implements EdgeOperateService {
|
|||||||
return action.name().startsWith("CAMERA_");
|
return action.name().startsWith("CAMERA_");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final String[] VIDEO_URLS = {
|
|
||||||
"http://192.168.0.100:9000/cmvr-iot/VIDEO/20260205/1770257486644.mp4", // 单次 东北话中的那疙瘩什么意思
|
|
||||||
"http://192.168.0.100:9000/cmvr-iot/VIDEO/20260205/1770257510949.mp4",// 单次 打开视频
|
|
||||||
"http://192.168.0.100:9000/cmvr-iot/VIDEO/20260205/1770257536282.mp4", // 连续 导航到解放碑
|
|
||||||
"http://192.168.0.100:9000/cmvr-iot/VIDEO/20260205/1770257552050.mp4" // 连续 青花瓷
|
|
||||||
};
|
|
||||||
|
|
||||||
private AtomicInteger currentIndex = new AtomicInteger(0);
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TaskNodeExecuteResult execute(TaskNodeExecuteMessage message) {
|
public TaskNodeExecuteResult execute(TaskNodeExecuteMessage message) {
|
||||||
// try {
|
// try {
|
||||||
@ -48,8 +46,6 @@ public class EdgeCameraOperateService implements EdgeOperateService {
|
|||||||
// } catch (InterruptedException e) {
|
// } catch (InterruptedException e) {
|
||||||
// throw new RuntimeException(e);
|
// throw new RuntimeException(e);
|
||||||
// }
|
// }
|
||||||
String instId = message.getInstId();
|
|
||||||
String nodeId = message.getNodeId();
|
|
||||||
ActionEnum action = message.getAction();
|
ActionEnum action = message.getAction();
|
||||||
JSONObject inputParams = message.getInputParams();
|
JSONObject inputParams = message.getInputParams();
|
||||||
String deviceId = inputParams.getString("deviceId");
|
String deviceId = inputParams.getString("deviceId");
|
||||||
@ -64,30 +60,18 @@ public class EdgeCameraOperateService implements EdgeOperateService {
|
|||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case CAMERA_START: {
|
case CAMERA_START: {
|
||||||
// String result = edgeCameraService.start(robotId, deviceId);
|
String result = edgeCameraService.start(edgeCommonVO);
|
||||||
String result = StrUtil.format("id为 [{}] 的相机开启了", deviceId);
|
|
||||||
output.put("result", result);
|
output.put("result", result);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case CAMERA_STOP: {
|
case CAMERA_STOP: {
|
||||||
String result = StrUtil.format("id为 [{}] 的相机关闭了", deviceId);
|
String result = edgeCameraService.stop(edgeCommonVO);
|
||||||
// String result = edgeCameraService.stop(robotId, deviceId);
|
|
||||||
output.put("result", result);
|
output.put("result", result);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case CAMERA_GETRGBIMAGE: {
|
case CAMERA_GETRGBIMAGE: {
|
||||||
// String imageUrl;
|
|
||||||
// String imageUrl = StrUtil.format("id为 [{}] 的相机拍照了", deviceId);
|
|
||||||
// if (message.getLoopNum() <= 1) {
|
|
||||||
// // 车辆控制调节
|
|
||||||
// imageUrl = "http://192.168.1.100:9000/cmvr-iot/FILE/20250821/1755761816340.jpg";
|
|
||||||
// } else {
|
|
||||||
// // 驾驶偏好
|
|
||||||
// imageUrl = "http://192.168.1.100:9000/cmvr-iot/FILE/20250822/1755849654731.jpg";
|
|
||||||
// }
|
|
||||||
// String imageUrl = "http://192.168.1.100:9000/cmvr-iot/IMAGE/20250901/1756718594725.jpg";
|
|
||||||
String imageUrl = edgeCameraService.getRGBImage(edgeCommonVO);
|
String imageUrl = edgeCameraService.getRGBImage(edgeCommonVO);
|
||||||
|
|
||||||
JSONArray imageUrls = new JSONArray();
|
JSONArray imageUrls = new JSONArray();
|
||||||
@ -99,51 +83,43 @@ public class EdgeCameraOperateService implements EdgeOperateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
imageUrls.add(imageUrl);
|
imageUrls.add(imageUrl);
|
||||||
output.put("imageUrl", imageUrl);
|
output.put("imageUrl", imageUrls);
|
||||||
output.put("type", FileType.IMAGE.code());
|
output.put("type", FileType.IMAGE.code());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case CAMERA_RECORDING_START: {
|
case CAMERA_RECORDING_START: {
|
||||||
// 生成视频路径
|
String recordingId = edgeCameraService.startWorkflowRecording(edgeCommonVO);
|
||||||
String videoUrl = edgeCameraService.startRecording(edgeCommonVO);
|
|
||||||
// String videoUrl = StrUtil.format("{}/{}_{}.mp4", "/home/share/assets/video", deviceId, System.currentTimeMillis());
|
|
||||||
// String videoUrl = "/home/xtkuang/Projects/models/assets/video/IMG_0524_silent.mp4";
|
|
||||||
// 创建一个新的 JSONObject 来保存视频路径和其他信息
|
|
||||||
JSONObject videoInfo = new JSONObject();
|
JSONObject videoInfo = new JSONObject();
|
||||||
videoInfo.put("videoUrl", videoUrl);
|
videoInfo.put("recordingId", recordingId);
|
||||||
videoInfo.put("isPlay", false); // 初始状态为不可播放
|
videoInfo.put("robotId", robotId);
|
||||||
videoInfo.put("type", FileType.VIDEO.code()); // 文件类型为视频
|
videoInfo.put("deviceId", deviceId);
|
||||||
|
videoInfo.put("isPlay", false);
|
||||||
// 将视频路径数组保存到 output 中
|
videoInfo.put("type", FileType.VIDEO.code());
|
||||||
output.put("videoInfo", videoInfo);
|
output.put("videoInfo", videoInfo);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case CAMERA_RECORDING_STOP: {
|
case CAMERA_RECORDING_STOP: {
|
||||||
// edgeCameraService.stopRecording(edgeCommonVO);
|
JSONObject preStart = findRecordingStartOutput(message, robotId, deviceId);
|
||||||
// todo 结束录像获取视频路径输出
|
|
||||||
// 获取视频录制开始节点的id
|
|
||||||
FlowNodeWrapper startRecordNode = message.getGraph()
|
|
||||||
.getNodeMap()
|
|
||||||
.values()
|
|
||||||
.stream()
|
|
||||||
.filter(node -> ActionEnum.CAMERA_RECORDING_START.equals(node.getAction()))
|
|
||||||
.findFirst()
|
|
||||||
.orElseThrow(() -> new IllegalStateException("未找到视频录制开始节点"));
|
|
||||||
|
|
||||||
// 从上下文获取上游节点存储的参数 获取视频地址
|
|
||||||
JSONObject preStart = taskInstHolder.getNodeOutParams(instId, startRecordNode.getNodeId(), message.getIterations());
|
|
||||||
|
|
||||||
JSONObject videoInfo = preStart.getJSONObject("videoInfo");
|
JSONObject videoInfo = preStart.getJSONObject("videoInfo");
|
||||||
videoInfo.put("isPlay", true);
|
String recordingId = videoInfo.getString("recordingId");
|
||||||
// String videoUrl = videoInfo.getString("videoUrl");
|
if (StrUtil.isBlank(recordingId)) {
|
||||||
// String url = sysFileService.uploadFile(new File(videoUrl), FileType.VIDEO.code());
|
throw new GlobalException("开始录像节点缺少录像会话信息,请重新执行流程");
|
||||||
// videoInfo.put("videoUrl", url);
|
}
|
||||||
String videoUrl = VIDEO_URLS[currentIndex.getAndUpdate(i -> (i + 1) % VIDEO_URLS.length)];
|
|
||||||
videoInfo.put("videoUrl", videoUrl);
|
File videoFile = edgeCameraService.stopWorkflowRecording(edgeCommonVO, recordingId);
|
||||||
// 将视频路径数组保存到 output 中
|
try {
|
||||||
output.put("videoUrl", videoInfo);
|
String videoUrl = sysFileService.uploadFile(videoFile, FileType.VIDEO.code());
|
||||||
|
JSONArray videoUrls = new JSONArray();
|
||||||
|
videoUrls.add(videoUrl);
|
||||||
|
output.put("videoUrl", videoUrls);
|
||||||
|
output.put("type", FileType.VIDEO.code());
|
||||||
|
} finally {
|
||||||
|
if (videoFile.exists() && !videoFile.delete()) {
|
||||||
|
videoFile.deleteOnExit();
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@ -152,4 +128,44 @@ public class EdgeCameraOperateService implements EdgeOperateService {
|
|||||||
|
|
||||||
return TaskNodeExecuteResult.success(output);
|
return TaskNodeExecuteResult.success(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private JSONObject findRecordingStartOutput(TaskNodeExecuteMessage message, String robotId, String deviceId) {
|
||||||
|
Queue<String> pending = new ArrayDeque<>();
|
||||||
|
Set<String> visited = new HashSet<>();
|
||||||
|
pending.add(message.getNodeId());
|
||||||
|
|
||||||
|
while (!pending.isEmpty()) {
|
||||||
|
String nodeId = pending.remove();
|
||||||
|
if (!visited.add(nodeId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<FlowEdge> predecessors = message.getGraph().getPredecessors()
|
||||||
|
.getOrDefault(nodeId, Collections.emptyList());
|
||||||
|
for (FlowEdge edge : predecessors) {
|
||||||
|
FlowNodeWrapper predecessor = message.getGraph().getNode(edge.getFrom());
|
||||||
|
if (predecessor == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ActionEnum.CAMERA_RECORDING_START.equals(predecessor.getAction())) {
|
||||||
|
JSONObject output = taskInstHolder.getNodeOutParams(
|
||||||
|
message.getInstId(), predecessor.getNodeId(), message.getIterations());
|
||||||
|
if (isMatchingRecording(output, robotId, deviceId)) {
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending.add(predecessor.getNodeId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new GlobalException("未找到与当前机器人和摄像头匹配的上游开始录像节点");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isMatchingRecording(JSONObject output, String robotId, String deviceId) {
|
||||||
|
if (output == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
JSONObject videoInfo = output.getJSONObject("videoInfo");
|
||||||
|
return videoInfo != null
|
||||||
|
&& StrUtil.equals(robotId, videoInfo.getString("robotId"))
|
||||||
|
&& StrUtil.equals(deviceId, videoInfo.getString("deviceId"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
package com.cmvr.test.flow.runtime.operator.edge;
|
package com.cmvr.test.flow.runtime.operator.edge;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.alibaba.fastjson2.JSONArray;
|
||||||
import com.alibaba.fastjson2.JSONObject;
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import com.cmvr.common.enums.FileType;
|
import com.cmvr.common.enums.FileType;
|
||||||
import com.cmvr.common.exception.GlobalException;
|
import com.cmvr.common.exception.GlobalException;
|
||||||
@ -7,6 +9,7 @@ import com.cmvr.edge.client.model.EdgeCommonVO;
|
|||||||
import com.cmvr.edge.client.service.EdgeMicrophoneService;
|
import com.cmvr.edge.client.service.EdgeMicrophoneService;
|
||||||
import com.cmvr.system.service.ISysFileService;
|
import com.cmvr.system.service.ISysFileService;
|
||||||
import com.cmvr.test.enums.ActionEnum;
|
import com.cmvr.test.enums.ActionEnum;
|
||||||
|
import com.cmvr.test.flow.builder.FlowEdge;
|
||||||
import com.cmvr.test.flow.builder.FlowNodeWrapper;
|
import com.cmvr.test.flow.builder.FlowNodeWrapper;
|
||||||
import com.cmvr.test.flow.context.TaskInstHolder;
|
import com.cmvr.test.flow.context.TaskInstHolder;
|
||||||
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
|
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
|
||||||
@ -14,7 +17,13 @@ import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.io.File;
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Queue;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@ -29,19 +38,8 @@ public class EdgeMicrophoneOperateService implements EdgeOperateService {
|
|||||||
return action.name().startsWith("MICROPHONE_");
|
return action.name().startsWith("MICROPHONE_");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final String[] AUDIO_URLS = {
|
|
||||||
"http://192.168.0.100:9000/cmvr-iot/AUDIO/20260205/1770257444584.wav", // 单次 东北话中的那疙瘩什么意思
|
|
||||||
"http://192.168.0.100:9000/cmvr-iot/AUDIO/20260205/1770255964713.wav",// 单次 打开视频
|
|
||||||
"http://192.168.0.100:9000/cmvr-iot/AUDIO/20260205/1770257150877.wav", // 连续 导航到解放碑
|
|
||||||
"http://192.168.0.100:9000/cmvr-iot/AUDIO/20260205/1770257188864.wav" // 连续 青花瓷
|
|
||||||
};
|
|
||||||
|
|
||||||
private AtomicInteger currentIndex = new AtomicInteger(0);
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TaskNodeExecuteResult execute(TaskNodeExecuteMessage message) {
|
public TaskNodeExecuteResult execute(TaskNodeExecuteMessage message) {
|
||||||
String instId = message.getInstId();
|
|
||||||
String nodeId = message.getNodeId();
|
|
||||||
ActionEnum action = message.getAction();
|
ActionEnum action = message.getAction();
|
||||||
JSONObject inputParams = message.getInputParams();
|
JSONObject inputParams = message.getInputParams();
|
||||||
String deviceId = inputParams.getString("deviceId");
|
String deviceId = inputParams.getString("deviceId");
|
||||||
@ -53,45 +51,40 @@ public class EdgeMicrophoneOperateService implements EdgeOperateService {
|
|||||||
edgeCommonVO.setRobotId(robotId);
|
edgeCommonVO.setRobotId(robotId);
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case MICROPHONE_START: {
|
case MICROPHONE_START: {
|
||||||
// String audioPath = edgeMicrophoneService.startRecord(edgeCommonVO);
|
String recordingId = edgeMicrophoneService.startWorkflowRecording(edgeCommonVO);
|
||||||
|
|
||||||
// String audioPath = StrUtil.format("{}/{}_{}.wav", "/home/share/assets/audio", deviceId, System.currentTimeMillis());
|
|
||||||
String audioPath = "/home/xtkuang/Projects/models/assets/video/IMG_0524_audio.wav";
|
|
||||||
output.put("audioUrl", audioPath);
|
|
||||||
|
|
||||||
// 创建一个新的 JSONObject 来保存音频路径和其他信息
|
|
||||||
JSONObject audioInfo = new JSONObject();
|
JSONObject audioInfo = new JSONObject();
|
||||||
audioInfo.put("audioUrl", audioPath);
|
audioInfo.put("recordingId", recordingId);
|
||||||
audioInfo.put("isPlay", false); // 初始状态为不可播放
|
audioInfo.put("robotId", robotId);
|
||||||
|
audioInfo.put("deviceId", deviceId);
|
||||||
|
audioInfo.put("isPlay", false);
|
||||||
audioInfo.put("type", FileType.AUDIO.code());
|
audioInfo.put("type", FileType.AUDIO.code());
|
||||||
|
|
||||||
output.put("audioInfo", audioInfo);
|
output.put("audioInfo", audioInfo);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case MICROPHONE_STOP: {
|
case MICROPHONE_STOP: {
|
||||||
// edgeMicrophoneService.stopRecord(edgeCommonVO);
|
JSONObject preStart = findRecordingStartOutput(message, robotId, deviceId);
|
||||||
// 获取视频录制开始节点的id
|
|
||||||
FlowNodeWrapper startRecordNode = message.getGraph()
|
|
||||||
.getNodeMap()
|
|
||||||
.values()
|
|
||||||
.stream()
|
|
||||||
.filter(node -> ActionEnum.MICROPHONE_START.equals(node.getAction()))
|
|
||||||
.findFirst()
|
|
||||||
.orElseThrow(() -> new IllegalStateException("未找到音频录制开始节点"));
|
|
||||||
|
|
||||||
// 从上下文获取上游节点存储的参数 获取音频地址
|
|
||||||
JSONObject preStart = taskInstHolder.getNodeOutParams(instId, startRecordNode.getNodeId(), message.getIterations());
|
|
||||||
|
|
||||||
JSONObject audioInfo = preStart.getJSONObject("audioInfo");
|
JSONObject audioInfo = preStart.getJSONObject("audioInfo");
|
||||||
audioInfo.put("isPlay", true);
|
String recordingId = audioInfo.getString("recordingId");
|
||||||
// String audioUrl = audioInfo.getString("audioUrl");
|
if (StrUtil.isBlank(recordingId)) {
|
||||||
// String url = sysFileService.uploadFile(new File(audioUrl), FileType.AUDIO.code());
|
throw new GlobalException("开始录音节点缺少录音会话信息,请重新执行流程");
|
||||||
// audioInfo.put("audioUrl", url);
|
}
|
||||||
String audioUrl = AUDIO_URLS[currentIndex.getAndUpdate(i -> (i + 1) % AUDIO_URLS.length)];
|
|
||||||
audioInfo.put("audioUrl", audioUrl);
|
File audioFile = edgeMicrophoneService.stopWorkflowRecording(edgeCommonVO, recordingId);
|
||||||
// 将视频路径数组保存到 output 中
|
try {
|
||||||
output.put("audioUrl", audioInfo);
|
String audioUrl = sysFileService.uploadFile(audioFile, FileType.AUDIO.code());
|
||||||
|
if (StrUtil.isBlank(audioUrl)) {
|
||||||
|
throw new GlobalException("录音文件上传未返回访问地址");
|
||||||
|
}
|
||||||
|
JSONArray audioUrls = new JSONArray();
|
||||||
|
audioUrls.add(audioUrl);
|
||||||
|
output.put("audioUrl", audioUrls);
|
||||||
|
output.put("type", FileType.AUDIO.code());
|
||||||
|
} finally {
|
||||||
|
if (audioFile.exists() && !audioFile.delete()) {
|
||||||
|
audioFile.deleteOnExit();
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,4 +94,44 @@ public class EdgeMicrophoneOperateService implements EdgeOperateService {
|
|||||||
|
|
||||||
return TaskNodeExecuteResult.success(output);
|
return TaskNodeExecuteResult.success(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private JSONObject findRecordingStartOutput(TaskNodeExecuteMessage message, String robotId, String deviceId) {
|
||||||
|
Queue<String> pending = new ArrayDeque<>();
|
||||||
|
Set<String> visited = new HashSet<>();
|
||||||
|
pending.add(message.getNodeId());
|
||||||
|
|
||||||
|
while (!pending.isEmpty()) {
|
||||||
|
String nodeId = pending.remove();
|
||||||
|
if (!visited.add(nodeId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<FlowEdge> predecessors = message.getGraph().getPredecessors()
|
||||||
|
.getOrDefault(nodeId, Collections.emptyList());
|
||||||
|
for (FlowEdge edge : predecessors) {
|
||||||
|
FlowNodeWrapper predecessor = message.getGraph().getNode(edge.getFrom());
|
||||||
|
if (predecessor == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ActionEnum.MICROPHONE_START.equals(predecessor.getAction())) {
|
||||||
|
JSONObject startOutput = taskInstHolder.getNodeOutParams(
|
||||||
|
message.getInstId(), predecessor.getNodeId(), message.getIterations());
|
||||||
|
if (isMatchingRecording(startOutput, robotId, deviceId)) {
|
||||||
|
return startOutput;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending.add(predecessor.getNodeId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new GlobalException("未找到与当前机器人和麦克风匹配的上游开始录音节点");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isMatchingRecording(JSONObject output, String robotId, String deviceId) {
|
||||||
|
if (output == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
JSONObject audioInfo = output.getJSONObject("audioInfo");
|
||||||
|
return audioInfo != null
|
||||||
|
&& StrUtil.equals(robotId, audioInfo.getString("robotId"))
|
||||||
|
&& StrUtil.equals(deviceId, audioInfo.getString("deviceId"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import com.cmvr.llm.config.AgentConfig;
|
|||||||
import com.cmvr.test.enums.ActionEnum;
|
import com.cmvr.test.enums.ActionEnum;
|
||||||
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
|
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
|
||||||
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
|
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
|
||||||
|
import com.cmvr.test.flow.runtime.operator.FlowMediaParamResolver;
|
||||||
import com.cmvr.test.flow.runtime.operator.edge.EdgeOperateService;
|
import com.cmvr.test.flow.runtime.operator.edge.EdgeOperateService;
|
||||||
import com.cmvr.test.service.ex.ExTiVehicleFunctionService;
|
import com.cmvr.test.service.ex.ExTiVehicleFunctionService;
|
||||||
import com.cmvr.test.service.ShangdaoFileService;
|
import com.cmvr.test.service.ShangdaoFileService;
|
||||||
@ -74,7 +75,7 @@ public class TiTouchOperateService implements EdgeOperateService {
|
|||||||
}
|
}
|
||||||
case TI_TOUCH_COORDINATES: {
|
case TI_TOUCH_COORDINATES: {
|
||||||
// 页面图片(相机)
|
// 页面图片(相机)
|
||||||
String imageUrl = inputParams.getString("imageUrl");
|
String imageUrl = FlowMediaParamResolver.lastUrl(inputParams, "imageUrl");
|
||||||
String pageImageUrl = shangdaoFileService.uploadFromMinio(imageUrl);
|
String pageImageUrl = shangdaoFileService.uploadFromMinio(imageUrl);
|
||||||
|
|
||||||
// icon图片
|
// icon图片
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import com.cmvr.llm.service.LLMAiAgentPlatformService;
|
|||||||
import com.cmvr.test.enums.ActionEnum;
|
import com.cmvr.test.enums.ActionEnum;
|
||||||
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
|
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
|
||||||
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
|
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
|
||||||
|
import com.cmvr.test.flow.runtime.operator.FlowMediaParamResolver;
|
||||||
import com.cmvr.test.model.vo.inspection.InspectionAlarmRuleVO;
|
import com.cmvr.test.model.vo.inspection.InspectionAlarmRuleVO;
|
||||||
import com.cmvr.test.model.vo.inspection.InspectionMeterRecognizeConfigVO;
|
import com.cmvr.test.model.vo.inspection.InspectionMeterRecognizeConfigVO;
|
||||||
import com.cmvr.test.service.ShangdaoFileService;
|
import com.cmvr.test.service.ShangdaoFileService;
|
||||||
@ -56,8 +57,10 @@ public class InspectionMeterRecognizeOperateService implements LLMOperateService
|
|||||||
public TaskNodeExecuteResult execute(TaskNodeExecuteMessage message)
|
public TaskNodeExecuteResult execute(TaskNodeExecuteMessage message)
|
||||||
{
|
{
|
||||||
JSONObject input = message.getInputParams() == null ? new JSONObject() : message.getInputParams();
|
JSONObject input = message.getInputParams() == null ? new JSONObject() : message.getInputParams();
|
||||||
InspectionMeterRecognizeConfigVO config = input.to(InspectionMeterRecognizeConfigVO.class);
|
String imageUrl = FlowMediaParamResolver.lastUrl(input, "imageUrl");
|
||||||
String imageUrl = StringUtils.trimToNull(config.getImageUrl());
|
JSONObject normalizedInput = new JSONObject(input);
|
||||||
|
normalizedInput.put("imageUrl", imageUrl);
|
||||||
|
InspectionMeterRecognizeConfigVO config = normalizedInput.to(InspectionMeterRecognizeConfigVO.class);
|
||||||
String resultName = StringUtils.defaultIfBlank(config.getResultName(), "仪表读数");
|
String resultName = StringUtils.defaultIfBlank(config.getResultName(), "仪表读数");
|
||||||
String configuredUnit = StringUtils.trimToNull(config.getUnit());
|
String configuredUnit = StringUtils.trimToNull(config.getUnit());
|
||||||
JSONArray ruleArray = input.getJSONArray("alarmRules");
|
JSONArray ruleArray = input.getJSONArray("alarmRules");
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import com.cmvr.llm.config.APIProperties;
|
|||||||
import com.cmvr.test.enums.ActionEnum;
|
import com.cmvr.test.enums.ActionEnum;
|
||||||
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
|
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
|
||||||
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
|
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
|
||||||
|
import com.cmvr.test.flow.runtime.operator.FlowMediaParamResolver;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@ -32,7 +33,7 @@ public class LLMCurrentPageOperateService implements LLMOperateService {
|
|||||||
public TaskNodeExecuteResult execute(TaskNodeExecuteMessage message) {
|
public TaskNodeExecuteResult execute(TaskNodeExecuteMessage message) {
|
||||||
JSONObject inputParams = message.getInputParams();
|
JSONObject inputParams = message.getInputParams();
|
||||||
|
|
||||||
String fileUrl = inputParams.getString("url"); // 原图片地址
|
String fileUrl = FlowMediaParamResolver.lastUrl(inputParams, "url");
|
||||||
String targetUrl = apiProperties.getCurrentPage(); // 目标接口地址
|
String targetUrl = apiProperties.getCurrentPage(); // 目标接口地址
|
||||||
|
|
||||||
File tempFile = null;
|
File tempFile = null;
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import com.cmvr.test.enums.ActionEnum;
|
|||||||
import com.cmvr.test.flow.context.TaskInstHolder;
|
import com.cmvr.test.flow.context.TaskInstHolder;
|
||||||
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
|
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
|
||||||
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
|
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
|
||||||
|
import com.cmvr.test.flow.runtime.operator.FlowMediaParamResolver;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@ -59,8 +60,7 @@ public class LLMTouchOperateService implements LLMOperateService {
|
|||||||
// JSONArray imageUrl = pre.getJSONArray("imageUrl");
|
// JSONArray imageUrl = pre.getJSONArray("imageUrl");
|
||||||
// String url = imageUrl.getString(imageUrl.size() - 1);
|
// String url = imageUrl.getString(imageUrl.size() - 1);
|
||||||
|
|
||||||
// 直接从 inputParams 获取上游相机的输出(单个 imageUrl)
|
String url = FlowMediaParamResolver.lastUrl(inputParams, "imageUrl");
|
||||||
String url = inputParams.getString("imageUrl");
|
|
||||||
if (StrUtil.isEmpty(url)) {
|
if (StrUtil.isEmpty(url)) {
|
||||||
throw new IllegalStateException("未找到上游相机输出的图片地址");
|
throw new IllegalStateException("未找到上游相机输出的图片地址");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,17 @@ package com.cmvr.test.model.vo;
|
|||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class FlowiseStartRequestVO {
|
public class FlowiseStartRequestVO {
|
||||||
|
|
||||||
private String question;
|
private String question;
|
||||||
|
|
||||||
|
private String taskId;
|
||||||
|
|
||||||
|
private String robotId;
|
||||||
|
|
||||||
|
private List<ResourceRuntimeAssignmentVO> runtimeAssignments = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,15 @@ public class ResourceRuntimeAssignmentVO {
|
|||||||
@ApiModelProperty("流程内稳定的机器人角色标识")
|
@ApiModelProperty("流程内稳定的机器人角色标识")
|
||||||
private String roleKey;
|
private String roleKey;
|
||||||
|
|
||||||
|
@ApiModelProperty("任务级安排对应的流程内原始角色标识")
|
||||||
|
private String sourceRoleKey;
|
||||||
|
|
||||||
|
@ApiModelProperty("任务级安排对应的检测项ID;为空表示适用于整个任务")
|
||||||
|
private String itemId;
|
||||||
|
|
||||||
|
@ApiModelProperty("同一检测项在任务编排中的出现序号,从1开始")
|
||||||
|
private Integer itemOccurrence;
|
||||||
|
|
||||||
@ApiModelProperty("本次执行绑定的机器人成员")
|
@ApiModelProperty("本次执行绑定的机器人成员")
|
||||||
private List<RobotRuntimeMemberVO> members = new ArrayList<>();
|
private List<RobotRuntimeMemberVO> members = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,8 @@ public class ITeNodeInstServiceImpl extends ServiceImpl<TeNodeInstMapper, TeNode
|
|||||||
LambdaQueryWrapper<TeNodeInst> wrapper = Wrappers.lambdaQuery();
|
LambdaQueryWrapper<TeNodeInst> wrapper = Wrappers.lambdaQuery();
|
||||||
wrapper.eq(TeNodeInst::getInstId, flowViewVO.getInstId())
|
wrapper.eq(TeNodeInst::getInstId, flowViewVO.getInstId())
|
||||||
.eq(TeNodeInst::getItemId, flowViewVO.getItemId())
|
.eq(TeNodeInst::getItemId, flowViewVO.getItemId())
|
||||||
.eq(StrUtil.isNotEmpty(flowViewVO.getTaskId()), TeNodeInst::getTaskId, flowViewVO.getTaskId());
|
.eq(StrUtil.isNotEmpty(flowViewVO.getTaskId()), TeNodeInst::getTaskId, flowViewVO.getTaskId())
|
||||||
|
.orderByAsc(TeNodeInst::getId);
|
||||||
return this.list(wrapper);
|
return this.list(wrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -45,7 +45,8 @@ public class TeTaskOrchestrationServiceImpl extends ServiceImpl<TeTaskOrchestrat
|
|||||||
.selectAs(TeDetectionItem::getDetectName, TeQueryTaskOrchestraItemVO::getItemName)
|
.selectAs(TeDetectionItem::getDetectName, TeQueryTaskOrchestraItemVO::getItemName)
|
||||||
.selectAs(TeDetectionItem::getConfig, TeQueryTaskOrchestraItemVO::getConfig)
|
.selectAs(TeDetectionItem::getConfig, TeQueryTaskOrchestraItemVO::getConfig)
|
||||||
.eq(TeTaskOrchestration::getTaskId, taskId)
|
.eq(TeTaskOrchestration::getTaskId, taskId)
|
||||||
.orderByAsc(TeTaskOrchestration::getOrderNum);
|
.orderByAsc(TeTaskOrchestration::getOrderNum)
|
||||||
|
.orderByAsc(TeTaskOrchestration::getId);
|
||||||
|
|
||||||
return baseMapper.selectJoinList(TeQueryTaskOrchestraItemVO.class, wrapper);
|
return baseMapper.selectJoinList(TeQueryTaskOrchestraItemVO.class, wrapper);
|
||||||
}
|
}
|
||||||
@ -65,6 +66,9 @@ public class TeTaskOrchestrationServiceImpl extends ServiceImpl<TeTaskOrchestrat
|
|||||||
deleteByTaskId(taskOrchestraVO.getTaskId());
|
deleteByTaskId(taskOrchestraVO.getTaskId());
|
||||||
|
|
||||||
List<String> itemIds = taskOrchestraVO.getItemIds();
|
List<String> itemIds = taskOrchestraVO.getItemIds();
|
||||||
|
if (itemIds == null || itemIds.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
List<TeTaskOrchestration> list = new ArrayList<>();
|
List<TeTaskOrchestration> list = new ArrayList<>();
|
||||||
for (int i = 0; i < itemIds.size(); i++) {
|
for (int i = 0; i < itemIds.size(); i++) {
|
||||||
@ -112,7 +116,8 @@ public class TeTaskOrchestrationServiceImpl extends ServiceImpl<TeTaskOrchestrat
|
|||||||
.select(TeDetectionItem::getFlowData)
|
.select(TeDetectionItem::getFlowData)
|
||||||
.eq(TeTaskOrchestration::getTaskId, taskId)
|
.eq(TeTaskOrchestration::getTaskId, taskId)
|
||||||
.leftJoin(TeDetectionItem.class, TeDetectionItem::getId, TeTaskOrchestration::getItemId)
|
.leftJoin(TeDetectionItem.class, TeDetectionItem::getId, TeTaskOrchestration::getItemId)
|
||||||
.orderByAsc(TeTaskOrchestration::getOrderNum);
|
.orderByAsc(TeTaskOrchestration::getOrderNum)
|
||||||
|
.orderByAsc(TeTaskOrchestration::getId);
|
||||||
|
|
||||||
return this.baseMapper.selectJoinList(TeQueryTaskDetailDTO.class, wrapper);
|
return this.baseMapper.selectJoinList(TeQueryTaskDetailDTO.class, wrapper);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,49 @@
|
|||||||
|
package com.cmvr.test.flow.runtime.engine;
|
||||||
|
|
||||||
|
import com.cmvr.test.model.vo.ResourceRuntimeAssignmentVO;
|
||||||
|
import com.cmvr.test.model.vo.RobotRuntimeMemberVO;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
|
||||||
|
public class FlowRuntimeAssignmentScopeTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void selectsAndRestoresRoleForCurrentItemOccurrence() {
|
||||||
|
ResourceRuntimeAssignmentVO first = assignment("camera-check:1:executor", "executor", "camera-check", 1, "robot-1");
|
||||||
|
ResourceRuntimeAssignmentVO second = assignment("camera-check:2:executor", "executor", "camera-check", 2, "robot-2");
|
||||||
|
ResourceRuntimeAssignmentVO other = assignment("audio-check:1:executor", "executor", "audio-check", 1, "robot-3");
|
||||||
|
|
||||||
|
List<ResourceRuntimeAssignmentVO> selected = FlowRuntimeAssignmentScope.forItem(
|
||||||
|
List.of(first, second, other), "camera-check", 2);
|
||||||
|
|
||||||
|
assertEquals(1, selected.size());
|
||||||
|
assertEquals("executor", selected.get(0).getRoleKey());
|
||||||
|
assertEquals("robot-2", selected.get(0).getMembers().get(0).getRobotId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void keepsLegacyUnscopedAssignments() {
|
||||||
|
ResourceRuntimeAssignmentVO legacy = assignment("executor", null, null, null, "robot-1");
|
||||||
|
|
||||||
|
List<ResourceRuntimeAssignmentVO> selected = FlowRuntimeAssignmentScope.forItem(
|
||||||
|
List.of(legacy), "camera-check", 1);
|
||||||
|
|
||||||
|
assertEquals(legacy, selected.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResourceRuntimeAssignmentVO assignment(String roleKey, String sourceRoleKey,
|
||||||
|
String itemId, Integer occurrence, String robotId) {
|
||||||
|
RobotRuntimeMemberVO member = new RobotRuntimeMemberVO();
|
||||||
|
member.setRobotId(robotId);
|
||||||
|
ResourceRuntimeAssignmentVO assignment = new ResourceRuntimeAssignmentVO();
|
||||||
|
assignment.setRoleKey(roleKey);
|
||||||
|
assignment.setSourceRoleKey(sourceRoleKey);
|
||||||
|
assignment.setItemId(itemId);
|
||||||
|
assignment.setItemOccurrence(occurrence);
|
||||||
|
assignment.setMembers(List.of(member));
|
||||||
|
return assignment;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
package com.cmvr.test.flow.runtime.operator.edge;
|
||||||
|
|
||||||
|
import cmvr.api.Common;
|
||||||
|
import cmvr.api.SystemCommand;
|
||||||
|
import com.alibaba.fastjson2.JSONArray;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import com.cmvr.edge.client.model.system.EdgeActionQueueVO;
|
||||||
|
import com.cmvr.edge.client.service.EdgeSystemService;
|
||||||
|
import com.cmvr.test.enums.ActionEnum;
|
||||||
|
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
|
||||||
|
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.lang.reflect.Proxy;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
public class EdgeActionQueueOperateServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void resolvesEachStepDeviceBindingAndReturnsQueueResult() {
|
||||||
|
AtomicReference<EdgeActionQueueVO> captured = new AtomicReference<>();
|
||||||
|
EdgeSystemService edgeSystemService = proxy((method, args) -> {
|
||||||
|
if (!"executeActionQueue".equals(method.getName())) return defaultValue(method.getReturnType());
|
||||||
|
captured.set((EdgeActionQueueVO) args[0]);
|
||||||
|
return SystemCommand.ActionQueueCommand.Feedback.newBuilder()
|
||||||
|
.setHeader(Common.CommandHeader.Feedback.newBuilder().setSuccess(true))
|
||||||
|
.setActionId("action-1")
|
||||||
|
.setCompletedSteps(2)
|
||||||
|
.setResult(SystemCommand.ActionResultCode.ACTION_RESULT_CODE_COMPLETED)
|
||||||
|
.setServiceInstanceId("instance-1")
|
||||||
|
.setDeduplicationStatus(SystemCommand.ActionDeduplicationStatus.ACTION_DEDUPLICATION_STATUS_ACCEPTED_NEW)
|
||||||
|
.build();
|
||||||
|
});
|
||||||
|
EdgeActionQueueOperateService service = new EdgeActionQueueOperateService(edgeSystemService);
|
||||||
|
|
||||||
|
JSONArray steps = new JSONArray()
|
||||||
|
.fluentAdd(new JSONObject().fluentPut("stepId", "move")
|
||||||
|
.fluentPut("type", "AGV_NAVIGATE_TO_STATION")
|
||||||
|
.fluentPut("deviceBindingKey", "queue_agv")
|
||||||
|
.fluentPut("params", new JSONObject().fluentPut("stationId", "station-2")))
|
||||||
|
.fluentAdd(new JSONObject().fluentPut("stepId", "wait")
|
||||||
|
.fluentPut("type", "DELAY")
|
||||||
|
.fluentPut("params", new JSONObject().fluentPut("durationMs", 500)));
|
||||||
|
TaskNodeExecuteMessage message = new TaskNodeExecuteMessage();
|
||||||
|
message.setAction(ActionEnum.SYSTEM_EXECUTE_ACTION_QUEUE);
|
||||||
|
message.setRobotId("robot-1");
|
||||||
|
message.setResolvedDeviceBindings(new JSONObject().fluentPut("queue_agv", "agv-2"));
|
||||||
|
message.setInputParams(new JSONObject().fluentPut("totalTimeoutMs", 10000).fluentPut("steps", steps));
|
||||||
|
|
||||||
|
TaskNodeExecuteResult result = service.execute(message);
|
||||||
|
|
||||||
|
assertTrue(result.isSuccess());
|
||||||
|
assertEquals("agv-2", captured.get().getSteps().get(0).getDeviceId());
|
||||||
|
assertEquals("robot-1", captured.get().getRobotId());
|
||||||
|
assertEquals(2, result.getOutputParams().getIntValue("completedSteps"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private EdgeSystemService proxy(Invocation invocation) {
|
||||||
|
return (EdgeSystemService) Proxy.newProxyInstance(EdgeSystemService.class.getClassLoader(),
|
||||||
|
new Class<?>[]{EdgeSystemService.class}, (proxy, method, args) -> invocation.invoke(method, args));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object defaultValue(Class<?> type) {
|
||||||
|
if (!type.isPrimitive()) return null;
|
||||||
|
if (type == boolean.class) return false;
|
||||||
|
if (type == char.class) return '\0';
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface Invocation {
|
||||||
|
Object invoke(java.lang.reflect.Method method, Object[] args) throws Throwable;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,185 @@
|
|||||||
|
package com.cmvr.test.flow.runtime.operator.edge;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSONArray;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import com.cmvr.edge.client.model.EdgeCommonVO;
|
||||||
|
import com.cmvr.edge.client.service.EdgeCameraService;
|
||||||
|
import com.cmvr.system.service.ISysFileService;
|
||||||
|
import com.cmvr.test.enums.ActionEnum;
|
||||||
|
import com.cmvr.test.flow.builder.FlowEdge;
|
||||||
|
import com.cmvr.test.flow.builder.FlowGraph;
|
||||||
|
import com.cmvr.test.flow.builder.FlowNodeWrapper;
|
||||||
|
import com.cmvr.test.flow.context.TaskContext;
|
||||||
|
import com.cmvr.test.flow.context.TaskContextManager;
|
||||||
|
import com.cmvr.test.flow.context.TaskInstHolder;
|
||||||
|
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
|
||||||
|
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
|
||||||
|
import com.cmvr.test.flow.runtime.operator.FlowMediaParamResolver;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.lang.reflect.Proxy;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
public class EdgeCameraOperateServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void resolvesLegacyScalarAndArrayMediaParameters() {
|
||||||
|
JSONObject legacy = new JSONObject().fluentPut("imageUrl", " https://files.example/legacy.jpg ");
|
||||||
|
JSONObject current = new JSONObject().fluentPut("imageUrl", new JSONArray()
|
||||||
|
.fluentAdd("https://files.example/first.jpg")
|
||||||
|
.fluentAdd("https://files.example/latest.jpg"));
|
||||||
|
|
||||||
|
assertEquals("https://files.example/legacy.jpg",
|
||||||
|
FlowMediaParamResolver.lastUrl(legacy, "imageUrl"));
|
||||||
|
assertEquals("https://files.example/latest.jpg",
|
||||||
|
FlowMediaParamResolver.lastUrl(current, "imageUrl"));
|
||||||
|
assertEquals(2, FlowMediaParamResolver.urls(current, "imageUrl").size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void executesCameraStartImageAndStopAgainstResolvedTarget() {
|
||||||
|
List<String> calls = new ArrayList<>();
|
||||||
|
EdgeCameraService cameraService = proxy(EdgeCameraService.class, (method, args) -> {
|
||||||
|
if ("start".equals(method.getName())
|
||||||
|
|| "getRGBImage".equals(method.getName())
|
||||||
|
|| "stop".equals(method.getName())) {
|
||||||
|
EdgeCommonVO target = (EdgeCommonVO) args[0];
|
||||||
|
calls.add(method.getName() + ":" + target.getRobotId() + ":" + target.getDeviceId());
|
||||||
|
}
|
||||||
|
if ("getRGBImage".equals(method.getName())) {
|
||||||
|
return "https://files.example/current.jpg";
|
||||||
|
}
|
||||||
|
if ("start".equals(method.getName()) || "stop".equals(method.getName())) {
|
||||||
|
return "{\"success\":true}";
|
||||||
|
}
|
||||||
|
return defaultValue(method.getReturnType());
|
||||||
|
});
|
||||||
|
EdgeCameraOperateService service = new EdgeCameraOperateService(cameraService, null, null);
|
||||||
|
|
||||||
|
service.execute(cameraMessage(ActionEnum.CAMERA_START));
|
||||||
|
TaskNodeExecuteMessage imageMessage = cameraMessage(ActionEnum.CAMERA_GETRGBIMAGE);
|
||||||
|
imageMessage.setUpStreamOutput(new JSONObject().fluentPut(
|
||||||
|
"imageUrl", new JSONArray().fluentAdd("https://files.example/previous.jpg")));
|
||||||
|
TaskNodeExecuteResult imageResult = service.execute(imageMessage);
|
||||||
|
service.execute(cameraMessage(ActionEnum.CAMERA_STOP));
|
||||||
|
|
||||||
|
assertEquals("start:robot-1:camera-1", calls.get(0));
|
||||||
|
assertEquals("getRGBImage:robot-1:camera-1", calls.get(1));
|
||||||
|
assertEquals("stop:robot-1:camera-1", calls.get(2));
|
||||||
|
JSONArray imageUrls = imageResult.getOutputParams().getJSONArray("imageUrl");
|
||||||
|
assertEquals(2, imageUrls.size());
|
||||||
|
assertEquals("https://files.example/previous.jpg", imageUrls.getString(0));
|
||||||
|
assertEquals("https://files.example/current.jpg", imageUrls.getString(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void stopsNearestMatchingUpstreamRecordingAndReturnsVideoUrlArray() throws Exception {
|
||||||
|
File recording = File.createTempFile("camera-recording-test-", ".mp4");
|
||||||
|
Files.write(recording.toPath(), new byte[]{1, 2, 3});
|
||||||
|
AtomicReference<EdgeCommonVO> stoppedTarget = new AtomicReference<>();
|
||||||
|
AtomicReference<String> stoppedRecordingId = new AtomicReference<>();
|
||||||
|
|
||||||
|
EdgeCameraService cameraService = proxy(EdgeCameraService.class, (method, args) -> {
|
||||||
|
if ("stopWorkflowRecording".equals(method.getName())) {
|
||||||
|
stoppedTarget.set((EdgeCommonVO) args[0]);
|
||||||
|
stoppedRecordingId.set((String) args[1]);
|
||||||
|
return recording;
|
||||||
|
}
|
||||||
|
return defaultValue(method.getReturnType());
|
||||||
|
});
|
||||||
|
ISysFileService fileService = proxy(ISysFileService.class, (method, args) -> {
|
||||||
|
if ("uploadFile".equals(method.getName()) && args[0] instanceof File) {
|
||||||
|
return "https://files.example/recording.mp4";
|
||||||
|
}
|
||||||
|
return defaultValue(method.getReturnType());
|
||||||
|
});
|
||||||
|
|
||||||
|
TaskContextManager contextManager = new TaskContextManager();
|
||||||
|
TaskContext context = new TaskContext();
|
||||||
|
context.setInstId("inst-1");
|
||||||
|
contextManager.register(context);
|
||||||
|
JSONObject videoInfo = new JSONObject()
|
||||||
|
.fluentPut("recordingId", "recording-1")
|
||||||
|
.fluentPut("robotId", "robot-1")
|
||||||
|
.fluentPut("deviceId", "camera-1");
|
||||||
|
context.setNodeOutput("record-start", Collections.emptyList(),
|
||||||
|
new JSONObject().fluentPut("videoInfo", videoInfo));
|
||||||
|
|
||||||
|
TaskInstHolder holder = new TaskInstHolder(null, contextManager, null, null);
|
||||||
|
EdgeCameraOperateService service = new EdgeCameraOperateService(cameraService, holder, fileService);
|
||||||
|
TaskNodeExecuteResult result = service.execute(stopMessage(graph(), "robot-1", "camera-1"));
|
||||||
|
|
||||||
|
assertTrue(result.isSuccess());
|
||||||
|
assertEquals("robot-1", stoppedTarget.get().getRobotId());
|
||||||
|
assertEquals("camera-1", stoppedTarget.get().getDeviceId());
|
||||||
|
assertEquals("recording-1", stoppedRecordingId.get());
|
||||||
|
JSONArray videoUrls = result.getOutputParams().getJSONArray("videoUrl");
|
||||||
|
assertEquals(1, videoUrls.size());
|
||||||
|
assertEquals("https://files.example/recording.mp4", videoUrls.getString(0));
|
||||||
|
assertFalse(recording.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskNodeExecuteMessage stopMessage(FlowGraph graph, String robotId, String deviceId) {
|
||||||
|
TaskNodeExecuteMessage message = new TaskNodeExecuteMessage();
|
||||||
|
message.setInstId("inst-1");
|
||||||
|
message.setNodeId("record-stop");
|
||||||
|
message.setAction(ActionEnum.CAMERA_RECORDING_STOP);
|
||||||
|
message.setRobotId(robotId);
|
||||||
|
message.setGraph(graph);
|
||||||
|
message.setInputParams(new JSONObject().fluentPut("deviceId", deviceId));
|
||||||
|
message.setIterations(Collections.emptyList());
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskNodeExecuteMessage cameraMessage(ActionEnum action) {
|
||||||
|
TaskNodeExecuteMessage message = new TaskNodeExecuteMessage();
|
||||||
|
message.setAction(action);
|
||||||
|
message.setRobotId("robot-1");
|
||||||
|
message.setInputParams(new JSONObject().fluentPut("deviceId", "camera-1"));
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FlowGraph graph() {
|
||||||
|
FlowGraph graph = new FlowGraph();
|
||||||
|
graph.getNodeMap().put("record-start", node("record-start", ActionEnum.CAMERA_RECORDING_START));
|
||||||
|
graph.getNodeMap().put("sleep", node("sleep", ActionEnum.SLEEP));
|
||||||
|
graph.getNodeMap().put("record-stop", node("record-stop", ActionEnum.CAMERA_RECORDING_STOP));
|
||||||
|
graph.addEdge(new FlowEdge("record-start", "sleep", "a", null));
|
||||||
|
graph.addEdge(new FlowEdge("sleep", "record-stop", "b", null));
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FlowNodeWrapper node(String nodeId, ActionEnum action) {
|
||||||
|
FlowNodeWrapper node = new FlowNodeWrapper();
|
||||||
|
node.setNodeId(nodeId);
|
||||||
|
node.setAction(action);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private <T> T proxy(Class<T> type, Invocation invocation) {
|
||||||
|
return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type},
|
||||||
|
(proxy, method, args) -> invocation.invoke(method, args));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object defaultValue(Class<?> type) {
|
||||||
|
if (!type.isPrimitive()) return null;
|
||||||
|
if (type == boolean.class) return false;
|
||||||
|
if (type == char.class) return '\0';
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface Invocation {
|
||||||
|
Object invoke(java.lang.reflect.Method method, Object[] args) throws Throwable;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,153 @@
|
|||||||
|
package com.cmvr.test.flow.runtime.operator.edge;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSONArray;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import com.cmvr.edge.client.model.EdgeCommonVO;
|
||||||
|
import com.cmvr.edge.client.service.EdgeMicrophoneService;
|
||||||
|
import com.cmvr.system.service.ISysFileService;
|
||||||
|
import com.cmvr.test.enums.ActionEnum;
|
||||||
|
import com.cmvr.test.flow.builder.FlowEdge;
|
||||||
|
import com.cmvr.test.flow.builder.FlowGraph;
|
||||||
|
import com.cmvr.test.flow.builder.FlowNodeWrapper;
|
||||||
|
import com.cmvr.test.flow.context.TaskContext;
|
||||||
|
import com.cmvr.test.flow.context.TaskContextManager;
|
||||||
|
import com.cmvr.test.flow.context.TaskInstHolder;
|
||||||
|
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
|
||||||
|
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.lang.reflect.Proxy;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
public class EdgeMicrophoneOperateServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void startsRecordingAgainstResolvedRobotAndMicrophone() {
|
||||||
|
AtomicReference<EdgeCommonVO> startedTarget = new AtomicReference<>();
|
||||||
|
EdgeMicrophoneService microphoneService = proxy(EdgeMicrophoneService.class, (method, args) -> {
|
||||||
|
if ("startWorkflowRecording".equals(method.getName())) {
|
||||||
|
startedTarget.set((EdgeCommonVO) args[0]);
|
||||||
|
return "recording-1";
|
||||||
|
}
|
||||||
|
return defaultValue(method.getReturnType());
|
||||||
|
});
|
||||||
|
EdgeMicrophoneOperateService service = new EdgeMicrophoneOperateService(microphoneService, null, null);
|
||||||
|
|
||||||
|
TaskNodeExecuteResult result = service.execute(message(ActionEnum.MICROPHONE_START, null));
|
||||||
|
|
||||||
|
assertTrue(result.isSuccess());
|
||||||
|
assertEquals("robot-1", startedTarget.get().getRobotId());
|
||||||
|
assertEquals("microphone-1", startedTarget.get().getDeviceId());
|
||||||
|
assertEquals("recording-1",
|
||||||
|
result.getOutputParams().getJSONObject("audioInfo").getString("recordingId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void stopsMatchingUpstreamRecordingAndReturnsAudioUrlArray() throws Exception {
|
||||||
|
File recording = File.createTempFile("microphone-recording-test-", ".wav");
|
||||||
|
Files.write(recording.toPath(), new byte[]{'R', 'I', 'F', 'F', 1, 2, 3});
|
||||||
|
AtomicReference<EdgeCommonVO> stoppedTarget = new AtomicReference<>();
|
||||||
|
AtomicReference<String> stoppedRecordingId = new AtomicReference<>();
|
||||||
|
|
||||||
|
EdgeMicrophoneService microphoneService = proxy(EdgeMicrophoneService.class, (method, args) -> {
|
||||||
|
if ("stopWorkflowRecording".equals(method.getName())) {
|
||||||
|
stoppedTarget.set((EdgeCommonVO) args[0]);
|
||||||
|
stoppedRecordingId.set((String) args[1]);
|
||||||
|
return recording;
|
||||||
|
}
|
||||||
|
return defaultValue(method.getReturnType());
|
||||||
|
});
|
||||||
|
ISysFileService fileService = proxy(ISysFileService.class, (method, args) -> {
|
||||||
|
if ("uploadFile".equals(method.getName()) && args[0] instanceof File) {
|
||||||
|
return "https://files.example/recording.wav";
|
||||||
|
}
|
||||||
|
return defaultValue(method.getReturnType());
|
||||||
|
});
|
||||||
|
|
||||||
|
TaskContextManager contextManager = new TaskContextManager();
|
||||||
|
TaskContext context = new TaskContext();
|
||||||
|
context.setInstId("inst-1");
|
||||||
|
contextManager.register(context);
|
||||||
|
JSONObject matchingInfo = new JSONObject()
|
||||||
|
.fluentPut("recordingId", "recording-1")
|
||||||
|
.fluentPut("robotId", "robot-1")
|
||||||
|
.fluentPut("deviceId", "microphone-1");
|
||||||
|
JSONObject otherInfo = new JSONObject()
|
||||||
|
.fluentPut("recordingId", "recording-other")
|
||||||
|
.fluentPut("robotId", "robot-2")
|
||||||
|
.fluentPut("deviceId", "microphone-2");
|
||||||
|
context.setNodeOutput("matching-start", Collections.emptyList(),
|
||||||
|
new JSONObject().fluentPut("audioInfo", matchingInfo));
|
||||||
|
context.setNodeOutput("other-start", Collections.emptyList(),
|
||||||
|
new JSONObject().fluentPut("audioInfo", otherInfo));
|
||||||
|
|
||||||
|
EdgeMicrophoneOperateService service = new EdgeMicrophoneOperateService(
|
||||||
|
microphoneService, new TaskInstHolder(null, contextManager, null, null), fileService);
|
||||||
|
TaskNodeExecuteResult result = service.execute(message(ActionEnum.MICROPHONE_STOP, graph()));
|
||||||
|
|
||||||
|
assertTrue(result.isSuccess());
|
||||||
|
assertEquals("robot-1", stoppedTarget.get().getRobotId());
|
||||||
|
assertEquals("microphone-1", stoppedTarget.get().getDeviceId());
|
||||||
|
assertEquals("recording-1", stoppedRecordingId.get());
|
||||||
|
JSONArray urls = result.getOutputParams().getJSONArray("audioUrl");
|
||||||
|
assertEquals(1, urls.size());
|
||||||
|
assertEquals("https://files.example/recording.wav", urls.getString(0));
|
||||||
|
assertFalse(recording.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskNodeExecuteMessage message(ActionEnum action, FlowGraph graph) {
|
||||||
|
TaskNodeExecuteMessage message = new TaskNodeExecuteMessage();
|
||||||
|
message.setInstId("inst-1");
|
||||||
|
message.setNodeId(action == ActionEnum.MICROPHONE_STOP ? "record-stop" : "record-start");
|
||||||
|
message.setAction(action);
|
||||||
|
message.setRobotId("robot-1");
|
||||||
|
message.setGraph(graph);
|
||||||
|
message.setInputParams(new JSONObject().fluentPut("deviceId", "microphone-1"));
|
||||||
|
message.setIterations(Collections.emptyList());
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FlowGraph graph() {
|
||||||
|
FlowGraph graph = new FlowGraph();
|
||||||
|
graph.getNodeMap().put("matching-start", node("matching-start", ActionEnum.MICROPHONE_START));
|
||||||
|
graph.getNodeMap().put("other-start", node("other-start", ActionEnum.MICROPHONE_START));
|
||||||
|
graph.getNodeMap().put("sleep", node("sleep", ActionEnum.SLEEP));
|
||||||
|
graph.getNodeMap().put("record-stop", node("record-stop", ActionEnum.MICROPHONE_STOP));
|
||||||
|
graph.addEdge(new FlowEdge("matching-start", "sleep", "a", null));
|
||||||
|
graph.addEdge(new FlowEdge("other-start", "sleep", "b", null));
|
||||||
|
graph.addEdge(new FlowEdge("sleep", "record-stop", "c", null));
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FlowNodeWrapper node(String nodeId, ActionEnum action) {
|
||||||
|
FlowNodeWrapper node = new FlowNodeWrapper();
|
||||||
|
node.setNodeId(nodeId);
|
||||||
|
node.setAction(action);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private <T> T proxy(Class<T> type, Invocation invocation) {
|
||||||
|
return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type},
|
||||||
|
(proxy, method, args) -> invocation.invoke(method, args));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object defaultValue(Class<?> type) {
|
||||||
|
if (!type.isPrimitive()) return null;
|
||||||
|
if (type == boolean.class) return false;
|
||||||
|
if (type == char.class) return '\0';
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface Invocation {
|
||||||
|
Object invoke(java.lang.reflect.Method method, Object[] args) throws Throwable;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
package com.cmvr.test.flow.runtime.operator.edge;
|
||||||
|
|
||||||
|
import com.cmvr.edge.client.service.impl.EdgeMicrophoneServiceImpl;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.RandomAccessFile;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
public class EdgeMicrophoneSignalProcessingTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void removesDcOffsetAndAmplifiesQuietPcmWithoutClipping() throws Exception {
|
||||||
|
short[] source = {100, 1_100, -900};
|
||||||
|
short[] normalized = normalize(source);
|
||||||
|
|
||||||
|
assertEquals(0, normalized[0]);
|
||||||
|
assertEquals(8_000, normalized[1]);
|
||||||
|
assertEquals(-8_000, normalized[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void attenuatesLoudPcmToLeaveHeadroom() throws Exception {
|
||||||
|
short[] normalized = normalize(new short[]{-30_000, 30_000});
|
||||||
|
|
||||||
|
assertEquals(-26_000, normalized[0]);
|
||||||
|
assertEquals(26_000, normalized[1]);
|
||||||
|
assertTrue(Math.abs(normalized[0]) < Short.MAX_VALUE);
|
||||||
|
assertTrue(Math.abs(normalized[1]) < Short.MAX_VALUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private short[] normalize(short[] samples) throws Exception {
|
||||||
|
File file = File.createTempFile("microphone-signal-test-", ".wav");
|
||||||
|
long sum = 0L;
|
||||||
|
try (RandomAccessFile output = new RandomAccessFile(file, "rw")) {
|
||||||
|
output.write(new byte[44]);
|
||||||
|
for (short sample : samples) {
|
||||||
|
output.write(sample & 0xff);
|
||||||
|
output.write((sample >>> 8) & 0xff);
|
||||||
|
sum += sample;
|
||||||
|
}
|
||||||
|
|
||||||
|
Method normalize = EdgeMicrophoneServiceImpl.class.getDeclaredMethod(
|
||||||
|
"normalizePcmSignal", RandomAccessFile.class, long.class, long.class, long.class);
|
||||||
|
normalize.setAccessible(true);
|
||||||
|
normalize.invoke(new EdgeMicrophoneServiceImpl(null), output,
|
||||||
|
samples.length * 2L, (long) samples.length, sum);
|
||||||
|
|
||||||
|
output.seek(44L);
|
||||||
|
short[] result = new short[samples.length];
|
||||||
|
for (int index = 0; index < result.length; index++) {
|
||||||
|
result[index] = (short) (output.readUnsignedByte() | (output.readUnsignedByte() << 8));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
file.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
package com.cmvr.test.flow.runtime.operator.edge;
|
||||||
|
|
||||||
|
import com.cmvr.edge.client.utils.H265Fmp4Transcoder;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
|
||||||
|
public class H265Fmp4TranscoderTimingTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void timelineUsesElapsedTimeInsteadOfConfiguredFrameCount() throws Exception {
|
||||||
|
Method method = H265Fmp4Transcoder.class.getDeclaredMethod(
|
||||||
|
"timelineTimestampMicros", long.class, long.class, long.class, long.class);
|
||||||
|
method.setAccessible(true);
|
||||||
|
|
||||||
|
long firstFrameNanos = 1_000_000_000L;
|
||||||
|
long fiveSecondsLater = firstFrameNanos + TimeUnit.SECONDS.toNanos(5L);
|
||||||
|
long timestamp = (long) method.invoke(null, firstFrameNanos, fiveSecondsLater,
|
||||||
|
1_880_000L, 40_000L);
|
||||||
|
|
||||||
|
assertEquals(5_000_000L, timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void timelineSkipsFramesThatMapToAnOccupiedOutputSlot() throws Exception {
|
||||||
|
Method method = H265Fmp4Transcoder.class.getDeclaredMethod(
|
||||||
|
"timelineTimestampMicros", long.class, long.class, long.class, long.class);
|
||||||
|
method.setAccessible(true);
|
||||||
|
|
||||||
|
long timestamp = (long) method.invoke(null, 1_000L, 1_001L, 0L, 40_000L);
|
||||||
|
|
||||||
|
assertEquals(-1L, timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -103,7 +103,7 @@ CREATE TABLE `aima_task_test_case` (
|
|||||||
`task_id` varchar(64) NOT NULL COMMENT '任务ID',
|
`task_id` varchar(64) NOT NULL COMMENT '任务ID',
|
||||||
`test_case_id` varchar(64) NOT NULL COMMENT '测试用例ID',
|
`test_case_id` varchar(64) NOT NULL COMMENT '测试用例ID',
|
||||||
`sort_order` int(11) DEFAULT '0' COMMENT '排序号',
|
`sort_order` int(11) DEFAULT '0' COMMENT '排序号',
|
||||||
PRIMARY KEY (`task_id`, `test_case_id`),
|
PRIMARY KEY (`task_id`, `sort_order`),
|
||||||
KEY `idx_task_id` (`task_id`),
|
KEY `idx_task_id` (`task_id`),
|
||||||
KEY `idx_test_case_id` (`test_case_id`)
|
KEY `idx_test_case_id` (`test_case_id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='爱玛任务与测试用例关联表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='爱玛任务与测试用例关联表';
|
||||||
|
|||||||
@ -125,7 +125,7 @@ CREATE TABLE `inspection_task_waypoint` (
|
|||||||
`task_id` varchar(64) NOT NULL COMMENT '任务ID',
|
`task_id` varchar(64) NOT NULL COMMENT '任务ID',
|
||||||
`waypoint_id` varchar(64) NOT NULL COMMENT '点位ID',
|
`waypoint_id` varchar(64) NOT NULL COMMENT '点位ID',
|
||||||
`sort_order` int(11) DEFAULT '0' COMMENT '排序号',
|
`sort_order` int(11) DEFAULT '0' COMMENT '排序号',
|
||||||
PRIMARY KEY (`task_id`, `waypoint_id`),
|
PRIMARY KEY (`task_id`, `sort_order`),
|
||||||
KEY `idx_task_id` (`task_id`),
|
KEY `idx_task_id` (`task_id`),
|
||||||
KEY `idx_waypoint_id` (`waypoint_id`)
|
KEY `idx_waypoint_id` (`waypoint_id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务与点位关联表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务与点位关联表';
|
||||||
|
|||||||
12
sql/task_binding_allow_duplicates.sql
Normal file
12
sql/task_binding_allow_duplicates.sql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
-- Allow the same test case or waypoint to appear more than once in a task.
|
||||||
|
-- The task position, rather than the bound resource ID, identifies a row.
|
||||||
|
|
||||||
|
ALTER TABLE `aima_task_test_case`
|
||||||
|
DROP PRIMARY KEY,
|
||||||
|
MODIFY COLUMN `sort_order` int NOT NULL,
|
||||||
|
ADD PRIMARY KEY (`task_id`, `sort_order`);
|
||||||
|
|
||||||
|
ALTER TABLE `inspection_task_waypoint`
|
||||||
|
DROP PRIMARY KEY,
|
||||||
|
MODIFY COLUMN `sort_order` int NOT NULL,
|
||||||
|
ADD PRIMARY KEY (`task_id`, `sort_order`);
|
||||||
Loading…
Reference in New Issue
Block a user