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')")
|
||||
@PostMapping("/stop")
|
||||
public AjaxResult stopRecord(@RequestBody EdgeCommonVO edgeCommonVO) {
|
||||
microphoneService.stopRecord(edgeCommonVO);
|
||||
return AjaxResult.success();
|
||||
return AjaxResult.success(microphoneService.stopRecord(edgeCommonVO));
|
||||
}
|
||||
|
||||
@ApiOperation("暂停录音")
|
||||
|
||||
@ -5,6 +5,7 @@ import com.cmvr.common.core.controller.BaseController;
|
||||
import com.cmvr.common.core.domain.AjaxResult;
|
||||
import com.cmvr.edge.client.model.system.EdgeSystemJsonCommandVO;
|
||||
import com.cmvr.edge.client.model.system.EdgeSystemUpdateParamsVO;
|
||||
import com.cmvr.edge.client.model.system.EdgeActionQueueVO;
|
||||
import com.cmvr.edge.client.service.EdgeSystemService;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
@ -48,6 +49,12 @@ public class EdgeSystemController extends BaseController {
|
||||
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("停止所有设备")
|
||||
@GetMapping("/stopAll")
|
||||
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.ApiOperation;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@ -50,8 +51,9 @@ public class TeFlowiseController extends BaseController {
|
||||
.put("test", request.getQuestion());
|
||||
runParams.put("5bb26d52e1861c6936f3c3149eaf17e8", runParams1);
|
||||
TeTaskExecuteNormalVO taskExecuteNormalVO = TeTaskExecuteNormalVO.builder()
|
||||
.taskId("b6ab5ffff783d9b25d0dc34f13be222d")
|
||||
.robotId("4ed1246c465b97975f96c9ef8371a3bd")
|
||||
.taskId(StringUtils.defaultIfBlank(request.getTaskId(), "b6ab5ffff783d9b25d0dc34f13be222d"))
|
||||
.robotId(StringUtils.defaultIfBlank(request.getRobotId(), "4ed1246c465b97975f96c9ef8371a3bd"))
|
||||
.runtimeAssignments(request.getRuntimeAssignments())
|
||||
.runParams(JSONObject.from(runParams)).build();
|
||||
if (!taskInstIdList.isEmpty()) {
|
||||
try {
|
||||
@ -112,4 +114,4 @@ public class TeFlowiseController extends BaseController {
|
||||
public AjaxResult actionStop() {
|
||||
return AjaxResult.ok(flowiseActionService.actionStop());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,6 +48,9 @@ user:
|
||||
|
||||
# Spring配置
|
||||
spring:
|
||||
jackson:
|
||||
date-format: "yyyy-MM-dd HH:mm:ss"
|
||||
time-zone: Asia/Shanghai
|
||||
sql:
|
||||
init:
|
||||
mode: always
|
||||
|
||||
@ -2,6 +2,7 @@ package com.cmvr.aima.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.*;
|
||||
@ -46,6 +47,18 @@ public class AimaTestLog extends BaseEntity
|
||||
@ApiModelProperty("测试用例ID")
|
||||
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 = "用例名称")
|
||||
@ApiModelProperty("用例名称")
|
||||
private String caseName;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.cmvr.aima.domain.vo;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.cmvr.test.model.vo.ResourceRuntimeAssignmentVO;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
@ -17,4 +18,7 @@ public class AimaTaskStartVO {
|
||||
|
||||
@ApiModelProperty("机器人角色运行分配")
|
||||
private List<ResourceRuntimeAssignmentVO> runtimeAssignments = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty("按检测项和出现序号组织的本次运行参数")
|
||||
private JSONObject runParams = new JSONObject();
|
||||
}
|
||||
|
||||
@ -1,16 +1,19 @@
|
||||
package com.cmvr.aima.listener;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.cmvr.common.utils.DateUtils;
|
||||
import com.cmvr.framework.websocket.service.MessagePushService;
|
||||
import com.cmvr.aima.domain.AimaTaskInstance;
|
||||
import com.cmvr.aima.domain.AimaTestLog;
|
||||
import com.cmvr.aima.enums.AimaTaskStatusEnum;
|
||||
import com.cmvr.aima.service.IAimaTaskInstanceService;
|
||||
import com.cmvr.aima.service.IAimaTaskService;
|
||||
import com.cmvr.aima.service.IAimaTestLogService;
|
||||
import com.cmvr.test.flow.runtime.event.FlowExecutionEvent;
|
||||
import com.cmvr.test.flow.runtime.event.FlowExecutionListener;
|
||||
import com.cmvr.test.flow.runtime.event.FlowExecutionModuleCodes;
|
||||
import com.cmvr.test.flow.runtime.operator.FlowMediaParamResolver;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@ -28,18 +31,23 @@ import java.util.*;
|
||||
public class AimaFlowExecutionListener implements FlowExecutionListener {
|
||||
private final IAimaTaskInstanceService aimaTaskInstanceService;
|
||||
private final IAimaTestLogService aimaTestLogService;
|
||||
private final IAimaTaskService aimaTaskService;
|
||||
private final MessagePushService messagePushService;
|
||||
|
||||
/** 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集合 */
|
||||
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,
|
||||
IAimaTestLogService aimaTestLogService,
|
||||
IAimaTaskService aimaTaskService,
|
||||
MessagePushService messagePushService) {
|
||||
this.aimaTaskInstanceService = aimaTaskInstanceService;
|
||||
this.aimaTestLogService = aimaTestLogService;
|
||||
this.aimaTaskService = aimaTaskService;
|
||||
this.messagePushService = messagePushService;
|
||||
}
|
||||
|
||||
@ -60,11 +68,13 @@ public class AimaFlowExecutionListener implements FlowExecutionListener {
|
||||
// 1.节点完成才记录当前item下node
|
||||
if (StrUtil.isNotBlank(itemId) && StrUtil.isNotBlank(nodeId)
|
||||
&& 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)
|
||||
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;
|
||||
|
||||
// 当前item已完成节点数
|
||||
Set<String> finishedNodeSet = itemNodeExecutMap.getOrDefault(itemId, new HashSet<>());
|
||||
Set<String> finishedNodeSet = itemNodeExecutMap.getOrDefault(itemExecutionKey(instId, event), new HashSet<>());
|
||||
int finishedNode = finishedNodeSet.size();
|
||||
int totalNode = event.getNodeCount() == 0 ? 1 : event.getNodeCount();
|
||||
|
||||
// 已完整跑完用例占比
|
||||
if (event.getItemOrder() != null && event.getItemOrder() > 0) {
|
||||
finishedItemNum = event.getItemOrder() - 1;
|
||||
}
|
||||
double itemTotalRatio = finishedItemNum / (double) totalCases;
|
||||
// 当前运行item内部节点占比
|
||||
double innerRatio = finishedNode / (double) totalNode / totalCases;
|
||||
@ -134,16 +147,27 @@ public class AimaFlowExecutionListener implements FlowExecutionListener {
|
||||
String logId = UUID.randomUUID().toString().replace("-", "");
|
||||
// 四舍五入保留2位小数
|
||||
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()
|
||||
.id(logId)
|
||||
.taskInstanceId(taskInstance.getId())
|
||||
.taskId(taskInstance.getTaskId())
|
||||
.testCaseId(itemId)
|
||||
.testCaseId(testCaseId)
|
||||
.itemId(itemId)
|
||||
.itemOrder(event.getItemOrder())
|
||||
.itemOccurrence(event.getItemOccurrence())
|
||||
.logLevel("INFO")
|
||||
.logType("执行日志")
|
||||
.logContent("节点【" + event.getNodeName() + "】" + info)
|
||||
.actualResult(outputParams == null || outputParams.isEmpty()
|
||||
? null : outputParams.toJSONString())
|
||||
.screenshotUrl(imageUrl)
|
||||
.videoUrl(videoUrl)
|
||||
.logTime(now)
|
||||
.status(targetStatus)
|
||||
.executionStep(event.getNodeName())
|
||||
@ -172,6 +196,24 @@ public class AimaFlowExecutionListener implements FlowExecutionListener {
|
||||
Set<String> itemList = taskItemExecutMap.getOrDefault(taskInsId, new HashSet<>());
|
||||
itemList.forEach(itemNodeExecutMap::remove);
|
||||
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 org.apache.ibatis.annotations.Param;
|
||||
import com.cmvr.aima.domain.AimaTaskTestCase;
|
||||
import com.cmvr.aima.domain.AimaTestCase;
|
||||
|
||||
/**
|
||||
* 爱玛任务与测试用例关联Mapper接口
|
||||
@ -38,6 +39,9 @@ public interface AimaTaskTestCaseMapper extends MPJBaseMapper<AimaTaskTestCase>
|
||||
*/
|
||||
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.UUID;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
|
||||
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.event.FlowExecutionModuleCodes;
|
||||
import com.cmvr.test.model.vo.TeTaskExecuteNormalVO;
|
||||
import com.cmvr.test.model.vo.TeTaskOrchestraVO;
|
||||
import com.cmvr.test.service.ITeTaskOrchestrationService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
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.domain.AimaTask;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 爱玛任务执行实例Service业务层处理
|
||||
@ -138,12 +143,37 @@ public class AimaTaskInstanceServiceImpl extends ServiceImpl<AimaTaskInstanceMap
|
||||
|
||||
|
||||
AimaTask aimaTask = aimaTaskService.selectAimaTaskById(instance.getTaskId());
|
||||
JSONObject runParams = new JSONObject();
|
||||
// 从test模块的任务编排中获取所有的检测项id
|
||||
if (aimaTask != null && aimaTask.getTaskConfigId() != null) {
|
||||
teTaskOrchestrationService.queryByTaskId(aimaTask.getTaskConfigId())
|
||||
.forEach(item -> runParams.put(item.getItemId(), new JSONObject()));
|
||||
if (aimaTask == null || StrUtil.isBlank(aimaTask.getTaskConfigId())) {
|
||||
throw new ServiceException("智能测试任务没有关联流程编排");
|
||||
}
|
||||
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(
|
||||
TeTaskExecuteNormalVO.builder()
|
||||
|
||||
@ -6,7 +6,6 @@ import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
|
||||
import com.cmvr.aima.domain.AimaTaskTestCase;
|
||||
import com.cmvr.aima.domain.AimaTestCase;
|
||||
@ -111,6 +110,21 @@ public class AimaTaskServiceImpl extends ServiceImpl<AimaTaskMapper, AimaTask> i
|
||||
|
||||
@Override
|
||||
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);
|
||||
|
||||
@ -121,7 +135,7 @@ public class AimaTaskServiceImpl extends ServiceImpl<AimaTaskMapper, AimaTask> i
|
||||
AimaTaskTestCase taskTestCase = new AimaTaskTestCase();
|
||||
taskTestCase.setTaskId(taskId);
|
||||
taskTestCase.setTestCaseId(testCaseIds.get(i));
|
||||
taskTestCase.setSortOrder(i);
|
||||
taskTestCase.setSortOrder(i + 1);
|
||||
list.add(taskTestCase);
|
||||
}
|
||||
aimaTaskTestCaseMapper.batchInsertTaskTestCase(list);
|
||||
@ -133,27 +147,27 @@ public class AimaTaskServiceImpl extends ServiceImpl<AimaTaskMapper, AimaTask> i
|
||||
aimaTaskMapper.updateAimaTask(task);
|
||||
|
||||
// 查询测试用例,获取detectItemId列表
|
||||
List<AimaTestCase> testCases = aimaTestCaseMapper.selectBatchIds(testCaseIds);
|
||||
List<AimaTestCase> testCases = getTestCaseIds(taskId);
|
||||
List<String> detectItemIds = testCases.stream()
|
||||
.map(AimaTestCase::getDetectItemId)
|
||||
.filter(itemId -> itemId != null && !itemId.isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 同步任务编排到test模块
|
||||
if (!detectItemIds.isEmpty()) {
|
||||
AimaTask aimaTask = aimaTaskMapper.selectAimaTaskById(taskId);
|
||||
if (!detectItemIds.isEmpty() && existingTask.getTaskConfigId() != null) {
|
||||
TeTaskOrchestraVO teTaskOrchestraVO = TeTaskOrchestraVO.builder()
|
||||
.taskId(aimaTask.getTaskConfigId())
|
||||
.taskId(existingTask.getTaskConfigId())
|
||||
.itemIds(detectItemIds)
|
||||
.build();
|
||||
teTaskOrchestrationService.insertTeTaskOrchestration(teTaskOrchestraVO);
|
||||
} else if (existingTask.getTaskConfigId() != null) {
|
||||
teTaskOrchestrationService.deleteByTaskId(existingTask.getTaskConfigId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AimaTestCase> getTestCaseIds(String taskId) {
|
||||
List<String> ids = aimaTaskTestCaseMapper.selectTestCaseIdsByTaskId(taskId);
|
||||
return aimaTestCaseMapper.selectList(new QueryWrapper<AimaTestCase>().in("id", ids));
|
||||
return aimaTaskTestCaseMapper.selectTestCasesByTaskId(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,7 +16,32 @@
|
||||
<select id="selectTestCaseIdsByTaskId" parameterType="String" resultType="String">
|
||||
SELECT test_case_id FROM aima_task_test_case
|
||||
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>
|
||||
|
||||
<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 io.grpc.stub.StreamObserver;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.OutputStream;
|
||||
|
||||
/**
|
||||
@ -49,6 +50,12 @@ public interface EdgeCameraService {
|
||||
*/
|
||||
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.microphone.EdgeMicrophoneVolumeVO;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* 边缘麦克风服务接口
|
||||
* 定义了与边缘麦克风进行交互的所有方法
|
||||
@ -25,6 +27,12 @@ public interface EdgeMicrophoneService {
|
||||
*/
|
||||
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);
|
||||
|
||||
/**
|
||||
* 暂停录音
|
||||
*/
|
||||
@ -44,4 +52,4 @@ public interface EdgeMicrophoneService {
|
||||
* 获取当前音量
|
||||
*/
|
||||
int getVolume(EdgeCommonVO edgeCommonVO);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import cmvr.api.SystemCommand;
|
||||
import com.cmvr.device.domain.DeDeviceRegistration;
|
||||
import com.cmvr.edge.client.model.system.EdgeSystemJsonCommandVO;
|
||||
import com.cmvr.edge.client.model.system.EdgeSystemUpdateParamsVO;
|
||||
import com.cmvr.edge.client.model.system.EdgeActionQueueVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -24,6 +25,8 @@ public interface EdgeSystemService {
|
||||
|
||||
Common.JsonDeviceCommand.Feedback executeJsonCommand(EdgeSystemJsonCommandVO request);
|
||||
|
||||
SystemCommand.ActionQueueCommand.Feedback executeActionQueue(EdgeActionQueueVO request);
|
||||
|
||||
|
||||
public List<DeDeviceRegistration> deviceList(String robotId);
|
||||
|
||||
|
||||
@ -22,9 +22,11 @@ import com.cmvr.system.service.ISysFileService;
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.grpc.Status;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@ -45,6 +47,7 @@ import java.awt.image.WritableRaster;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
@ -53,7 +56,11 @@ import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.util.Base64;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
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.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
@ -64,18 +71,26 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||
@RequiredArgsConstructor
|
||||
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 ISysFileService sysFileService;
|
||||
|
||||
private final MessagePushService messagePushService;
|
||||
|
||||
private final Map<String, WorkflowRecordingSession> workflowRecordings = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public CameraCommand.CameraState status(EdgeCommonVO edgeCommonVO) {
|
||||
CameraServiceGrpc.CameraServiceBlockingStub stub = grpcServiceManager.getGrpcClient(edgeCommonVO.getRobotId(), CameraServiceGrpc.CameraServiceBlockingStub.class);
|
||||
CameraCommand.GetCameraStateCommand.Request request = CameraCommand.GetCameraStateCommand.Request.newBuilder()
|
||||
.setHeader(EdgeCommonUtil.buildRequest(edgeCommonVO.getDeviceId()))
|
||||
.build();
|
||||
return executeGrpcCall(() -> stub.getStatus(request)).getState();
|
||||
CameraCommand.GetCameraStateCommand.Feedback feedback = executeGrpcCall(() -> stub.getStatus(request));
|
||||
requireSuccess(feedback.getHeader(), "获取相机状态");
|
||||
return feedback.getState();
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -94,6 +109,7 @@ public class EdgeCameraServiceImpl implements EdgeCameraService, EdgeStreamServi
|
||||
.setHeader(EdgeCommonUtil.buildRequest(edgeCommonVO.getDeviceId()))
|
||||
.build();
|
||||
Common.CommandHeader.Feedback header = executeGrpcCall(() -> stub.startCamera(request)).getHeader();
|
||||
requireSuccess(header, "开启相机");
|
||||
return JSON.toJSONString(header);
|
||||
}
|
||||
|
||||
@ -103,7 +119,9 @@ public class EdgeCameraServiceImpl implements EdgeCameraService, EdgeStreamServi
|
||||
CameraCommand.StopCameraCommand.Request request = CameraCommand.StopCameraCommand.Request.newBuilder()
|
||||
.setHeader(EdgeCommonUtil.buildRequest(edgeCommonVO.getDeviceId()))
|
||||
.build();
|
||||
abortWorkflowRecordings(edgeCommonVO);
|
||||
Common.CommandHeader.Feedback header = executeGrpcCall(() -> stub.stopCamera(request)).getHeader();
|
||||
requireSuccess(header, "关闭相机");
|
||||
return JSON.toJSONString(header);
|
||||
}
|
||||
|
||||
@ -118,20 +136,23 @@ public class EdgeCameraServiceImpl implements EdgeCameraService, EdgeStreamServi
|
||||
.setHeader(EdgeCommonUtil.buildRequest(edgeCommonVO.getDeviceId()))
|
||||
.build();
|
||||
CameraCommand.GetRGBImageCommand.Feedback feedback = executeGrpcCall(() -> stub.getRGBImage(request));
|
||||
requireSuccess(feedback.getHeader(), "获取相机图片");
|
||||
CameraCommand.FrameData frame = feedback.getColorFrame();
|
||||
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 {
|
||||
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) {
|
||||
throw new GlobalException("图片上传失败: " + e.getMessage());
|
||||
throw new GlobalException("获取相机图片失败: " + e.getMessage());
|
||||
} finally {
|
||||
deleteTempFile(filePath);
|
||||
// todo 暂时不关闭摄像头
|
||||
// stop(robotId, deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -186,6 +207,70 @@ public class EdgeCameraServiceImpl implements EdgeCameraService, EdgeStreamServi
|
||||
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
|
||||
public String getRGBDImages(EdgeCommonVO 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
|
||||
private interface GrpcCall<T> {
|
||||
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 {
|
||||
int rowSize = (width * 3 + 3) & ~3; // BMP 行对齐
|
||||
int imageSize = rowSize * height;
|
||||
@ -580,6 +896,42 @@ public class EdgeCameraServiceImpl implements EdgeCameraService, EdgeStreamServi
|
||||
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) {
|
||||
File tempDir = new File(System.getProperty("java.io.tmpdir"));
|
||||
FileUtil.mkdir(tempDir);
|
||||
|
||||
@ -1,19 +1,37 @@
|
||||
package com.cmvr.edge.client.service.impl;
|
||||
|
||||
import cmvr.api.Common;
|
||||
import cmvr.api.MicPhoneServiceGrpc;
|
||||
import cmvr.api.MicrophoneCommand;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.cmvr.common.exception.GlobalException;
|
||||
import com.cmvr.edge.client.manage.GrpcServiceManager;
|
||||
import com.cmvr.edge.client.model.EdgeCommonVO;
|
||||
import com.cmvr.edge.client.model.microphone.EdgeMicrophoneVolumeVO;
|
||||
import com.cmvr.edge.client.service.EdgeMicrophoneService;
|
||||
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 org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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通信,控制麦克风录音和状态
|
||||
@ -22,6 +40,19 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
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 Map<String, WorkflowAudioRecordingSession> workflowRecordings = new ConcurrentHashMap<>();
|
||||
private final Map<String, String> workflowRecordingTargets = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 获取麦克风当前状态
|
||||
* 通过gRPC调用获取麦克风的运行状态,包括是否正在录音、音量等信息
|
||||
@ -41,16 +75,10 @@ public class EdgeMicrophoneServiceImpl implements EdgeMicrophoneService {
|
||||
@Override
|
||||
public String getStatus(EdgeCommonVO edgeCommonVO) {
|
||||
try {
|
||||
MicPhoneServiceGrpc.MicPhoneServiceBlockingStub stub = grpcServiceManager.getGrpcClient(edgeCommonVO.getRobotId(), MicPhoneServiceGrpc.MicPhoneServiceBlockingStub.class);
|
||||
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());
|
||||
return JSON.toJSONString(getMicrophoneState(edgeCommonVO));
|
||||
} catch (Exception e) {
|
||||
logger.error("获取麦克风状态失败", e);
|
||||
throw new RuntimeException("获取麦克风状态失败", e);
|
||||
throw operationFailure("获取麦克风状态", e);
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,24 +88,19 @@ public class EdgeMicrophoneServiceImpl implements EdgeMicrophoneService {
|
||||
*/
|
||||
@Override
|
||||
public String startRecord(EdgeCommonVO edgeCommonVO) {
|
||||
String deviceId = edgeCommonVO.getDeviceId();
|
||||
try {
|
||||
// 获取gRPC客户端存根
|
||||
MicPhoneServiceGrpc.MicPhoneServiceBlockingStub stub = grpcServiceManager.getGrpcClient(edgeCommonVO.getRobotId(), MicPhoneServiceGrpc.MicPhoneServiceBlockingStub.class);
|
||||
String filePath = StrUtil.format("{}/{}_{}.wav", "/home/share/assets/video", deviceId, System.currentTimeMillis());
|
||||
|
||||
// 构建请求消息
|
||||
MicrophoneCommand.StartMicRecordingCommand.Request request = MicrophoneCommand.StartMicRecordingCommand.Request.newBuilder()
|
||||
.setHeader(EdgeCommonUtil.buildRequest(deviceId))
|
||||
.setFilePath(filePath)
|
||||
.build();
|
||||
|
||||
// 发送请求
|
||||
stub.startRecord(request);
|
||||
if (workflowRecordingTargets.containsKey(
|
||||
recordingTargetKey(edgeCommonVO.getRobotId(), edgeCommonVO.getDeviceId()))) {
|
||||
throw new GlobalException("该机器人麦克风正在被工作流录音占用");
|
||||
}
|
||||
String filePath = StrUtil.format("{}/{}_{}.wav", "/home/share/assets/video",
|
||||
edgeCommonVO.getDeviceId(),
|
||||
UUID.randomUUID().toString().replace("-", ""));
|
||||
startTerminalRecording(edgeCommonVO, filePath);
|
||||
return filePath;
|
||||
} catch (Exception e) {
|
||||
logger.error("开始录音失败", e);
|
||||
throw new RuntimeException("开始录音失败", e);
|
||||
throw operationFailure("开始录音", e);
|
||||
}
|
||||
}
|
||||
|
||||
@ -88,23 +111,98 @@ public class EdgeMicrophoneServiceImpl implements EdgeMicrophoneService {
|
||||
@Override
|
||||
public String stopRecord(EdgeCommonVO edgeCommonVO) {
|
||||
try {
|
||||
// 获取gRPC客户端存根
|
||||
MicPhoneServiceGrpc.MicPhoneServiceBlockingStub stub = grpcServiceManager.getGrpcClient(edgeCommonVO.getRobotId(), MicPhoneServiceGrpc.MicPhoneServiceBlockingStub.class);
|
||||
|
||||
// 构建请求消息
|
||||
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());
|
||||
abortWorkflowRecordings(edgeCommonVO);
|
||||
return JSON.toJSONString(stopTerminalRecording(edgeCommonVO));
|
||||
} catch (Exception 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调用暂停麦克风的当前录音
|
||||
@ -198,4 +296,470 @@ public class EdgeMicrophoneServiceImpl implements EdgeMicrophoneService {
|
||||
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.ArmServiceGrpc;
|
||||
import cmvr.api.ArmCommand;
|
||||
import cmvr.api.AgvCommand;
|
||||
import cmvr.msgs.AgvUtils;
|
||||
import cmvr.api.SystemCommand;
|
||||
import cmvr.api.SystemServiceGrpc;
|
||||
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.model.system.EdgeSystemJsonCommandVO;
|
||||
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.utils.EdgeCommonUtil;
|
||||
import com.cmvr.common.exception.GlobalException;
|
||||
@ -21,6 +25,10 @@ import org.springframework.stereotype.Service;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
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);
|
||||
}
|
||||
|
||||
@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) {
|
||||
if (param == null || param.getName() == null || param.getName().trim().isEmpty()) {
|
||||
throw new GlobalException("Parameter name cannot be empty");
|
||||
|
||||
@ -14,7 +14,9 @@ import java.io.OutputStream;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
@ -32,10 +34,13 @@ public final class H265Fmp4Transcoder implements AutoCloseable {
|
||||
private final LiveEncodedVideoInputStream inputStream;
|
||||
private final MediaChunkOutputStream outputStream;
|
||||
private final AtomicBoolean closed = new AtomicBoolean(false);
|
||||
private final CountDownLatch firstFrameWritten = new CountDownLatch(1);
|
||||
private final Thread worker;
|
||||
|
||||
private volatile FFmpegFrameGrabber grabber;
|
||||
private volatile FFmpegFrameRecorder recorder;
|
||||
private volatile long writtenFrameCount;
|
||||
private volatile long lastWrittenTimestampMicros = -1L;
|
||||
|
||||
public H265Fmp4Transcoder(String streamName,
|
||||
int configuredFrameRate,
|
||||
@ -66,6 +71,19 @@ public final class H265Fmp4Transcoder implements AutoCloseable {
|
||||
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() {
|
||||
try {
|
||||
InputCodec inputCodec = inputStream.awaitInputCodec();
|
||||
@ -99,7 +117,6 @@ public final class H265Fmp4Transcoder implements AutoCloseable {
|
||||
sourceFrameRate = Math.max(5D, Math.min(sourceFrameRate, 60D));
|
||||
double outputFrameRate = Math.min(sourceFrameRate, 25D);
|
||||
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));
|
||||
log.info("Camera stream {} uses {} source fps, {} output fps and a {} frame GOP",
|
||||
streamName, sourceFrameRate, outputFrameRate, gopSize);
|
||||
@ -119,17 +136,22 @@ public final class H265Fmp4Transcoder implements AutoCloseable {
|
||||
recorder.start();
|
||||
outputStream.publishAvailable();
|
||||
|
||||
long sourceFrameIndex = 0L;
|
||||
long nextOutputTimestampMicros = 0L;
|
||||
long firstFrameMonotonicNanos = System.nanoTime();
|
||||
long previousTimestampMicros = -1L;
|
||||
do {
|
||||
long sourceTimestampMicros = sourceFrameIndex * sourceFrameDurationMicros;
|
||||
if (sourceTimestampMicros + sourceFrameDurationMicros / 2 >= nextOutputTimestampMicros) {
|
||||
recorder.setTimestamp(nextOutputTimestampMicros);
|
||||
recorder.record(frame);
|
||||
nextOutputTimestampMicros += outputFrameDurationMicros;
|
||||
outputStream.publishAvailable();
|
||||
long timestampMicros = timelineTimestampMicros(
|
||||
firstFrameMonotonicNanos, System.nanoTime(), previousTimestampMicros,
|
||||
outputFrameDurationMicros);
|
||||
if (timestampMicros < 0L) {
|
||||
continue;
|
||||
}
|
||||
sourceFrameIndex++;
|
||||
recorder.setTimestamp(timestampMicros);
|
||||
recorder.record(frame);
|
||||
previousTimestampMicros = timestampMicros;
|
||||
writtenFrameCount++;
|
||||
lastWrittenTimestampMicros = timestampMicros;
|
||||
firstFrameWritten.countDown();
|
||||
outputStream.publishAvailable();
|
||||
} while (!closed.get() && (frame = grabber.grabImage()) != null);
|
||||
} catch (Throwable throwable) {
|
||||
if (!closed.get()) {
|
||||
@ -143,9 +165,23 @@ public final class H265Fmp4Transcoder implements AutoCloseable {
|
||||
}
|
||||
inputStream.close();
|
||||
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
|
||||
public void close() {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
@ -155,6 +191,15 @@ public final class H265Fmp4Transcoder implements AutoCloseable {
|
||||
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() {
|
||||
FFmpegFrameRecorder currentRecorder = recorder;
|
||||
recorder = null;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -170,6 +170,37 @@ public final class SystemServiceGrpc {
|
||||
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
|
||||
*/
|
||||
@ -253,6 +284,13 @@ public final class SystemServiceGrpc {
|
||||
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() {
|
||||
return io.grpc.ServerServiceDefinition.builder(getServiceDescriptor())
|
||||
.addMethod(
|
||||
@ -290,6 +328,13 @@ public final class SystemServiceGrpc {
|
||||
cmvr.api.SystemCommand.StopAllCommand.Request,
|
||||
cmvr.api.SystemCommand.StopAllCommand.Feedback>(
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -347,6 +392,14 @@ public final class SystemServiceGrpc {
|
||||
io.grpc.stub.ClientCalls.asyncUnaryCall(
|
||||
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(
|
||||
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(
|
||||
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;
|
||||
@ -459,6 +527,7 @@ public final class SystemServiceGrpc {
|
||||
private static final int METHODID_GET_DEVICE_LIST = 2;
|
||||
private static final int METHODID_UPDATE_PARAMS = 3;
|
||||
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
|
||||
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
|
||||
@ -497,6 +566,10 @@ public final class SystemServiceGrpc {
|
||||
serviceImpl.stopAll((cmvr.api.SystemCommand.StopAllCommand.Request) request,
|
||||
(io.grpc.stub.StreamObserver<cmvr.api.SystemCommand.StopAllCommand.Feedback>) responseObserver);
|
||||
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:
|
||||
throw new AssertionError();
|
||||
}
|
||||
@ -563,6 +636,7 @@ public final class SystemServiceGrpc {
|
||||
.addMethod(getGetDeviceListMethod())
|
||||
.addMethod(getUpdateParamsMethod())
|
||||
.addMethod(getStopAllMethod())
|
||||
.addMethod(getExecuteActionQueueMethod())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,7 +24,7 @@ public final class SystemServiceOuterClass {
|
||||
static {
|
||||
java.lang.String[] descriptorData = {
|
||||
"\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" +
|
||||
"etSystemInfoCommand.Request\032\'.cmvr.api.G" +
|
||||
"etSystemInfoCommand.Feedback\"\000\022h\n\017GetSys" +
|
||||
@ -37,7 +37,10 @@ public final class SystemServiceOuterClass {
|
||||
"and.Request\032&.cmvr.api.UpdateParamsComma" +
|
||||
"nd.Feedback\"\000\022P\n\007StopAll\022 .cmvr.api.Stop" +
|
||||
"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
|
||||
.internalBuildGeneratedFileFrom(descriptorData,
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
syntax = "proto3";
|
||||
|
||||
import "cmvr/api/agv_command.proto";
|
||||
import "cmvr/api/arm_command.proto";
|
||||
import "cmvr/api/common.proto";
|
||||
|
||||
package cmvr.api;
|
||||
@ -103,6 +105,10 @@ message GetSystemInfoCommand {
|
||||
string os = 5;
|
||||
string kernel_version = 6;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@ -134,7 +135,7 @@ public class MinioService {
|
||||
.bucket(bucketName)
|
||||
.object(path + "/" + objectName)
|
||||
.stream(stream, file.length(), -1) // 传入文件流
|
||||
.contentType("application/octet-stream") // 可以根据需要修改文件类型
|
||||
.contentType(resolveContentType(file))
|
||||
.build();
|
||||
|
||||
// 上传文件到 MinIO
|
||||
@ -147,6 +148,24 @@ public class MinioService {
|
||||
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.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
@ -46,6 +47,14 @@ public class InspectionTaskLog extends BaseEntity
|
||||
@ApiModelProperty("检查项id")
|
||||
private String itemId;
|
||||
|
||||
/** 当前绑定项的执行位置,从 1 开始。 */
|
||||
@TableField(exist = false)
|
||||
private Integer itemOrder;
|
||||
|
||||
/** 同一检测项在任务中第几次出现。 */
|
||||
@TableField(exist = false)
|
||||
private Integer itemOccurrence;
|
||||
|
||||
@Excel(name = "节点ID")
|
||||
@ApiModelProperty("节点ID")
|
||||
private String nodeId;
|
||||
|
||||
@ -55,6 +55,10 @@ public class InspectionTaskInstanceVo extends BaseEntity
|
||||
@ApiModelProperty("机器人名称")
|
||||
private String robotName;
|
||||
|
||||
/** 主要机器人的永久身份,用于兼容旧任务的执行默认值 */
|
||||
@ApiModelProperty("主要机器人的永久身份ID")
|
||||
private String robotIdentity;
|
||||
|
||||
/** 地图ID */
|
||||
@Excel(name = "地图ID")
|
||||
@ApiModelProperty("地图ID")
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.cmvr.inspection.domain.vo;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.cmvr.test.model.vo.ResourceRuntimeAssignmentVO;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
@ -17,4 +18,7 @@ public class InspectionTaskStartVO {
|
||||
|
||||
@ApiModelProperty("机器人角色运行分配")
|
||||
private List<ResourceRuntimeAssignmentVO> runtimeAssignments = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty("按检测项和出现序号组织的本次运行参数")
|
||||
private JSONObject runParams = new JSONObject();
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
package com.cmvr.inspection.listener;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.cmvr.common.utils.DateUtils;
|
||||
import com.cmvr.framework.websocket.service.MessagePushService;
|
||||
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.FlowExecutionListener;
|
||||
import com.cmvr.test.flow.runtime.event.FlowExecutionModuleCodes;
|
||||
import com.cmvr.test.flow.runtime.operator.FlowMediaParamResolver;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@ -71,13 +73,14 @@ public class InspectionFlowExecutionListener implements FlowExecutionListener {
|
||||
// 1.节点完成才记录当前item下node
|
||||
if (StrUtil.isNotBlank(itemId) && StrUtil.isNotBlank(nodeId)
|
||||
&& 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);
|
||||
}
|
||||
// 任意事件:当前任务绑定item(item一启动就入Map,所以统计已完成item要-1)
|
||||
if (StrUtil.isNotBlank(instId) && StrUtil.isNotBlank(itemId)) {
|
||||
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);
|
||||
@ -133,11 +136,14 @@ public class InspectionFlowExecutionListener implements FlowExecutionListener {
|
||||
|
||||
// 当前item已完成节点数
|
||||
Set<String> finishedNodeSet = itemNodeExecutMap.getOrDefault(
|
||||
itemExecutionKey(instId, itemId), new HashSet<>());
|
||||
itemExecutionKey(instId, itemId, event.getItemOrder()), new HashSet<>());
|
||||
int finishedNode = finishedNodeSet.size();
|
||||
int totalNode = event.getNodeCount() == 0 ? 1 : event.getNodeCount();
|
||||
|
||||
// 已完整跑完点位占比
|
||||
if (event.getItemOrder() != null && event.getItemOrder() > 0) {
|
||||
finishedItemNum = event.getItemOrder() - 1;
|
||||
}
|
||||
double itemTotalRatio = finishedItemNum / (double) waypointCount;
|
||||
// 当前运行item内部节点占比
|
||||
double innerRatio = finishedNode / (double) totalNode / waypointCount;
|
||||
@ -153,16 +159,37 @@ public class InspectionFlowExecutionListener implements FlowExecutionListener {
|
||||
String logId = UUID.randomUUID().toString().replace("-", "");
|
||||
// 四舍五入保留2位小数
|
||||
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()
|
||||
.id(logId)
|
||||
.taskInstanceId(taskInstance.getId())
|
||||
.taskId(taskInstance.getTaskId())
|
||||
.itemId(itemId)
|
||||
.itemOrder(event.getItemOrder())
|
||||
.itemOccurrence(event.getItemOccurrence())
|
||||
.nodeId(nodeId)
|
||||
.nodeName(event.getNodeName())
|
||||
.logType(InspectionLogTypeEnum.TEXT.getCode())
|
||||
.logType(logType)
|
||||
.logContent("节点【" + event.getNodeName() + "】" + info)
|
||||
.mediaUrl(mediaUrl)
|
||||
.extraInfo(outputParams == null || outputParams.isEmpty()
|
||||
? null : outputParams.toJSONString())
|
||||
.status(targetStatus)
|
||||
.progress(progress)
|
||||
.logTime(now)
|
||||
@ -185,7 +212,7 @@ public class InspectionFlowExecutionListener implements FlowExecutionListener {
|
||||
/** 任务结束统一清理缓存 */
|
||||
private void clearCache(String taskInsId) {
|
||||
Set<String> itemList = taskItemExecutMap.getOrDefault(taskInsId, new HashSet<>());
|
||||
itemList.forEach(itemId -> itemNodeExecutMap.remove(itemExecutionKey(taskInsId, itemId)));
|
||||
itemList.forEach(itemNodeExecutMap::remove);
|
||||
taskItemExecutMap.remove(taskInsId);
|
||||
}
|
||||
|
||||
@ -193,6 +220,11 @@ public class InspectionFlowExecutionListener implements FlowExecutionListener {
|
||||
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,短暂重试消除该竞态。
|
||||
*/
|
||||
|
||||
@ -6,10 +6,16 @@ import com.cmvr.test.flow.runtime.engine.RobotDeviceAssignmentValidator;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
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;
|
||||
|
||||
@Override
|
||||
@ -18,9 +24,28 @@ public class InspectionRobotDeviceAssignmentValidator implements RobotDeviceAssi
|
||||
if (devices.isEmpty()) {
|
||||
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)) {
|
||||
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.UUID;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.cmvr.common.exception.ServiceException;
|
||||
import com.cmvr.common.utils.SecurityUtils;
|
||||
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.event.FlowExecutionModuleCodes;
|
||||
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 org.springframework.beans.factory.annotation.Autowired;
|
||||
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.vo.InspectionTaskStartVO;
|
||||
import com.cmvr.inspection.service.IInspectionTaskInstanceService;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 巡检任务执行实例Service业务层处理
|
||||
@ -160,18 +167,60 @@ public class InspectionTaskInstanceServiceImpl extends ServiceImpl<InspectionTas
|
||||
update.setUpdateTime(DateUtils.getNowDate());
|
||||
update.setUpdateBy(SecurityUtils.getUsername());
|
||||
InspectionTask inspectionTask = inspectionTaskService.selectInspectionTaskById(instance.getTaskId());
|
||||
InspectionRobot robot = inspectionRobotMapper.selectInspectionRobotById(instance.getRobotId());
|
||||
if (robot == null) {
|
||||
throw new ServiceException("巡检任务实例关联的机器人不存在");
|
||||
if (inspectionTask == null || StrUtil.isBlank(inspectionTask.getTaskConfigId())) {
|
||||
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 requestedRunParams = startVO == null ? null : startVO.getRunParams();
|
||||
Map<String, Integer> itemOccurrences = new HashMap<>();
|
||||
// 获取所有的检测项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()
|
||||
.taskId(inspectionTask.getTaskConfigId())
|
||||
.robotId(startVO != null && startVO.getRobotId() != null ? startVO.getRobotId() : robot.getRobotId())
|
||||
.runtimeAssignments(startVO == null ? null : startVO.getRuntimeAssignments())
|
||||
.robotId(robotId)
|
||||
.runtimeAssignments(runtimeAssignments)
|
||||
.moduleCode(FlowExecutionModuleCodes.INSPECTION)
|
||||
.runParams(runParams)
|
||||
.build());
|
||||
@ -179,6 +228,55 @@ public class InspectionTaskInstanceServiceImpl extends ServiceImpl<InspectionTas
|
||||
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 org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import com.cmvr.common.utils.DateUtils;
|
||||
import com.cmvr.common.utils.SecurityUtils;
|
||||
import com.cmvr.inspection.mapper.InspectionTaskMapper;
|
||||
@ -144,11 +145,22 @@ public class InspectionTaskServiceImpl extends ServiceImpl<InspectionTaskMapper,
|
||||
* @return 结果
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
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);
|
||||
if (waypointIds == null || waypointIds.length == 0) {
|
||||
inspectionTask.setWaypointCount(0);
|
||||
inspectionTaskMapper.updateInspectionTask(inspectionTask);
|
||||
if (inspectionTask.getTaskConfigId() != null) {
|
||||
teTaskOrchestrationService.deleteByTaskId(inspectionTask.getTaskConfigId());
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
// 批量插入新的关联关系
|
||||
@ -162,14 +174,24 @@ public class InspectionTaskServiceImpl extends ServiceImpl<InspectionTaskMapper,
|
||||
}
|
||||
inspectionTaskWaypointMapper.batchInsertTaskWaypoint(list);
|
||||
// 查询巡检任务,并修改点位数量
|
||||
InspectionTask inspectionTask = inspectionTaskMapper.selectInspectionTaskById(taskId);
|
||||
inspectionTask.setWaypointCount(waypointIds.length);
|
||||
inspectionTaskMapper.updateInspectionTask(inspectionTask);
|
||||
// 查询点位
|
||||
List<InspectionWaypoint> waypoints = inspectionTaskWaypointMapper.selectWaypointsByTaskId(taskId);
|
||||
// 同步任务编排
|
||||
TeTaskOrchestraVO teTaskOrchestraVO = TeTaskOrchestraVO.builder().taskId(inspectionTask.getTaskConfigId()).itemIds(waypoints.stream().map(InspectionWaypoint::getDetectItemId).collect(Collectors.toList())).build();
|
||||
teTaskOrchestrationService.insertTeTaskOrchestration(teTaskOrchestraVO);
|
||||
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);
|
||||
} else if (inspectionTask.getTaskConfigId() != null) {
|
||||
teTaskOrchestrationService.deleteByTaskId(inspectionTask.getTaskConfigId());
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
@ -180,8 +202,18 @@ public class InspectionTaskServiceImpl extends ServiceImpl<InspectionTaskMapper,
|
||||
* @return 结果
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
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="robotId" column="robot_id" />
|
||||
<result property="robotName" column="robot_name" />
|
||||
<result property="robotIdentity" column="robot_identity" />
|
||||
<result property="mapId" column="robot_current_map_id" />
|
||||
<result property="mapName" column="map_name" />
|
||||
<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 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.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,
|
||||
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
|
||||
@ -168,7 +169,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
<select id="selectInspectionTaskInstanceVoById" parameterType="String" resultMap="InspectionTaskInstanceVoResult">
|
||||
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.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,
|
||||
ti.status, ti.start_time, ti.end_time, ti.task_ins_id
|
||||
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 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
|
||||
inner join inspection_waypoint w on tw.waypoint_id = w.id
|
||||
where tw.task_id = #{taskId}
|
||||
order by tw.sort_order
|
||||
order by tw.sort_order, tw.waypoint_id
|
||||
</select>
|
||||
|
||||
<select id="selectTaskIdsByWaypointId" parameterType="String" resultType="String">
|
||||
|
||||
@ -11,6 +11,7 @@ import java.util.Map;
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum ActionEnum {
|
||||
SYSTEM_EXECUTE_ACTION_QUEUE("EDGE", "SYSTEM_EXECUTE_ACTION_QUEUE", "Execute edge action queue"),
|
||||
SPEAKER_PLAYAUDIO("EDGE", "SPEAKER_PLAYAUDIO", "Play audio"),
|
||||
|
||||
// 平台控制类
|
||||
|
||||
@ -43,6 +43,12 @@ public class TaskContext {
|
||||
*/
|
||||
private String itemId;
|
||||
|
||||
/** 当前检测项在任务中的实际执行位置。 */
|
||||
private Integer itemOrder;
|
||||
|
||||
/** 当前检测项在任务中的重复出现序号。 */
|
||||
private Integer itemOccurrence;
|
||||
|
||||
/** 机器人永久唯一 ID,用于执行路由和互斥。 */
|
||||
private String robotId;
|
||||
|
||||
|
||||
@ -97,6 +97,8 @@ public class TaskInstHolder {
|
||||
.taskId(taskId)
|
||||
.moduleCode(moduleCode)
|
||||
.itemId(itemId)
|
||||
.itemOrder(ctx != null ? ctx.getItemOrder() : null)
|
||||
.itemOccurrence(ctx != null ? ctx.getItemOccurrence() : null)
|
||||
.status(TaskStatusEnum.SUCCESS)
|
||||
.build());
|
||||
|
||||
@ -133,6 +135,8 @@ public class TaskInstHolder {
|
||||
.taskId(taskId)
|
||||
.moduleCode(moduleCode)
|
||||
.itemId(itemId)
|
||||
.itemOrder(ctx != null ? ctx.getItemOrder() : null)
|
||||
.itemOccurrence(ctx != null ? ctx.getItemOccurrence() : null)
|
||||
.status(TaskStatusEnum.FAILED)
|
||||
.errorMessage(message)
|
||||
.build());
|
||||
@ -167,6 +171,7 @@ public class TaskInstHolder {
|
||||
eventPublisher.publishEvent(FlowExecutionEvent.builder()
|
||||
.eventType(FlowExecutionEvent.EventType.TASK_PAUSED)
|
||||
.instId(instId).taskId(ctx.getTaskId()).itemId(ctx.getItemId())
|
||||
.itemOrder(ctx.getItemOrder()).itemOccurrence(ctx.getItemOccurrence())
|
||||
.moduleCode(ctx.getModuleCode())
|
||||
.status(status).build());
|
||||
break;
|
||||
@ -174,6 +179,7 @@ public class TaskInstHolder {
|
||||
eventPublisher.publishEvent(FlowExecutionEvent.builder()
|
||||
.eventType(FlowExecutionEvent.EventType.TASK_STOPPED)
|
||||
.instId(instId).taskId(ctx.getTaskId()).itemId(ctx.getItemId())
|
||||
.itemOrder(ctx.getItemOrder()).itemOccurrence(ctx.getItemOccurrence())
|
||||
.moduleCode(ctx.getModuleCode())
|
||||
.status(status).build());
|
||||
break;
|
||||
@ -191,6 +197,7 @@ public class TaskInstHolder {
|
||||
eventPublisher.publishEvent(FlowExecutionEvent.builder()
|
||||
.eventType(FlowExecutionEvent.EventType.TASK_RESUMED)
|
||||
.instId(instId).taskId(ctx.getTaskId()).itemId(ctx.getItemId())
|
||||
.itemOrder(ctx.getItemOrder()).itemOccurrence(ctx.getItemOccurrence())
|
||||
.moduleCode(ctx.getModuleCode())
|
||||
.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));
|
||||
}
|
||||
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++) {
|
||||
JSONObject role = roles.getJSONObject(i);
|
||||
@ -79,10 +81,26 @@ public class FlowRuntimeDefinitionValidator {
|
||||
throw new GlobalException("设备用途[" + slot.getString("displayName") + "]未分配真实设备");
|
||||
}
|
||||
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);
|
||||
}
|
||||
for (String assignedRoleKey : assignmentByRole.keySet()) {
|
||||
if (!slotKeysByRole.containsKey(assignedRoleKey)) {
|
||||
throw new GlobalException("运行安排引用了流程中不存在的机器人角色:" + assignedRoleKey);
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < nodes.size(); i++) {
|
||||
JSONObject node = nodes.getJSONObject(i);
|
||||
|
||||
@ -11,6 +11,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
|
||||
@Slf4j
|
||||
@ -36,20 +38,28 @@ public class FlowTaskEngine {
|
||||
JSONObject runParams = context.getRunParams();
|
||||
|
||||
int totalItemCount = items.size();
|
||||
Map<String, Integer> itemOccurrences = new HashMap<>();
|
||||
|
||||
for (int i = 0; i < totalItemCount; i++) {
|
||||
TeQueryTaskDetailDTO item = items.get(i);
|
||||
// 等待当前 item 完成后再执行下一个
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
String itemId = item.getDetectItemId();
|
||||
int itemOccurrence = itemOccurrences.merge(itemId, 1, Integer::sum);
|
||||
int itemOrder = i + 1;
|
||||
context.setItemId(itemId);
|
||||
context.setItemOrder(itemOrder);
|
||||
context.setItemOccurrence(itemOccurrence);
|
||||
|
||||
TaskNodeExecuteMessage message = new TaskNodeExecuteMessage();
|
||||
message.setInstId(instId);
|
||||
message.setTaskId(taskId);
|
||||
message.setRobotId(robotId);
|
||||
message.setRuntimeAssignments(context.getRuntimeAssignments());
|
||||
message.setRuntimeAssignments(FlowRuntimeAssignmentScope.forItem(
|
||||
context.getRuntimeAssignments(), itemId, itemOccurrence));
|
||||
message.setItemId(itemId);
|
||||
message.setItemOrder(itemOrder);
|
||||
message.setItemOccurrence(itemOccurrence);
|
||||
if (runMode.equals(RunModeEnum.VI_PROJECT)) {
|
||||
message.setLoopDetail(item.getSchemeInfo());
|
||||
}
|
||||
@ -57,7 +67,11 @@ public class FlowTaskEngine {
|
||||
int pendingItemCount = totalItemCount - i - 1;
|
||||
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)) {
|
||||
context.setRunParams(jsonObject);
|
||||
}
|
||||
|
||||
@ -31,6 +31,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
@ -147,8 +149,16 @@ public class FlowTaskRuntimeEntry implements FlowTaskRuntimeService {
|
||||
String robotId = target.getRobotId();
|
||||
assertResourcesAvailable(resources);
|
||||
// 查询任务详情(检测项信息)
|
||||
List<TeQueryTaskDetailDTO> details = taskOrchestrationService.queryTaskDetail(taskId);
|
||||
details.forEach(detail -> runtimeDefinitionValidator.validate(detail.getFlowData(), resources.assignments()));
|
||||
List<TeQueryTaskDetailDTO> details = new ArrayList<>(taskOrchestrationService.queryTaskDetail(taskId));
|
||||
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();
|
||||
if (count > 0) {
|
||||
throw new GlobalException("当前任务存在未发布的检测项!");
|
||||
|
||||
@ -51,6 +51,12 @@ public class FlowExecutionEvent {
|
||||
*/
|
||||
private String itemId;
|
||||
|
||||
/** 当前检测项在本次任务中的实际执行位置,从 1 开始。 */
|
||||
private Integer itemOrder;
|
||||
|
||||
/** 同一检测项在本次任务中第几次出现,从 1 开始。 */
|
||||
private Integer itemOccurrence;
|
||||
|
||||
/**
|
||||
* 当前节点ID,节点级事件时有值。
|
||||
*/
|
||||
|
||||
@ -57,6 +57,8 @@ public class FlowAfterInterceptor extends AbstractFlowMsgPreInterceptor{
|
||||
.taskId(message.getTaskId())
|
||||
.moduleCode(moduleCode)
|
||||
.itemId(message.getItemId())
|
||||
.itemOrder(message.getItemOrder())
|
||||
.itemOccurrence(message.getItemOccurrence())
|
||||
.nodeId(message.getNodeId())
|
||||
.nodeCount(message.getGraph().allNodeIds().size())
|
||||
.nodeType(message.getNodeType())
|
||||
|
||||
@ -40,6 +40,8 @@ public class FlowBeforeInterceptor extends AbstractFlowMsgPreInterceptor{
|
||||
.taskId(message.getTaskId())
|
||||
.moduleCode(moduleCode)
|
||||
.itemId(message.getItemId())
|
||||
.itemOrder(message.getItemOrder())
|
||||
.itemOccurrence(message.getItemOccurrence())
|
||||
.nodeId(message.getNodeId())
|
||||
.nodeCount(message.getGraph().allNodeIds().size())
|
||||
.nodeName(message.getNodeName())
|
||||
|
||||
@ -30,6 +30,12 @@ public class TaskNodeExecuteMessage {
|
||||
*/
|
||||
private String itemId;
|
||||
|
||||
/** 当前检测项在本次任务中的实际执行位置,从 1 开始。 */
|
||||
private int itemOrder;
|
||||
|
||||
/** 同一检测项在本次任务中第几次出现,从 1 开始。 */
|
||||
private int itemOccurrence;
|
||||
|
||||
/**
|
||||
* 节点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.system.service.ISysFileService;
|
||||
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.context.TaskInstHolder;
|
||||
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
|
||||
@ -17,7 +18,13 @@ import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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
|
||||
@RequiredArgsConstructor
|
||||
@ -32,15 +39,6 @@ public class EdgeCameraOperateService implements EdgeOperateService {
|
||||
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
|
||||
public TaskNodeExecuteResult execute(TaskNodeExecuteMessage message) {
|
||||
// try {
|
||||
@ -48,8 +46,6 @@ public class EdgeCameraOperateService implements EdgeOperateService {
|
||||
// } catch (InterruptedException e) {
|
||||
// throw new RuntimeException(e);
|
||||
// }
|
||||
String instId = message.getInstId();
|
||||
String nodeId = message.getNodeId();
|
||||
ActionEnum action = message.getAction();
|
||||
JSONObject inputParams = message.getInputParams();
|
||||
String deviceId = inputParams.getString("deviceId");
|
||||
@ -64,31 +60,19 @@ public class EdgeCameraOperateService implements EdgeOperateService {
|
||||
|
||||
switch (action) {
|
||||
case CAMERA_START: {
|
||||
// String result = edgeCameraService.start(robotId, deviceId);
|
||||
String result = StrUtil.format("id为 [{}] 的相机开启了", deviceId);
|
||||
String result = edgeCameraService.start(edgeCommonVO);
|
||||
output.put("result", result);
|
||||
break;
|
||||
}
|
||||
|
||||
case CAMERA_STOP: {
|
||||
String result = StrUtil.format("id为 [{}] 的相机关闭了", deviceId);
|
||||
// String result = edgeCameraService.stop(robotId, deviceId);
|
||||
String result = edgeCameraService.stop(edgeCommonVO);
|
||||
output.put("result", result);
|
||||
break;
|
||||
}
|
||||
|
||||
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();
|
||||
if (ObjUtil.isNotEmpty(upstreamOutput)) {
|
||||
@ -99,51 +83,43 @@ public class EdgeCameraOperateService implements EdgeOperateService {
|
||||
}
|
||||
|
||||
imageUrls.add(imageUrl);
|
||||
output.put("imageUrl", imageUrl);
|
||||
output.put("imageUrl", imageUrls);
|
||||
output.put("type", FileType.IMAGE.code());
|
||||
break;
|
||||
}
|
||||
|
||||
case CAMERA_RECORDING_START: {
|
||||
// 生成视频路径
|
||||
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 来保存视频路径和其他信息
|
||||
String recordingId = edgeCameraService.startWorkflowRecording(edgeCommonVO);
|
||||
JSONObject videoInfo = new JSONObject();
|
||||
videoInfo.put("videoUrl", videoUrl);
|
||||
videoInfo.put("isPlay", false); // 初始状态为不可播放
|
||||
videoInfo.put("type", FileType.VIDEO.code()); // 文件类型为视频
|
||||
|
||||
// 将视频路径数组保存到 output 中
|
||||
videoInfo.put("recordingId", recordingId);
|
||||
videoInfo.put("robotId", robotId);
|
||||
videoInfo.put("deviceId", deviceId);
|
||||
videoInfo.put("isPlay", false);
|
||||
videoInfo.put("type", FileType.VIDEO.code());
|
||||
output.put("videoInfo", videoInfo);
|
||||
break;
|
||||
}
|
||||
|
||||
case CAMERA_RECORDING_STOP: {
|
||||
// edgeCameraService.stopRecording(edgeCommonVO);
|
||||
// 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 preStart = findRecordingStartOutput(message, robotId, deviceId);
|
||||
JSONObject videoInfo = preStart.getJSONObject("videoInfo");
|
||||
videoInfo.put("isPlay", true);
|
||||
// String videoUrl = videoInfo.getString("videoUrl");
|
||||
// String url = sysFileService.uploadFile(new File(videoUrl), FileType.VIDEO.code());
|
||||
// videoInfo.put("videoUrl", url);
|
||||
String videoUrl = VIDEO_URLS[currentIndex.getAndUpdate(i -> (i + 1) % VIDEO_URLS.length)];
|
||||
videoInfo.put("videoUrl", videoUrl);
|
||||
// 将视频路径数组保存到 output 中
|
||||
output.put("videoUrl", videoInfo);
|
||||
String recordingId = videoInfo.getString("recordingId");
|
||||
if (StrUtil.isBlank(recordingId)) {
|
||||
throw new GlobalException("开始录像节点缺少录像会话信息,请重新执行流程");
|
||||
}
|
||||
|
||||
File videoFile = edgeCameraService.stopWorkflowRecording(edgeCommonVO, recordingId);
|
||||
try {
|
||||
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;
|
||||
}
|
||||
default:
|
||||
@ -152,4 +128,44 @@ public class EdgeCameraOperateService implements EdgeOperateService {
|
||||
|
||||
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;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.cmvr.common.enums.FileType;
|
||||
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.system.service.ISysFileService;
|
||||
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.context.TaskInstHolder;
|
||||
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
|
||||
@ -14,7 +17,13 @@ import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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
|
||||
@RequiredArgsConstructor
|
||||
@ -29,19 +38,8 @@ public class EdgeMicrophoneOperateService implements EdgeOperateService {
|
||||
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
|
||||
public TaskNodeExecuteResult execute(TaskNodeExecuteMessage message) {
|
||||
String instId = message.getInstId();
|
||||
String nodeId = message.getNodeId();
|
||||
ActionEnum action = message.getAction();
|
||||
JSONObject inputParams = message.getInputParams();
|
||||
String deviceId = inputParams.getString("deviceId");
|
||||
@ -53,45 +51,40 @@ public class EdgeMicrophoneOperateService implements EdgeOperateService {
|
||||
edgeCommonVO.setRobotId(robotId);
|
||||
switch (action) {
|
||||
case MICROPHONE_START: {
|
||||
// String audioPath = edgeMicrophoneService.startRecord(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 来保存音频路径和其他信息
|
||||
String recordingId = edgeMicrophoneService.startWorkflowRecording(edgeCommonVO);
|
||||
JSONObject audioInfo = new JSONObject();
|
||||
audioInfo.put("audioUrl", audioPath);
|
||||
audioInfo.put("isPlay", false); // 初始状态为不可播放
|
||||
audioInfo.put("recordingId", recordingId);
|
||||
audioInfo.put("robotId", robotId);
|
||||
audioInfo.put("deviceId", deviceId);
|
||||
audioInfo.put("isPlay", false);
|
||||
audioInfo.put("type", FileType.AUDIO.code());
|
||||
|
||||
output.put("audioInfo", audioInfo);
|
||||
break;
|
||||
}
|
||||
|
||||
case MICROPHONE_STOP: {
|
||||
// edgeMicrophoneService.stopRecord(edgeCommonVO);
|
||||
// 获取视频录制开始节点的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 preStart = findRecordingStartOutput(message, robotId, deviceId);
|
||||
JSONObject audioInfo = preStart.getJSONObject("audioInfo");
|
||||
audioInfo.put("isPlay", true);
|
||||
// String audioUrl = audioInfo.getString("audioUrl");
|
||||
// String url = sysFileService.uploadFile(new File(audioUrl), FileType.AUDIO.code());
|
||||
// audioInfo.put("audioUrl", url);
|
||||
String audioUrl = AUDIO_URLS[currentIndex.getAndUpdate(i -> (i + 1) % AUDIO_URLS.length)];
|
||||
audioInfo.put("audioUrl", audioUrl);
|
||||
// 将视频路径数组保存到 output 中
|
||||
output.put("audioUrl", audioInfo);
|
||||
String recordingId = audioInfo.getString("recordingId");
|
||||
if (StrUtil.isBlank(recordingId)) {
|
||||
throw new GlobalException("开始录音节点缺少录音会话信息,请重新执行流程");
|
||||
}
|
||||
|
||||
File audioFile = edgeMicrophoneService.stopWorkflowRecording(edgeCommonVO, recordingId);
|
||||
try {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -101,4 +94,44 @@ public class EdgeMicrophoneOperateService implements EdgeOperateService {
|
||||
|
||||
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.flow.runtime.message.TaskNodeExecuteMessage;
|
||||
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.service.ex.ExTiVehicleFunctionService;
|
||||
import com.cmvr.test.service.ShangdaoFileService;
|
||||
@ -74,7 +75,7 @@ public class TiTouchOperateService implements EdgeOperateService {
|
||||
}
|
||||
case TI_TOUCH_COORDINATES: {
|
||||
// 页面图片(相机)
|
||||
String imageUrl = inputParams.getString("imageUrl");
|
||||
String imageUrl = FlowMediaParamResolver.lastUrl(inputParams, "imageUrl");
|
||||
String pageImageUrl = shangdaoFileService.uploadFromMinio(imageUrl);
|
||||
|
||||
// icon图片
|
||||
|
||||
@ -8,6 +8,7 @@ import com.cmvr.llm.service.LLMAiAgentPlatformService;
|
||||
import com.cmvr.test.enums.ActionEnum;
|
||||
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 com.cmvr.test.model.vo.inspection.InspectionAlarmRuleVO;
|
||||
import com.cmvr.test.model.vo.inspection.InspectionMeterRecognizeConfigVO;
|
||||
import com.cmvr.test.service.ShangdaoFileService;
|
||||
@ -56,8 +57,10 @@ public class InspectionMeterRecognizeOperateService implements LLMOperateService
|
||||
public TaskNodeExecuteResult execute(TaskNodeExecuteMessage message)
|
||||
{
|
||||
JSONObject input = message.getInputParams() == null ? new JSONObject() : message.getInputParams();
|
||||
InspectionMeterRecognizeConfigVO config = input.to(InspectionMeterRecognizeConfigVO.class);
|
||||
String imageUrl = StringUtils.trimToNull(config.getImageUrl());
|
||||
String imageUrl = FlowMediaParamResolver.lastUrl(input, "imageUrl");
|
||||
JSONObject normalizedInput = new JSONObject(input);
|
||||
normalizedInput.put("imageUrl", imageUrl);
|
||||
InspectionMeterRecognizeConfigVO config = normalizedInput.to(InspectionMeterRecognizeConfigVO.class);
|
||||
String resultName = StringUtils.defaultIfBlank(config.getResultName(), "仪表读数");
|
||||
String configuredUnit = StringUtils.trimToNull(config.getUnit());
|
||||
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.flow.runtime.message.TaskNodeExecuteMessage;
|
||||
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
|
||||
import com.cmvr.test.flow.runtime.operator.FlowMediaParamResolver;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -32,7 +33,7 @@ public class LLMCurrentPageOperateService implements LLMOperateService {
|
||||
public TaskNodeExecuteResult execute(TaskNodeExecuteMessage message) {
|
||||
JSONObject inputParams = message.getInputParams();
|
||||
|
||||
String fileUrl = inputParams.getString("url"); // 原图片地址
|
||||
String fileUrl = FlowMediaParamResolver.lastUrl(inputParams, "url");
|
||||
String targetUrl = apiProperties.getCurrentPage(); // 目标接口地址
|
||||
|
||||
File tempFile = null;
|
||||
@ -85,4 +86,4 @@ public class LLMCurrentPageOperateService implements LLMOperateService {
|
||||
}
|
||||
return cleanUrl.substring(cleanUrl.lastIndexOf("/") + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import com.cmvr.test.enums.ActionEnum;
|
||||
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 lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -59,8 +60,7 @@ public class LLMTouchOperateService implements LLMOperateService {
|
||||
// JSONArray imageUrl = pre.getJSONArray("imageUrl");
|
||||
// String url = imageUrl.getString(imageUrl.size() - 1);
|
||||
|
||||
// 直接从 inputParams 获取上游相机的输出(单个 imageUrl)
|
||||
String url = inputParams.getString("imageUrl");
|
||||
String url = FlowMediaParamResolver.lastUrl(inputParams, "imageUrl");
|
||||
if (StrUtil.isEmpty(url)) {
|
||||
throw new IllegalStateException("未找到上游相机输出的图片地址");
|
||||
}
|
||||
@ -97,4 +97,4 @@ public class LLMTouchOperateService implements LLMOperateService {
|
||||
|
||||
return TaskNodeExecuteResult.success(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,8 +2,17 @@ package com.cmvr.test.model.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class FlowiseStartRequestVO {
|
||||
|
||||
private String question;
|
||||
|
||||
private String taskId;
|
||||
|
||||
private String robotId;
|
||||
|
||||
private List<ResourceRuntimeAssignmentVO> runtimeAssignments = new ArrayList<>();
|
||||
}
|
||||
|
||||
@ -14,6 +14,15 @@ public class ResourceRuntimeAssignmentVO {
|
||||
@ApiModelProperty("流程内稳定的机器人角色标识")
|
||||
private String roleKey;
|
||||
|
||||
@ApiModelProperty("任务级安排对应的流程内原始角色标识")
|
||||
private String sourceRoleKey;
|
||||
|
||||
@ApiModelProperty("任务级安排对应的检测项ID;为空表示适用于整个任务")
|
||||
private String itemId;
|
||||
|
||||
@ApiModelProperty("同一检测项在任务编排中的出现序号,从1开始")
|
||||
private Integer itemOccurrence;
|
||||
|
||||
@ApiModelProperty("本次执行绑定的机器人成员")
|
||||
private List<RobotRuntimeMemberVO> members = new ArrayList<>();
|
||||
}
|
||||
|
||||
@ -28,7 +28,8 @@ public class ITeNodeInstServiceImpl extends ServiceImpl<TeNodeInstMapper, TeNode
|
||||
LambdaQueryWrapper<TeNodeInst> wrapper = Wrappers.lambdaQuery();
|
||||
wrapper.eq(TeNodeInst::getInstId, flowViewVO.getInstId())
|
||||
.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);
|
||||
}
|
||||
|
||||
|
||||
@ -45,7 +45,8 @@ public class TeTaskOrchestrationServiceImpl extends ServiceImpl<TeTaskOrchestrat
|
||||
.selectAs(TeDetectionItem::getDetectName, TeQueryTaskOrchestraItemVO::getItemName)
|
||||
.selectAs(TeDetectionItem::getConfig, TeQueryTaskOrchestraItemVO::getConfig)
|
||||
.eq(TeTaskOrchestration::getTaskId, taskId)
|
||||
.orderByAsc(TeTaskOrchestration::getOrderNum);
|
||||
.orderByAsc(TeTaskOrchestration::getOrderNum)
|
||||
.orderByAsc(TeTaskOrchestration::getId);
|
||||
|
||||
return baseMapper.selectJoinList(TeQueryTaskOrchestraItemVO.class, wrapper);
|
||||
}
|
||||
@ -65,6 +66,9 @@ public class TeTaskOrchestrationServiceImpl extends ServiceImpl<TeTaskOrchestrat
|
||||
deleteByTaskId(taskOrchestraVO.getTaskId());
|
||||
|
||||
List<String> itemIds = taskOrchestraVO.getItemIds();
|
||||
if (itemIds == null || itemIds.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
List<TeTaskOrchestration> list = new ArrayList<>();
|
||||
for (int i = 0; i < itemIds.size(); i++) {
|
||||
@ -112,7 +116,8 @@ public class TeTaskOrchestrationServiceImpl extends ServiceImpl<TeTaskOrchestrat
|
||||
.select(TeDetectionItem::getFlowData)
|
||||
.eq(TeTaskOrchestration::getTaskId, taskId)
|
||||
.leftJoin(TeDetectionItem.class, TeDetectionItem::getId, TeTaskOrchestration::getItemId)
|
||||
.orderByAsc(TeTaskOrchestration::getOrderNum);
|
||||
.orderByAsc(TeTaskOrchestration::getOrderNum)
|
||||
.orderByAsc(TeTaskOrchestration::getId);
|
||||
|
||||
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',
|
||||
`test_case_id` varchar(64) NOT NULL COMMENT '测试用例ID',
|
||||
`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_test_case_id` (`test_case_id`)
|
||||
) 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',
|
||||
`waypoint_id` varchar(64) NOT NULL COMMENT '点位ID',
|
||||
`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_waypoint_id` (`waypoint_id`)
|
||||
) 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