feat(audio): 实现实时语音会话功能支持设备分离和音频缓冲优化

- 配置Tomcat支持20MB大JSON请求处理PPE报警中的Base64图片
- 扩展AudioService支持独立的扬声器和麦克风设备ID参数处理
- 实现浏览器音频推送器,提供音频帧缓冲和定时推送机制
- 添加音频数据protobuf定义,支持多种音频格式和编解码参数
- 重构音频会话管理,实现音频队列、统计监控和性能优化
- 增强心跳检测机制,改进机器人离线状态处理逻辑
This commit is contained in:
lixiaolong 2026-07-22 13:49:05 +08:00
parent 152cae0323
commit ecda2b0ebc
37 changed files with 6966 additions and 12644 deletions

View File

@ -0,0 +1,123 @@
package com.cmvr.web.controller.inspection;
import com.cmvr.common.annotation.Anonymous;
import com.cmvr.inspection.domain.dto.alert.AlertEnvelope;
import com.cmvr.inspection.exception.DetectionAlertBadRequestException;
import com.cmvr.inspection.service.IInspectionDetectionAlertService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* cmvr_edge_ai 检测报警推送入口
*
* 该接口由边缘服务主动调用使用HTTP状态码表达处理结果不使用项目通用AjaxResult包装
*/
@Slf4j
@Anonymous
@RestController
@RequiredArgsConstructor
@Api(tags = "智能巡检--PPE报警接收")
public class InspectionDetectionAlertController
{
/** 包含Base64图片的JSON请求最大允许20MB防止异常请求耗尽服务端内存。 */
private static final int MAX_REQUEST_BYTES = 20 * 1024 * 1024;
private final IInspectionDetectionAlertService detectionAlertService;
private final ObjectMapper objectMapper;
/**
* 接收PPE违规报警重复事件视为已处理仍返回204避免边缘端持续重试
*/
@ApiOperation("接收边缘AI的PPE违规报警")
@PostMapping(value = "/v1/detection-alerts", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> receive(
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
HttpServletRequest request)
{
try
{
String requestBody = readRequestBody(request);
AlertEnvelope envelope = objectMapper.readValue(requestBody, AlertEnvelope.class);
detectionAlertService.receive(envelope, idempotencyKey);
return ResponseEntity.noContent().build();
}
catch (JsonProcessingException | DetectionAlertBadRequestException ex)
{
log.warn("拒绝不合法的PPE报警请求idempotencyKey={},原因={}", idempotencyKey, ex.getMessage());
return ResponseEntity.badRequest().build();
}
catch (DuplicateKeyException ex)
{
// 预查与插入之间仍可能发生并发由数据库唯一索引完成最终幂等
log.info("忽略并发重复的PPE报警idempotencyKey={}", idempotencyKey);
return ResponseEntity.noContent().build();
}
catch (IOException ex)
{
log.warn("读取PPE报警请求失败idempotencyKey={},原因={}", idempotencyKey, ex.getMessage());
return ResponseEntity.badRequest().build();
}
catch (Exception ex)
{
log.error("PPE报警处理暂时失败idempotencyKey={}", idempotencyKey, ex);
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build();
}
}
private String readRequestBody(HttpServletRequest request) throws IOException
{
long contentLength = request.getContentLengthLong();
if (contentLength > MAX_REQUEST_BYTES)
{
throw new DetectionAlertBadRequestException("请求体不能超过20MB");
}
try (ServletInputStream inputStream = request.getInputStream();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream(initialCapacity(contentLength)))
{
byte[] buffer = new byte[8192];
int total = 0;
int length;
while ((length = inputStream.read(buffer)) != -1)
{
total += length;
if (total > MAX_REQUEST_BYTES)
{
throw new DetectionAlertBadRequestException("请求体不能超过20MB");
}
outputStream.write(buffer, 0, length);
}
if (total == 0)
{
throw new DetectionAlertBadRequestException("请求体不能为空");
}
return new String(outputStream.toByteArray(), StandardCharsets.UTF_8);
}
}
private int initialCapacity(long contentLength)
{
if (contentLength <= 0 || contentLength > MAX_REQUEST_BYTES)
{
return 8192;
}
return (int) contentLength;
}
}

View File

@ -21,6 +21,9 @@ server:
tomcat:
# tomcat的URI编码
uri-encoding: UTF-8
# PPE报警包含Base64图片允许读取较大的JSON请求同时限制异常请求的吞入大小
max-http-form-post-size: 20MB
max-swallow-size: 20MB
# 连接数满后的排队数默认为100
accept-count: 1000
threads:

View File

@ -47,8 +47,10 @@ public class GrpcServiceManager {
clientFactories.put(SystemServiceGrpc.SystemServiceBlockingStub.class, new GrpcClientFactory<>(SystemServiceGrpc::newBlockingStub));
// 注册扬声器服务的 Stub
clientFactories.put(SpeakerServiceGrpc.SpeakerServiceBlockingStub.class, new GrpcClientFactory<>(SpeakerServiceGrpc::newBlockingStub));
clientFactories.put(SpeakerServiceGrpc.SpeakerServiceStub.class, new GrpcClientFactory<>(SpeakerServiceGrpc::newStub));
// 注册麦克风服务的 Stub
clientFactories.put(MicPhoneServiceGrpc.MicPhoneServiceBlockingStub.class, new GrpcClientFactory<>(MicPhoneServiceGrpc::newBlockingStub));
clientFactories.put(MicPhoneServiceGrpc.MicPhoneServiceStub.class, new GrpcClientFactory<>(MicPhoneServiceGrpc::newStub));
// 注册机器人头部服务的 Stub
clientFactories.put(BioHeadServiceGrpc.BioHeadServiceBlockingStub.class, new GrpcClientFactory<>(BioHeadServiceGrpc::newBlockingStub));
// 注册机器人灵巧手服务的 Stub
@ -60,7 +62,6 @@ public class GrpcServiceManager {
// 注册agv服务的stub
clientFactories.put(AgvServiceGrpc.AgvServiceBlockingStub.class, new GrpcClientFactory<>(AgvServiceGrpc::newBlockingStub));
// 注册机器人实时语音双向流服务的异步Stub
clientFactories.put(RobotAudioServiceGrpc.RobotAudioServiceStub.class, new GrpcClientFactory<>(RobotAudioServiceGrpc::newStub));
}

View File

@ -21,6 +21,12 @@ import java.nio.ByteBuffer;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
/**
@ -40,6 +46,12 @@ public class AudioService implements RobotAudioStreamListener {
/** 机器人心跳超时时间:机器人每 1 秒上报一次,超过 5 秒判定离线。 */
private static final long ROBOT_HEARTBEAT_TIMEOUT_MS = 5000L;
private static final int BROWSER_AUDIO_FRAME_BYTES = 960;
private static final int BROWSER_AUDIO_FRAME_MS = 10;
private static final int TARGET_BROWSER_AUDIO_QUEUE_FRAMES = 4;
private static final int MAX_BROWSER_AUDIO_CATCH_UP_FRAMES = 4;
private static final int WARN_BROWSER_AUDIO_QUEUE_FRAMES = 100;
private static final long BROWSER_AUDIO_PUSH_INTERVAL_MS = 5L;
private final RobotAudioGrpcAdapter robotAudioGrpcAdapter;
@ -166,13 +178,25 @@ public class AudioService implements RobotAudioStreamListener {
private void handleJoin(WebSocketSession webSocketSession, JsonNode jsonNode) {
String terminalId = getText(jsonNode, "terminalId");
String deviceId = getText(jsonNode, "deviceId");
String speakerDeviceId = getText(jsonNode, "speakerDeviceId");
String micDeviceId = getText(jsonNode, "micDeviceId");
String operatorId = getText(jsonNode, "operatorId");
String sessionId = getText(jsonNode, "sessionId");
if (terminalId == null || deviceId == null) {
sendError(webSocketSession, sessionId, "terminalId和deviceId不能为空");
if (speakerDeviceId == null) {
speakerDeviceId = deviceId;
}
if (micDeviceId == null) {
micDeviceId = deviceId;
}
if (terminalId == null || speakerDeviceId == null || micDeviceId == null) {
sendError(webSocketSession, sessionId, "terminalId、speakerDeviceId、micDeviceId不能为空");
return;
}
if (deviceId == null) {
deviceId = speakerDeviceId;
}
if (sessionId == null) {
sessionId = IdUtils.fastSimpleUUID();
}
@ -191,24 +215,28 @@ public class AudioService implements RobotAudioStreamListener {
session.sessionId = sessionId;
session.terminalId = terminalId;
session.deviceId = deviceId;
session.speakerDeviceId = speakerDeviceId;
session.micDeviceId = micDeviceId;
session.operatorId = operatorId;
session.webSocketSession = webSocketSession;
session.state = AudioSessionState.CONNECTING;
session.createdAt = System.currentTimeMillis();
session.lastBrowserSignalAt = session.createdAt;
session.lastRobotHeartbeatAt = session.createdAt;
startBrowserAudioPusher(session);
sessionMap.put(sessionId, session);
webSocketSessionMap.put(webSocketSession.getId(), sessionId);
try {
session.robotStream = robotAudioGrpcAdapter.openStream(terminalId, deviceId, sessionId, operatorId, this);
session.robotStream = robotAudioGrpcAdapter.openStream(terminalId, speakerDeviceId, micDeviceId, sessionId, operatorId, this);
} catch (Exception e) {
sessionMap.remove(sessionId);
webSocketSessionMap.remove(webSocketSession.getId());
stopBrowserAudioPusher(session);
throw e;
}
sendState(session, "joined");
log.info("实时语音会话创建成功sessionId={}terminalId={}deviceId={}", sessionId, terminalId, deviceId);
log.info("实时语音会话创建成功sessionId={}terminalId={}speakerDeviceId={}micDeviceId={}", sessionId, terminalId, speakerDeviceId, micDeviceId);
} catch (Exception e) {
log.error("创建实时语音会话失败sessionId={}", sessionId, e);
sendError(webSocketSession, sessionId, "创建实时语音会话失败:" + e.getMessage());
@ -294,6 +322,7 @@ public class AudioService implements RobotAudioStreamListener {
try {
session.state = AudioSessionState.CLOSED;
removeWebSocketMapping(session);
stopBrowserAudioPusher(session);
if (session.robotStream != null) {
session.robotStream.sendStop(reason);
}
@ -318,12 +347,13 @@ public class AudioService implements RobotAudioStreamListener {
continue;
}
if (now - session.lastRobotHeartbeatAt > ROBOT_HEARTBEAT_TIMEOUT_MS) {
log.warn("机器人心跳超时sessionId={}terminalId={}deviceId={}",
session.sessionId, session.terminalId, session.deviceId);
log.warn("机器人心跳超时sessionId={}terminalId={}speakerDeviceId={}micDeviceId={}",
session.sessionId, session.terminalId, session.speakerDeviceId, session.micDeviceId);
// 先从业务会话表摘除避免 cancel/close 触发的异步回调再次进入同一个会话并重复关闭 native 对象
iterator.remove();
session.state = AudioSessionState.ROBOT_OFFLINE;
sendState(session, "robotOffline");
stopBrowserAudioPusher(session);
if (session.robotStream != null) {
session.robotStream.cancel("robot heartbeat timeout");
}
@ -359,7 +389,7 @@ public class AudioService implements RobotAudioStreamListener {
}
session.lastRobotAudioAt = System.currentTimeMillis();
session.lastRobotHeartbeatAt = session.lastRobotAudioAt;
sendBinary(session.webSocketSession, pcm);
enqueueRobotAudioForBrowser(session, pcm);
Consumer<byte[]> consumer = session.robotAudioConsumer;
if (consumer != null) {
try {
@ -412,12 +442,14 @@ public class AudioService implements RobotAudioStreamListener {
sendState(session, "grpcCompleted");
}
sessionMap.remove(sessionId);
stopBrowserAudioPusher(session);
removeWebSocketMapping(session);
}
private void failSession(AudioSession session, String reason, boolean cancelRobotStream) {
sessionMap.remove(session.sessionId, session);
session.state = AudioSessionState.FAILED;
stopBrowserAudioPusher(session);
ObjectNode event = baseMessage("error", session.sessionId);
event.put("message", reason == null ? "未知错误" : reason);
event.put("state", session.state.name());
@ -436,6 +468,8 @@ public class AudioService implements RobotAudioStreamListener {
ObjectNode event = baseMessage(type, session.sessionId);
event.put("terminalId", session.terminalId);
event.put("deviceId", session.deviceId);
event.put("speakerDeviceId", session.speakerDeviceId);
event.put("micDeviceId", session.micDeviceId);
event.put("state", session.state.name());
event.put("createdAt", session.createdAt);
event.put("lastRobotHeartbeatAt", session.lastRobotHeartbeatAt);
@ -462,6 +496,108 @@ public class AudioService implements RobotAudioStreamListener {
sendText(session, toJson(event));
}
private void startBrowserAudioPusher(AudioSession session) {
if (session == null || session.robotBrowserAudioExecutor != null) {
return;
}
session.robotBrowserAudioExecutor = Executors.newSingleThreadScheduledExecutor(
new NamedThreadFactory("robot-browser-audio-" + session.sessionId));
session.robotBrowserAudioFuture = session.robotBrowserAudioExecutor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
try {
int queued = session.robotBrowserAudioQueue.size();
int framesToSend = queued > TARGET_BROWSER_AUDIO_QUEUE_FRAMES
? Math.min(MAX_BROWSER_AUDIO_CATCH_UP_FRAMES, queued - TARGET_BROWSER_AUDIO_QUEUE_FRAMES + 1)
: 1;
for (int i = 0; i < framesToSend; i++) {
byte[] frame = session.robotBrowserAudioQueue.poll();
if (frame == null) {
break;
}
sendBinary(session.webSocketSession, frame);
session.robotBrowserAudioSent++;
}
logRobotBrowserAudioStatsIfNeeded(session, "robotAudioPush");
} catch (Exception e) {
log.error("推送机器人音频到浏览器失败sessionId={}", session.sessionId, e);
}
}
}, 0L, BROWSER_AUDIO_PUSH_INTERVAL_MS, TimeUnit.MILLISECONDS);
}
private void enqueueRobotAudioForBrowser(AudioSession session, byte[] pcm) {
if (session == null || pcm == null || pcm.length == 0) {
return;
}
synchronized (session.robotBrowserAudioLock) {
byte[] merged = new byte[session.robotBrowserRemainder.length + pcm.length];
System.arraycopy(session.robotBrowserRemainder, 0, merged, 0, session.robotBrowserRemainder.length);
System.arraycopy(pcm, 0, merged, session.robotBrowserRemainder.length, pcm.length);
int offset = 0;
while (merged.length - offset >= BROWSER_AUDIO_FRAME_BYTES) {
byte[] frame = new byte[BROWSER_AUDIO_FRAME_BYTES];
System.arraycopy(merged, offset, frame, 0, BROWSER_AUDIO_FRAME_BYTES);
offerRobotAudioFrame(session, frame);
offset += BROWSER_AUDIO_FRAME_BYTES;
}
int remain = merged.length - offset;
session.robotBrowserRemainder = new byte[remain];
if (remain > 0) {
System.arraycopy(merged, offset, session.robotBrowserRemainder, 0, remain);
}
}
logRobotBrowserAudioStatsIfNeeded(session, "robotAudioEnqueue");
}
private void offerRobotAudioFrame(AudioSession session, byte[] frame) {
session.robotBrowserAudioQueue.offer(frame);
session.robotBrowserAudioEnqueued++;
}
private void stopBrowserAudioPusher(AudioSession session) {
if (session == null) {
return;
}
ScheduledFuture<?> future = session.robotBrowserAudioFuture;
if (future != null) {
future.cancel(false);
session.robotBrowserAudioFuture = null;
}
ScheduledExecutorService executor = session.robotBrowserAudioExecutor;
if (executor != null) {
executor.shutdownNow();
session.robotBrowserAudioExecutor = null;
}
synchronized (session.robotBrowserAudioLock) {
session.robotBrowserAudioQueue.clear();
session.robotBrowserRemainder = new byte[0];
}
}
private void logRobotBrowserAudioStatsIfNeeded(AudioSession session, String source) {
long now = System.currentTimeMillis();
if (now - session.lastRobotBrowserAudioLogAt < 5000L) {
return;
}
session.lastRobotBrowserAudioLogAt = now;
int queued = session.robotBrowserAudioQueue.size();
log.info("机器人到浏览器音频统计source={}sessionId={}enqueued={}sent={}dropped={}queued={}remainderBytes={}",
source,
session.sessionId,
session.robotBrowserAudioEnqueued,
session.robotBrowserAudioSent,
session.robotBrowserAudioDropped,
queued,
session.robotBrowserRemainder.length);
if (queued > WARN_BROWSER_AUDIO_QUEUE_FRAMES) {
log.warn("机器人到浏览器音频队列积压较高sessionId={}queued={}estimatedDelayMs={},请检查前端播放消费或网络发送是否跟不上",
session.sessionId, queued, queued * BROWSER_AUDIO_FRAME_MS);
}
}
private void sendBinary(WebSocketSession session, byte[] pcm) {
if (session == null || !session.isOpen() || pcm == null || pcm.length == 0) {
return;
@ -571,10 +707,21 @@ public class AudioService implements RobotAudioStreamListener {
private String sessionId;
private String terminalId;
private String deviceId;
private String speakerDeviceId;
private String micDeviceId;
private String operatorId;
private volatile WebSocketSession webSocketSession;
private volatile RobotAudioStream robotStream;
private volatile Consumer<byte[]> robotAudioConsumer;
private volatile ScheduledExecutorService robotBrowserAudioExecutor;
private volatile ScheduledFuture<?> robotBrowserAudioFuture;
private final LinkedBlockingQueue<byte[]> robotBrowserAudioQueue = new LinkedBlockingQueue<>();
private final Object robotBrowserAudioLock = new Object();
private byte[] robotBrowserRemainder = new byte[0];
private volatile long robotBrowserAudioEnqueued;
private volatile long robotBrowserAudioSent;
private volatile long robotBrowserAudioDropped;
private volatile long lastRobotBrowserAudioLogAt;
private volatile AudioSessionState state;
private volatile long createdAt;
private volatile long lastBrowserSignalAt;
@ -588,4 +735,19 @@ public class AudioService implements RobotAudioStreamListener {
private String sdpMid;
private int sdpMLineIndex;
}
private static class NamedThreadFactory implements ThreadFactory {
private final String name;
private NamedThreadFactory(String name) {
this.name = name;
}
@Override
public Thread newThread(Runnable runnable) {
Thread thread = new Thread(runnable, name);
thread.setDaemon(true);
return thread;
}
}
}

View File

@ -170,6 +170,37 @@ public final class MicPhoneServiceGrpc {
return getResumeRecordMethod;
}
private static volatile io.grpc.MethodDescriptor<cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Request,
cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Feedback> getStreamAudioMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "StreamAudio",
requestType = cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Request.class,
responseType = cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Feedback.class,
methodType = io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
public static io.grpc.MethodDescriptor<cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Request,
cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Feedback> getStreamAudioMethod() {
io.grpc.MethodDescriptor<cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Request, cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Feedback> getStreamAudioMethod;
if ((getStreamAudioMethod = MicPhoneServiceGrpc.getStreamAudioMethod) == null) {
synchronized (MicPhoneServiceGrpc.class) {
if ((getStreamAudioMethod = MicPhoneServiceGrpc.getStreamAudioMethod) == null) {
MicPhoneServiceGrpc.getStreamAudioMethod = getStreamAudioMethod =
io.grpc.MethodDescriptor.<cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Request, cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Feedback>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "StreamAudio"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Request.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Feedback.getDefaultInstance()))
.setSchemaDescriptor(new MicPhoneServiceMethodDescriptorSupplier("StreamAudio"))
.build();
}
}
}
return getStreamAudioMethod;
}
private static volatile io.grpc.MethodDescriptor<cmvr.api.MicrophoneCommand.SetMicPhoneVolumeCommand.Request,
cmvr.api.MicrophoneCommand.SetMicPhoneVolumeCommand.Feedback> getSetVolumeMethod;
@ -281,9 +312,6 @@ public final class MicPhoneServiceGrpc {
public static abstract class MicPhoneServiceImplBase implements io.grpc.BindableService {
/**
* <pre>
* 基本控制
* </pre>
*/
public void getStatus(cmvr.api.MicrophoneCommand.GetMicStateCommand.Request request,
io.grpc.stub.StreamObserver<cmvr.api.MicrophoneCommand.GetMicStateCommand.Feedback> responseObserver) {
@ -319,9 +347,13 @@ public final class MicPhoneServiceGrpc {
}
/**
* <pre>
* 音量控制
* </pre>
*/
public void streamAudio(cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Request request,
io.grpc.stub.StreamObserver<cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Feedback> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getStreamAudioMethod(), responseObserver);
}
/**
*/
public void setVolume(cmvr.api.MicrophoneCommand.SetMicPhoneVolumeCommand.Request request,
io.grpc.stub.StreamObserver<cmvr.api.MicrophoneCommand.SetMicPhoneVolumeCommand.Feedback> responseObserver) {
@ -372,6 +404,13 @@ public final class MicPhoneServiceGrpc {
cmvr.api.MicrophoneCommand.ResumeMicRecordingCommand.Request,
cmvr.api.MicrophoneCommand.ResumeMicRecordingCommand.Feedback>(
this, METHODID_RESUME_RECORD)))
.addMethod(
getStreamAudioMethod(),
io.grpc.stub.ServerCalls.asyncServerStreamingCall(
new MethodHandlers<
cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Request,
cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Feedback>(
this, METHODID_STREAM_AUDIO)))
.addMethod(
getSetVolumeMethod(),
io.grpc.stub.ServerCalls.asyncUnaryCall(
@ -405,9 +444,6 @@ public final class MicPhoneServiceGrpc {
}
/**
* <pre>
* 基本控制
* </pre>
*/
public void getStatus(cmvr.api.MicrophoneCommand.GetMicStateCommand.Request request,
io.grpc.stub.StreamObserver<cmvr.api.MicrophoneCommand.GetMicStateCommand.Feedback> responseObserver) {
@ -448,9 +484,14 @@ public final class MicPhoneServiceGrpc {
}
/**
* <pre>
* 音量控制
* </pre>
*/
public void streamAudio(cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Request request,
io.grpc.stub.StreamObserver<cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Feedback> responseObserver) {
io.grpc.stub.ClientCalls.asyncServerStreamingCall(
getChannel().newCall(getStreamAudioMethod(), getCallOptions()), request, responseObserver);
}
/**
*/
public void setVolume(cmvr.api.MicrophoneCommand.SetMicPhoneVolumeCommand.Request request,
io.grpc.stub.StreamObserver<cmvr.api.MicrophoneCommand.SetMicPhoneVolumeCommand.Feedback> responseObserver) {
@ -482,9 +523,6 @@ public final class MicPhoneServiceGrpc {
}
/**
* <pre>
* 基本控制
* </pre>
*/
public cmvr.api.MicrophoneCommand.GetMicStateCommand.Feedback getStatus(cmvr.api.MicrophoneCommand.GetMicStateCommand.Request request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
@ -520,9 +558,14 @@ public final class MicPhoneServiceGrpc {
}
/**
* <pre>
* 音量控制
* </pre>
*/
public java.util.Iterator<cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Feedback> streamAudio(
cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Request request) {
return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
getChannel(), getStreamAudioMethod(), getCallOptions(), request);
}
/**
*/
public cmvr.api.MicrophoneCommand.SetMicPhoneVolumeCommand.Feedback setVolume(cmvr.api.MicrophoneCommand.SetMicPhoneVolumeCommand.Request request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
@ -552,9 +595,6 @@ public final class MicPhoneServiceGrpc {
}
/**
* <pre>
* 基本控制
* </pre>
*/
public com.google.common.util.concurrent.ListenableFuture<cmvr.api.MicrophoneCommand.GetMicStateCommand.Feedback> getStatus(
cmvr.api.MicrophoneCommand.GetMicStateCommand.Request request) {
@ -595,9 +635,6 @@ public final class MicPhoneServiceGrpc {
}
/**
* <pre>
* 音量控制
* </pre>
*/
public com.google.common.util.concurrent.ListenableFuture<cmvr.api.MicrophoneCommand.SetMicPhoneVolumeCommand.Feedback> setVolume(
cmvr.api.MicrophoneCommand.SetMicPhoneVolumeCommand.Request request) {
@ -619,8 +656,9 @@ public final class MicPhoneServiceGrpc {
private static final int METHODID_STOP_RECORD = 2;
private static final int METHODID_PAUSE_RECORD = 3;
private static final int METHODID_RESUME_RECORD = 4;
private static final int METHODID_SET_VOLUME = 5;
private static final int METHODID_GET_VOLUME = 6;
private static final int METHODID_STREAM_AUDIO = 5;
private static final int METHODID_SET_VOLUME = 6;
private static final int METHODID_GET_VOLUME = 7;
private static final class MethodHandlers<Req, Resp> implements
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
@ -659,6 +697,10 @@ public final class MicPhoneServiceGrpc {
serviceImpl.resumeRecord((cmvr.api.MicrophoneCommand.ResumeMicRecordingCommand.Request) request,
(io.grpc.stub.StreamObserver<cmvr.api.MicrophoneCommand.ResumeMicRecordingCommand.Feedback>) responseObserver);
break;
case METHODID_STREAM_AUDIO:
serviceImpl.streamAudio((cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Request) request,
(io.grpc.stub.StreamObserver<cmvr.api.MicrophoneCommand.StreamMicAudioCommand.Feedback>) responseObserver);
break;
case METHODID_SET_VOLUME:
serviceImpl.setVolume((cmvr.api.MicrophoneCommand.SetMicPhoneVolumeCommand.Request) request,
(io.grpc.stub.StreamObserver<cmvr.api.MicrophoneCommand.SetMicPhoneVolumeCommand.Feedback>) responseObserver);
@ -733,6 +775,7 @@ public final class MicPhoneServiceGrpc {
.addMethod(getStopRecordMethod())
.addMethod(getPauseRecordMethod())
.addMethod(getResumeRecordMethod())
.addMethod(getStreamAudioMethod())
.addMethod(getSetVolumeMethod())
.addMethod(getGetVolumeMethod())
.build();

View File

@ -25,7 +25,7 @@ public final class MicrophoneService {
java.lang.String[] descriptorData = {
"\n!cmvr/api/microphone_service.proto\022\010cmv" +
"r.api\032!cmvr/api/microphone_command.proto" +
"2\327\005\n\017MicPhoneService\022X\n\tGetStatus\022$.cmvr" +
"2\273\006\n\017MicPhoneService\022X\n\tGetStatus\022$.cmvr" +
".api.GetMicStateCommand.Request\032%.cmvr.a" +
"pi.GetMicStateCommand.Feedback\022f\n\013StartR" +
"ecord\022*.cmvr.api.StartMicRecordingComman" +
@ -38,12 +38,14 @@ public final class MicrophoneService {
"ingCommand.Feedback\022i\n\014ResumeRecord\022+.cm" +
"vr.api.ResumeMicRecordingCommand.Request" +
"\032,.cmvr.api.ResumeMicRecordingCommand.Fe" +
"edback\022d\n\tSetVolume\022*.cmvr.api.SetMicPho" +
"neVolumeCommand.Request\032+.cmvr.api.SetMi" +
"cPhoneVolumeCommand.Feedback\022d\n\tGetVolum" +
"e\022*.cmvr.api.GetMicPhoneVolumeCommand.Re" +
"quest\032+.cmvr.api.GetMicPhoneVolumeComman" +
"d.Feedbackb\006proto3"
"edback\022b\n\013StreamAudio\022\'.cmvr.api.StreamM" +
"icAudioCommand.Request\032(.cmvr.api.Stream" +
"MicAudioCommand.Feedback0\001\022d\n\tSetVolume\022" +
"*.cmvr.api.SetMicPhoneVolumeCommand.Requ" +
"est\032+.cmvr.api.SetMicPhoneVolumeCommand." +
"Feedback\022d\n\tGetVolume\022*.cmvr.api.GetMicP" +
"honeVolumeCommand.Request\032+.cmvr.api.Get" +
"MicPhoneVolumeCommand.Feedbackb\006proto3"
};
descriptor = com.google.protobuf.Descriptors.FileDescriptor
.internalBuildGeneratedFileFrom(descriptorData,

View File

@ -1,280 +0,0 @@
package cmvr.api;
import static io.grpc.MethodDescriptor.generateFullMethodName;
/**
* <pre>
* Real-time bidirectional robot audio service.
* Java backend is the gRPC client; robot terminal is the gRPC server.
* </pre>
*/
@javax.annotation.Generated(
value = "by gRPC proto compiler (version 1.52.0)",
comments = "Source: cmvr/api/robot_audio.proto")
@io.grpc.stub.annotations.GrpcGenerated
public final class RobotAudioServiceGrpc {
private RobotAudioServiceGrpc() {}
public static final String SERVICE_NAME = "cmvr.api.RobotAudioService";
// Static method descriptors that strictly reflect the proto.
private static volatile io.grpc.MethodDescriptor<cmvr.api.RobotAudio.RobotAudioRequest,
cmvr.api.RobotAudio.RobotAudioResponse> getAudioTalkMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "AudioTalk",
requestType = cmvr.api.RobotAudio.RobotAudioRequest.class,
responseType = cmvr.api.RobotAudio.RobotAudioResponse.class,
methodType = io.grpc.MethodDescriptor.MethodType.BIDI_STREAMING)
public static io.grpc.MethodDescriptor<cmvr.api.RobotAudio.RobotAudioRequest,
cmvr.api.RobotAudio.RobotAudioResponse> getAudioTalkMethod() {
io.grpc.MethodDescriptor<cmvr.api.RobotAudio.RobotAudioRequest, cmvr.api.RobotAudio.RobotAudioResponse> getAudioTalkMethod;
if ((getAudioTalkMethod = RobotAudioServiceGrpc.getAudioTalkMethod) == null) {
synchronized (RobotAudioServiceGrpc.class) {
if ((getAudioTalkMethod = RobotAudioServiceGrpc.getAudioTalkMethod) == null) {
RobotAudioServiceGrpc.getAudioTalkMethod = getAudioTalkMethod =
io.grpc.MethodDescriptor.<cmvr.api.RobotAudio.RobotAudioRequest, cmvr.api.RobotAudio.RobotAudioResponse>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.BIDI_STREAMING)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "AudioTalk"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
cmvr.api.RobotAudio.RobotAudioRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
cmvr.api.RobotAudio.RobotAudioResponse.getDefaultInstance()))
.setSchemaDescriptor(new RobotAudioServiceMethodDescriptorSupplier("AudioTalk"))
.build();
}
}
}
return getAudioTalkMethod;
}
/**
* Creates a new async stub that supports all call types for the service
*/
public static RobotAudioServiceStub newStub(io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<RobotAudioServiceStub> factory =
new io.grpc.stub.AbstractStub.StubFactory<RobotAudioServiceStub>() {
@java.lang.Override
public RobotAudioServiceStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new RobotAudioServiceStub(channel, callOptions);
}
};
return RobotAudioServiceStub.newStub(factory, channel);
}
/**
* Creates a new blocking-style stub that supports unary and streaming output calls on the service
*/
public static RobotAudioServiceBlockingStub newBlockingStub(
io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<RobotAudioServiceBlockingStub> factory =
new io.grpc.stub.AbstractStub.StubFactory<RobotAudioServiceBlockingStub>() {
@java.lang.Override
public RobotAudioServiceBlockingStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new RobotAudioServiceBlockingStub(channel, callOptions);
}
};
return RobotAudioServiceBlockingStub.newStub(factory, channel);
}
/**
* Creates a new ListenableFuture-style stub that supports unary calls on the service
*/
public static RobotAudioServiceFutureStub newFutureStub(
io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<RobotAudioServiceFutureStub> factory =
new io.grpc.stub.AbstractStub.StubFactory<RobotAudioServiceFutureStub>() {
@java.lang.Override
public RobotAudioServiceFutureStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new RobotAudioServiceFutureStub(channel, callOptions);
}
};
return RobotAudioServiceFutureStub.newStub(factory, channel);
}
/**
* <pre>
* Real-time bidirectional robot audio service.
* Java backend is the gRPC client; robot terminal is the gRPC server.
* </pre>
*/
public static abstract class RobotAudioServiceImplBase implements io.grpc.BindableService {
/**
*/
public io.grpc.stub.StreamObserver<cmvr.api.RobotAudio.RobotAudioRequest> audioTalk(
io.grpc.stub.StreamObserver<cmvr.api.RobotAudio.RobotAudioResponse> responseObserver) {
return io.grpc.stub.ServerCalls.asyncUnimplementedStreamingCall(getAudioTalkMethod(), responseObserver);
}
@java.lang.Override public final io.grpc.ServerServiceDefinition bindService() {
return io.grpc.ServerServiceDefinition.builder(getServiceDescriptor())
.addMethod(
getAudioTalkMethod(),
io.grpc.stub.ServerCalls.asyncBidiStreamingCall(
new MethodHandlers<
cmvr.api.RobotAudio.RobotAudioRequest,
cmvr.api.RobotAudio.RobotAudioResponse>(
this, METHODID_AUDIO_TALK)))
.build();
}
}
/**
* <pre>
* Real-time bidirectional robot audio service.
* Java backend is the gRPC client; robot terminal is the gRPC server.
* </pre>
*/
public static final class RobotAudioServiceStub extends io.grpc.stub.AbstractAsyncStub<RobotAudioServiceStub> {
private RobotAudioServiceStub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected RobotAudioServiceStub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new RobotAudioServiceStub(channel, callOptions);
}
/**
*/
public io.grpc.stub.StreamObserver<cmvr.api.RobotAudio.RobotAudioRequest> audioTalk(
io.grpc.stub.StreamObserver<cmvr.api.RobotAudio.RobotAudioResponse> responseObserver) {
return io.grpc.stub.ClientCalls.asyncBidiStreamingCall(
getChannel().newCall(getAudioTalkMethod(), getCallOptions()), responseObserver);
}
}
/**
* <pre>
* Real-time bidirectional robot audio service.
* Java backend is the gRPC client; robot terminal is the gRPC server.
* </pre>
*/
public static final class RobotAudioServiceBlockingStub extends io.grpc.stub.AbstractBlockingStub<RobotAudioServiceBlockingStub> {
private RobotAudioServiceBlockingStub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected RobotAudioServiceBlockingStub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new RobotAudioServiceBlockingStub(channel, callOptions);
}
}
/**
* <pre>
* Real-time bidirectional robot audio service.
* Java backend is the gRPC client; robot terminal is the gRPC server.
* </pre>
*/
public static final class RobotAudioServiceFutureStub extends io.grpc.stub.AbstractFutureStub<RobotAudioServiceFutureStub> {
private RobotAudioServiceFutureStub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected RobotAudioServiceFutureStub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new RobotAudioServiceFutureStub(channel, callOptions);
}
}
private static final int METHODID_AUDIO_TALK = 0;
private static final class MethodHandlers<Req, Resp> implements
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
io.grpc.stub.ServerCalls.ServerStreamingMethod<Req, Resp>,
io.grpc.stub.ServerCalls.ClientStreamingMethod<Req, Resp>,
io.grpc.stub.ServerCalls.BidiStreamingMethod<Req, Resp> {
private final RobotAudioServiceImplBase serviceImpl;
private final int methodId;
MethodHandlers(RobotAudioServiceImplBase serviceImpl, int methodId) {
this.serviceImpl = serviceImpl;
this.methodId = methodId;
}
@java.lang.Override
@java.lang.SuppressWarnings("unchecked")
public void invoke(Req request, io.grpc.stub.StreamObserver<Resp> responseObserver) {
switch (methodId) {
default:
throw new AssertionError();
}
}
@java.lang.Override
@java.lang.SuppressWarnings("unchecked")
public io.grpc.stub.StreamObserver<Req> invoke(
io.grpc.stub.StreamObserver<Resp> responseObserver) {
switch (methodId) {
case METHODID_AUDIO_TALK:
return (io.grpc.stub.StreamObserver<Req>) serviceImpl.audioTalk(
(io.grpc.stub.StreamObserver<cmvr.api.RobotAudio.RobotAudioResponse>) responseObserver);
default:
throw new AssertionError();
}
}
}
private static abstract class RobotAudioServiceBaseDescriptorSupplier
implements io.grpc.protobuf.ProtoFileDescriptorSupplier, io.grpc.protobuf.ProtoServiceDescriptorSupplier {
RobotAudioServiceBaseDescriptorSupplier() {}
@java.lang.Override
public com.google.protobuf.Descriptors.FileDescriptor getFileDescriptor() {
return cmvr.api.RobotAudio.getDescriptor();
}
@java.lang.Override
public com.google.protobuf.Descriptors.ServiceDescriptor getServiceDescriptor() {
return getFileDescriptor().findServiceByName("RobotAudioService");
}
}
private static final class RobotAudioServiceFileDescriptorSupplier
extends RobotAudioServiceBaseDescriptorSupplier {
RobotAudioServiceFileDescriptorSupplier() {}
}
private static final class RobotAudioServiceMethodDescriptorSupplier
extends RobotAudioServiceBaseDescriptorSupplier
implements io.grpc.protobuf.ProtoMethodDescriptorSupplier {
private final String methodName;
RobotAudioServiceMethodDescriptorSupplier(String methodName) {
this.methodName = methodName;
}
@java.lang.Override
public com.google.protobuf.Descriptors.MethodDescriptor getMethodDescriptor() {
return getServiceDescriptor().findMethodByName(methodName);
}
}
private static volatile io.grpc.ServiceDescriptor serviceDescriptor;
public static io.grpc.ServiceDescriptor getServiceDescriptor() {
io.grpc.ServiceDescriptor result = serviceDescriptor;
if (result == null) {
synchronized (RobotAudioServiceGrpc.class) {
result = serviceDescriptor;
if (result == null) {
serviceDescriptor = result = io.grpc.ServiceDescriptor.newBuilder(SERVICE_NAME)
.setSchemaDescriptor(new RobotAudioServiceFileDescriptorSupplier())
.addMethod(getAudioTalkMethod())
.build();
}
}
}
return result;
}
}

View File

@ -77,6 +77,37 @@ public final class SpeakerServiceGrpc {
return getPlayAudioMethod;
}
private static volatile io.grpc.MethodDescriptor<cmvr.api.SpeakerCommand.StreamSpeakerAudioCommand.Request,
cmvr.api.SpeakerCommand.StreamSpeakerAudioCommand.Feedback> getStreamAudioMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "StreamAudio",
requestType = cmvr.api.SpeakerCommand.StreamSpeakerAudioCommand.Request.class,
responseType = cmvr.api.SpeakerCommand.StreamSpeakerAudioCommand.Feedback.class,
methodType = io.grpc.MethodDescriptor.MethodType.CLIENT_STREAMING)
public static io.grpc.MethodDescriptor<cmvr.api.SpeakerCommand.StreamSpeakerAudioCommand.Request,
cmvr.api.SpeakerCommand.StreamSpeakerAudioCommand.Feedback> getStreamAudioMethod() {
io.grpc.MethodDescriptor<cmvr.api.SpeakerCommand.StreamSpeakerAudioCommand.Request, cmvr.api.SpeakerCommand.StreamSpeakerAudioCommand.Feedback> getStreamAudioMethod;
if ((getStreamAudioMethod = SpeakerServiceGrpc.getStreamAudioMethod) == null) {
synchronized (SpeakerServiceGrpc.class) {
if ((getStreamAudioMethod = SpeakerServiceGrpc.getStreamAudioMethod) == null) {
SpeakerServiceGrpc.getStreamAudioMethod = getStreamAudioMethod =
io.grpc.MethodDescriptor.<cmvr.api.SpeakerCommand.StreamSpeakerAudioCommand.Request, cmvr.api.SpeakerCommand.StreamSpeakerAudioCommand.Feedback>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.CLIENT_STREAMING)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "StreamAudio"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
cmvr.api.SpeakerCommand.StreamSpeakerAudioCommand.Request.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
cmvr.api.SpeakerCommand.StreamSpeakerAudioCommand.Feedback.getDefaultInstance()))
.setSchemaDescriptor(new SpeakerServiceMethodDescriptorSupplier("StreamAudio"))
.build();
}
}
}
return getStreamAudioMethod;
}
private static volatile io.grpc.MethodDescriptor<cmvr.api.SpeakerCommand.StopSpeakerCommand.Request,
cmvr.api.SpeakerCommand.StopSpeakerCommand.Feedback> getStopPlaybackMethod;
@ -281,9 +312,6 @@ public final class SpeakerServiceGrpc {
public static abstract class SpeakerServiceImplBase implements io.grpc.BindableService {
/**
* <pre>
* 基本控制
* </pre>
*/
public void getStatus(cmvr.api.SpeakerCommand.GetSpeakerStateCommand.Request request,
io.grpc.stub.StreamObserver<cmvr.api.SpeakerCommand.GetSpeakerStateCommand.Feedback> responseObserver) {
@ -297,6 +325,13 @@ public final class SpeakerServiceGrpc {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getPlayAudioMethod(), responseObserver);
}
/**
*/
public io.grpc.stub.StreamObserver<cmvr.api.SpeakerCommand.StreamSpeakerAudioCommand.Request> streamAudio(
io.grpc.stub.StreamObserver<cmvr.api.SpeakerCommand.StreamSpeakerAudioCommand.Feedback> responseObserver) {
return io.grpc.stub.ServerCalls.asyncUnimplementedStreamingCall(getStreamAudioMethod(), responseObserver);
}
/**
*/
public void stopPlayback(cmvr.api.SpeakerCommand.StopSpeakerCommand.Request request,
@ -319,9 +354,6 @@ public final class SpeakerServiceGrpc {
}
/**
* <pre>
* 音量控制
* </pre>
*/
public void setVolume(cmvr.api.SpeakerCommand.SetSpeakerVolumeCommand.Request request,
io.grpc.stub.StreamObserver<cmvr.api.SpeakerCommand.SetSpeakerVolumeCommand.Feedback> responseObserver) {
@ -351,6 +383,13 @@ public final class SpeakerServiceGrpc {
cmvr.api.SpeakerCommand.PlayAudioCommand.Request,
cmvr.api.SpeakerCommand.PlayAudioCommand.Feedback>(
this, METHODID_PLAY_AUDIO)))
.addMethod(
getStreamAudioMethod(),
io.grpc.stub.ServerCalls.asyncClientStreamingCall(
new MethodHandlers<
cmvr.api.SpeakerCommand.StreamSpeakerAudioCommand.Request,
cmvr.api.SpeakerCommand.StreamSpeakerAudioCommand.Feedback>(
this, METHODID_STREAM_AUDIO)))
.addMethod(
getStopPlaybackMethod(),
io.grpc.stub.ServerCalls.asyncUnaryCall(
@ -405,9 +444,6 @@ public final class SpeakerServiceGrpc {
}
/**
* <pre>
* 基本控制
* </pre>
*/
public void getStatus(cmvr.api.SpeakerCommand.GetSpeakerStateCommand.Request request,
io.grpc.stub.StreamObserver<cmvr.api.SpeakerCommand.GetSpeakerStateCommand.Feedback> responseObserver) {
@ -423,6 +459,14 @@ public final class SpeakerServiceGrpc {
getChannel().newCall(getPlayAudioMethod(), getCallOptions()), request, responseObserver);
}
/**
*/
public io.grpc.stub.StreamObserver<cmvr.api.SpeakerCommand.StreamSpeakerAudioCommand.Request> streamAudio(
io.grpc.stub.StreamObserver<cmvr.api.SpeakerCommand.StreamSpeakerAudioCommand.Feedback> responseObserver) {
return io.grpc.stub.ClientCalls.asyncClientStreamingCall(
getChannel().newCall(getStreamAudioMethod(), getCallOptions()), responseObserver);
}
/**
*/
public void stopPlayback(cmvr.api.SpeakerCommand.StopSpeakerCommand.Request request,
@ -448,9 +492,6 @@ public final class SpeakerServiceGrpc {
}
/**
* <pre>
* 音量控制
* </pre>
*/
public void setVolume(cmvr.api.SpeakerCommand.SetSpeakerVolumeCommand.Request request,
io.grpc.stub.StreamObserver<cmvr.api.SpeakerCommand.SetSpeakerVolumeCommand.Feedback> responseObserver) {
@ -482,9 +523,6 @@ public final class SpeakerServiceGrpc {
}
/**
* <pre>
* 基本控制
* </pre>
*/
public cmvr.api.SpeakerCommand.GetSpeakerStateCommand.Feedback getStatus(cmvr.api.SpeakerCommand.GetSpeakerStateCommand.Request request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
@ -520,9 +558,6 @@ public final class SpeakerServiceGrpc {
}
/**
* <pre>
* 音量控制
* </pre>
*/
public cmvr.api.SpeakerCommand.SetSpeakerVolumeCommand.Feedback setVolume(cmvr.api.SpeakerCommand.SetSpeakerVolumeCommand.Request request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
@ -552,9 +587,6 @@ public final class SpeakerServiceGrpc {
}
/**
* <pre>
* 基本控制
* </pre>
*/
public com.google.common.util.concurrent.ListenableFuture<cmvr.api.SpeakerCommand.GetSpeakerStateCommand.Feedback> getStatus(
cmvr.api.SpeakerCommand.GetSpeakerStateCommand.Request request) {
@ -595,9 +627,6 @@ public final class SpeakerServiceGrpc {
}
/**
* <pre>
* 音量控制
* </pre>
*/
public com.google.common.util.concurrent.ListenableFuture<cmvr.api.SpeakerCommand.SetSpeakerVolumeCommand.Feedback> setVolume(
cmvr.api.SpeakerCommand.SetSpeakerVolumeCommand.Request request) {
@ -621,6 +650,7 @@ public final class SpeakerServiceGrpc {
private static final int METHODID_RESUME_PLAYBACK = 4;
private static final int METHODID_SET_VOLUME = 5;
private static final int METHODID_GET_VOLUME = 6;
private static final int METHODID_STREAM_AUDIO = 7;
private static final class MethodHandlers<Req, Resp> implements
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
@ -677,6 +707,9 @@ public final class SpeakerServiceGrpc {
public io.grpc.stub.StreamObserver<Req> invoke(
io.grpc.stub.StreamObserver<Resp> responseObserver) {
switch (methodId) {
case METHODID_STREAM_AUDIO:
return (io.grpc.stub.StreamObserver<Req>) serviceImpl.streamAudio(
(io.grpc.stub.StreamObserver<cmvr.api.SpeakerCommand.StreamSpeakerAudioCommand.Feedback>) responseObserver);
default:
throw new AssertionError();
}
@ -730,6 +763,7 @@ public final class SpeakerServiceGrpc {
.setSchemaDescriptor(new SpeakerServiceFileDescriptorSupplier())
.addMethod(getGetStatusMethod())
.addMethod(getPlayAudioMethod())
.addMethod(getStreamAudioMethod())
.addMethod(getStopPlaybackMethod())
.addMethod(getPausePlaybackMethod())
.addMethod(getResumePlaybackMethod())

View File

@ -24,25 +24,28 @@ public final class SpeakerServiceOuterClass {
static {
java.lang.String[] descriptorData = {
"\n\036cmvr/api/speaker_service.proto\022\010cmvr.a" +
"pi\032\036cmvr/api/speaker_command.proto2\260\005\n\016S" +
"pi\032\036cmvr/api/speaker_command.proto2\234\006\n\016S" +
"peakerService\022`\n\tGetStatus\022(.cmvr.api.Ge" +
"tSpeakerStateCommand.Request\032).cmvr.api." +
"GetSpeakerStateCommand.Feedback\022T\n\tPlayA" +
"udio\022\".cmvr.api.PlayAudioCommand.Request" +
"\032#.cmvr.api.PlayAudioCommand.Feedback\022[\n" +
"\014StopPlayback\022$.cmvr.api.StopSpeakerComm" +
"and.Request\032%.cmvr.api.StopSpeakerComman" +
"d.Feedback\022^\n\rPausePlayback\022%.cmvr.api.P" +
"auseSpeakerCommand.Request\032&.cmvr.api.Pa" +
"useSpeakerCommand.Feedback\022a\n\016ResumePlay" +
"back\022&.cmvr.api.ResumeSpeakerCommand.Req" +
"uest\032\'.cmvr.api.ResumeSpeakerCommand.Fee" +
"dback\022b\n\tSetVolume\022).cmvr.api.SetSpeaker" +
"VolumeCommand.Request\032*.cmvr.api.SetSpea" +
"kerVolumeCommand.Feedback\022b\n\tGetVolume\022)" +
".cmvr.api.GetSpeakerVolumeCommand.Reques" +
"t\032*.cmvr.api.GetSpeakerVolumeCommand.Fee" +
"dbackb\006proto3"
"\032#.cmvr.api.PlayAudioCommand.Feedback\022j\n" +
"\013StreamAudio\022+.cmvr.api.StreamSpeakerAud" +
"ioCommand.Request\032,.cmvr.api.StreamSpeak" +
"erAudioCommand.Feedback(\001\022[\n\014StopPlaybac" +
"k\022$.cmvr.api.StopSpeakerCommand.Request\032" +
"%.cmvr.api.StopSpeakerCommand.Feedback\022^" +
"\n\rPausePlayback\022%.cmvr.api.PauseSpeakerC" +
"ommand.Request\032&.cmvr.api.PauseSpeakerCo" +
"mmand.Feedback\022a\n\016ResumePlayback\022&.cmvr." +
"api.ResumeSpeakerCommand.Request\032\'.cmvr." +
"api.ResumeSpeakerCommand.Feedback\022b\n\tSet" +
"Volume\022).cmvr.api.SetSpeakerVolumeComman" +
"d.Request\032*.cmvr.api.SetSpeakerVolumeCom" +
"mand.Feedback\022b\n\tGetVolume\022).cmvr.api.Ge" +
"tSpeakerVolumeCommand.Request\032*.cmvr.api" +
".GetSpeakerVolumeCommand.Feedbackb\006proto" +
"3"
};
descriptor = com.google.protobuf.Descriptors.FileDescriptor
.internalBuildGeneratedFileFrom(descriptorData,

View File

@ -29,6 +29,22 @@ message CommandHeader {
}
}
message AudioData {
enum AudioFormat {
PCM = 0;
MP3 = 1;
AAC = 2;
WAV = 3;
}
bytes data = 1;
int32 sample_rate = 2;
int32 channels = 3;
AudioFormat format = 4;
string codec = 5;
int64 pts = 6;
int32 nb_samples = 7;
}
message ConfigParam {
string param_name = 1;

View File

@ -11,6 +11,7 @@ message MicState {
int32 volume = 4;
string error_message = 5;
}
message GetMicStateCommand {
message Request {
CommandHeader.Request header = 1;
@ -21,6 +22,7 @@ message GetMicStateCommand {
MicState state = 2;
}
}
message StartMicRecordingCommand {
message Request {
CommandHeader.Request header = 1;
@ -59,10 +61,20 @@ message ResumeMicRecordingCommand {
}
}
message StreamMicAudioCommand {
message Request {
CommandHeader.Request header = 1;
}
message Feedback {
CommandHeader.Feedback header = 1;
AudioData audio = 2;
}
}
message SetMicPhoneVolumeCommand {
message Request {
CommandHeader.Request header = 1;
int32 volume = 2; // 0 ~ 100
int32 volume = 2;
}
message Feedback {
CommandHeader.Feedback header = 1;
@ -75,7 +87,6 @@ message GetMicPhoneVolumeCommand {
}
message Feedback {
CommandHeader.Feedback header = 1;
int32 volume = 2; //
int32 volume = 2;
}
}

View File

@ -4,17 +4,14 @@ import "cmvr/api/microphone_command.proto";
package cmvr.api;
service MicPhoneService {
//
rpc GetStatus(GetMicStateCommand.Request) returns (GetMicStateCommand.Feedback);
rpc StartRecord(StartMicRecordingCommand.Request) returns (StartMicRecordingCommand.Feedback);
rpc StopRecord(StopMicRecordingCommand.Request) returns (StopMicRecordingCommand.Feedback);
rpc PauseRecord(PauseMicRecordingCommand.Request) returns (PauseMicRecordingCommand.Feedback);
rpc ResumeRecord(ResumeMicRecordingCommand.Request) returns (ResumeMicRecordingCommand.Feedback);
rpc StreamAudio(StreamMicAudioCommand.Request) returns (stream StreamMicAudioCommand.Feedback);
//
rpc SetVolume(SetMicPhoneVolumeCommand.Request) returns (SetMicPhoneVolumeCommand.Feedback);
rpc GetVolume(GetMicPhoneVolumeCommand.Request) returns (GetMicPhoneVolumeCommand.Feedback);
}

View File

@ -1,81 +0,0 @@
syntax = "proto3";
import "cmvr/api/common.proto";
package cmvr.api;
// Real-time bidirectional robot audio service.
// Java backend is the gRPC client; robot terminal is the gRPC server.
service RobotAudioService {
rpc AudioTalk(stream RobotAudioRequest) returns (stream RobotAudioResponse);
}
// Fixed audio format: PCM_S16LE, 48 kHz, 16-bit, mono.
// Both directions use 10 ms frames: 480 samples, 960 bytes.
message RobotAudioFormat {
int32 sample_rate = 1;
int32 channels = 2;
int32 bits_per_sample = 3;
string encoding = 4;
}
message RobotAudioStart {
string session_id = 1;
string operator_id = 2;
RobotAudioFormat format = 3;
}
message RobotAudioFrame {
string session_id = 1;
int64 sequence = 2;
// Robot upload: capture timestamp. Browser downlink: backend send timestamp.
int64 timestamp_ms = 3;
// PCM_S16LE. Fixed 10 ms / 960 bytes at 48 kHz mono 16-bit.
bytes pcm = 4;
}
message RobotAudioHeartbeat {
string session_id = 1;
int64 timestamp_ms = 2;
}
message RobotAudioStop {
string session_id = 1;
string reason = 2;
}
message RobotAudioError {
string session_id = 1;
int32 code = 2;
string message = 3;
}
message RobotAudioStartAck {
string session_id = 1;
bool accepted = 2;
string message = 3;
}
message RobotAudioRequest {
CommandHeader.Request header = 1;
oneof payload {
RobotAudioStart start = 2;
RobotAudioFrame audio_frame = 3;
RobotAudioHeartbeat heartbeat = 4;
RobotAudioStop stop = 5;
RobotAudioError error = 6;
}
}
message RobotAudioResponse {
CommandHeader.Feedback header = 1;
oneof payload {
RobotAudioStartAck start_ack = 2;
RobotAudioFrame audio_frame = 3;
RobotAudioHeartbeat heartbeat = 4;
RobotAudioStop stop = 5;
RobotAudioError error = 6;
}
}

View File

@ -4,33 +4,15 @@ import "cmvr/api/common.proto";
package cmvr.api;
//
message AudioData {
enum AudioFormat {
PCM = 0;
MP3 = 1;
AAC = 2;
WAV = 3;
}
bytes data = 1; //
int32 sample_rate = 2; // Hz
int32 channels = 3; //
AudioFormat format = 4; //
string codec = 5; //
}
//
message SpeakerState {
bool is_initialized = 1; //
bool is_running = 2; //
bool is_initialized = 1;
bool is_running = 2;
bool is_decoding = 3;
bool is_paused = 5;
int32 volume = 6; // 0 ~ 100
string error_message = 7; //
int32 volume = 6;
string error_message = 7;
}
//
message GetSpeakerStateCommand {
message Request {
CommandHeader.Request header = 1;
@ -47,7 +29,19 @@ message PlayAudioCommand {
CommandHeader.Request header = 1;
string audio_path = 2;
}
message Feedback { CommandHeader.Feedback header = 1; }
message Feedback {
CommandHeader.Feedback header = 1;
}
}
message StreamSpeakerAudioCommand {
message Request {
CommandHeader.Request header = 1;
AudioData audio = 2;
}
message Feedback {
CommandHeader.Feedback header = 1;
}
}
message StopSpeakerCommand {
@ -80,7 +74,7 @@ message ResumeSpeakerCommand {
message SetSpeakerVolumeCommand {
message Request {
CommandHeader.Request header = 1;
int32 volume = 2; // 0 ~ 100
int32 volume = 2;
}
message Feedback {
CommandHeader.Feedback header = 1;
@ -93,7 +87,6 @@ message GetSpeakerVolumeCommand {
}
message Feedback {
CommandHeader.Feedback header = 1;
int32 volume = 2; //
int32 volume = 2;
}
}

View File

@ -4,17 +4,14 @@ import "cmvr/api/speaker_command.proto";
package cmvr.api;
service SpeakerService {
//
rpc GetStatus(GetSpeakerStateCommand.Request) returns (GetSpeakerStateCommand.Feedback);
rpc PlayAudio(PlayAudioCommand.Request) returns (PlayAudioCommand.Feedback);
rpc StreamAudio(stream StreamSpeakerAudioCommand.Request) returns (StreamSpeakerAudioCommand.Feedback);
rpc StopPlayback(StopSpeakerCommand.Request) returns (StopSpeakerCommand.Feedback);
rpc PausePlayback(PauseSpeakerCommand.Request) returns (PauseSpeakerCommand.Feedback);
rpc ResumePlayback(ResumeSpeakerCommand.Request) returns (ResumeSpeakerCommand.Feedback);
//
rpc SetVolume(SetSpeakerVolumeCommand.Request) returns (SetSpeakerVolumeCommand.Feedback);
rpc GetVolume(GetSpeakerVolumeCommand.Request) returns (GetSpeakerVolumeCommand.Feedback);
}

View File

@ -0,0 +1,52 @@
package com.cmvr.inspection.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 边缘 AI 推送的 PPE 违规报警主表实体
*/
@Data
@TableName("inspection_detection_alert")
public class InspectionDetectionAlert implements Serializable
{
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.INPUT)
private String id;
private String eventId;
private String idempotencyKey;
@TableField("schema_version")
private String schema;
private String sourceId;
@TableField("source_sequence")
private Long sequence;
private Long capturedAtNs;
private Long receivedAtNs;
private String traceId;
private String sessionId;
private String inputPort;
private String ruleId;
private String modelId;
private String modelName;
private String labelsJson;
@TableField("detection_scope")
private String scope;
private String scopeId;
private Integer hitCount;
private Double windowMs;
private Long firstSeenNs;
private Long lastSeenNs;
private Long triggeredAtNs;
private Double maxConfidence;
private String imagePath;
private String rawJson;
private Date createdAt;
}

View File

@ -0,0 +1,30 @@
package com.cmvr.inspection.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
/**
* PPE 报警检测框明细实体
*/
@Data
@TableName("inspection_detection_detail")
public class InspectionDetectionDetail implements Serializable
{
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.INPUT)
private String id;
private String alertId;
private String label;
private Double confidence;
private Double xMin;
private Double yMin;
private Double xMax;
private Double yMax;
private Long trackId;
}

View File

@ -0,0 +1,55 @@
package com.cmvr.inspection.domain.dto.alert;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.Map;
/**
* 边缘 AI 推送消息的统一信封
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class AlertEnvelope
{
/** 消息结构版本,当前固定为 DetectionAlert/v1。 */
@JsonProperty("schema")
private String schema;
/** 产生消息的视频源标识。 */
@JsonProperty("source_id")
private String sourceId;
/** 视频源内递增序号。 */
@JsonProperty("sequence")
private Long sequence;
/** 图像采集时间Unix 纳秒时间戳。 */
@JsonProperty("captured_at_ns")
private Long capturedAtNs;
/** 边缘端接收时间Unix 纳秒时间戳。 */
@JsonProperty("received_at_ns")
private Long receivedAtNs;
/** 全链路追踪标识。 */
@JsonProperty("trace_id")
private String traceId;
/** 边缘推理会话标识。 */
@JsonProperty("session_id")
private String sessionId;
/** 产生消息的输入端口。 */
@JsonProperty("input_port")
private String inputPort;
/** 扩展属性。 */
@JsonProperty("attributes")
private Map<String, Object> attributes;
/** PPE 违规报警内容。 */
@JsonProperty("payload")
private DetectionAlert payload;
}

View File

@ -0,0 +1,33 @@
package com.cmvr.inspection.domain.dto.alert;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.ToString;
/**
* 报警现场图片
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class AlertImage
{
/** 图片媒体类型,例如 image/jpeg。 */
@JsonProperty("media_type")
private String mediaType;
@JsonProperty("width")
private Integer width;
@JsonProperty("height")
private Integer height;
/** 编码方式,当前协议固定为 base64。 */
@JsonProperty("encoding")
private String encoding;
/** Base64 编码后的图片内容。 */
@JsonProperty("data")
@ToString.Exclude
private String data;
}

View File

@ -0,0 +1,25 @@
package com.cmvr.inspection.domain.dto.alert;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* 检测目标的像素坐标框
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class BoundingBox
{
@JsonProperty("x_min")
private Double xMin;
@JsonProperty("y_min")
private Double yMin;
@JsonProperty("x_max")
private Double xMax;
@JsonProperty("y_max")
private Double yMax;
}

View File

@ -0,0 +1,29 @@
package com.cmvr.inspection.domain.dto.alert;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* 单个 PPE 检测结果
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class Detection
{
/** 检测标签。 */
@JsonProperty("label")
private String label;
/** 检测置信度。 */
@JsonProperty("confidence")
private Double confidence;
/** 目标框坐标。 */
@JsonProperty("box")
private BoundingBox box;
/** 跟踪目标标识。 */
@JsonProperty("track_id")
private Long trackId;
}

View File

@ -0,0 +1,75 @@
package com.cmvr.inspection.domain.dto.alert;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
/**
* PPE 违规报警业务数据
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class DetectionAlert
{
/** 边缘端生成的全局事件标识。 */
@JsonProperty("event_id")
private String eventId;
/** 命中的规则标识。 */
@JsonProperty("rule_id")
private String ruleId;
/** 模型版本标识。 */
@JsonProperty("model_id")
private String modelId;
/** 模型显示名称。 */
@JsonProperty("model_name")
private String modelName;
/** 命中的标签集合。 */
@JsonProperty("labels")
private List<String> labels;
/** 规则统计范围。 */
@JsonProperty("scope")
private String scope;
/** 规则统计范围对应的对象标识。 */
@JsonProperty("scope_id")
private String scopeId;
/** 时间窗口内命中次数。 */
@JsonProperty("hit_count")
private Integer hitCount;
/** 规则统计窗口,单位毫秒。 */
@JsonProperty("window_ms")
private Double windowMs;
/** 首次命中时间Unix 纳秒时间戳。 */
@JsonProperty("first_seen_ns")
private Long firstSeenNs;
/** 最后命中时间Unix 纳秒时间戳。 */
@JsonProperty("last_seen_ns")
private Long lastSeenNs;
/** 触发报警时间Unix 纳秒时间戳。 */
@JsonProperty("triggered_at_ns")
private Long triggeredAtNs;
/** 本次报警中的最大置信度。 */
@JsonProperty("max_confidence")
private Double maxConfidence;
/** 报警关联的检测框。 */
@JsonProperty("detections")
private List<Detection> detections;
/** 报警现场图片,允许为空。 */
@JsonProperty("image")
private AlertImage image;
}

View File

@ -0,0 +1,17 @@
package com.cmvr.inspection.exception;
/**
* 边缘检测报警请求参数不符合协议
*/
public class DetectionAlertBadRequestException extends RuntimeException
{
public DetectionAlertBadRequestException(String message)
{
super(message);
}
public DetectionAlertBadRequestException(String message, Throwable cause)
{
super(message, cause);
}
}

View File

@ -0,0 +1,17 @@
package com.cmvr.inspection.exception;
/**
* 数据库或对象存储暂时不可用边缘端可稍后重试
*/
public class DetectionAlertTemporaryException extends RuntimeException
{
public DetectionAlertTemporaryException(String message)
{
super(message);
}
public DetectionAlertTemporaryException(String message, Throwable cause)
{
super(message, cause);
}
}

View File

@ -0,0 +1,11 @@
package com.cmvr.inspection.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.cmvr.inspection.domain.InspectionDetectionAlert;
/**
* PPE 违规报警主表 Mapper
*/
public interface InspectionDetectionAlertMapper extends BaseMapper<InspectionDetectionAlert>
{
}

View File

@ -0,0 +1,11 @@
package com.cmvr.inspection.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.cmvr.inspection.domain.InspectionDetectionDetail;
/**
* PPE 报警检测框明细 Mapper
*/
public interface InspectionDetectionDetailMapper extends BaseMapper<InspectionDetectionDetail>
{
}

View File

@ -0,0 +1,18 @@
package com.cmvr.inspection.service;
import com.cmvr.inspection.domain.dto.alert.AlertEnvelope;
/**
* 边缘 AI 检测报警接收服务
*/
public interface IInspectionDetectionAlertService
{
/**
* 接收并持久化一条 PPE 违规报警
*
* @param envelope 报警信封
* @param requestIdempotencyKey HTTP 请求头中的幂等键允许为空
* @return true 表示本次新增false 表示重复事件
*/
boolean receive(AlertEnvelope envelope, String requestIdempotencyKey);
}

View File

@ -0,0 +1,391 @@
package com.cmvr.inspection.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.cmvr.common.config.properties.MinioProperties;
import com.cmvr.common.core.minio.MinioService;
import com.cmvr.inspection.domain.InspectionDetectionAlert;
import com.cmvr.inspection.domain.InspectionDetectionDetail;
import com.cmvr.inspection.domain.dto.alert.AlertEnvelope;
import com.cmvr.inspection.domain.dto.alert.AlertImage;
import com.cmvr.inspection.domain.dto.alert.BoundingBox;
import com.cmvr.inspection.domain.dto.alert.Detection;
import com.cmvr.inspection.exception.DetectionAlertBadRequestException;
import com.cmvr.inspection.exception.DetectionAlertTemporaryException;
import com.cmvr.inspection.mapper.InspectionDetectionAlertMapper;
import com.cmvr.inspection.mapper.InspectionDetectionDetailMapper;
import com.cmvr.inspection.service.IInspectionDetectionAlertService;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
/**
* PPE 违规报警接收业务实现
*
* 数据库唯一索引是并发幂等的最终保障业务预查用于减少重复请求的无效写入
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class InspectionDetectionAlertServiceImpl implements IInspectionDetectionAlertService
{
private static final String SUPPORTED_SCHEMA = "DetectionAlert/v1";
private static final String IMAGE_ENCODING_BASE64 = "base64";
private static final String IMAGE_OBJECT_PREFIX = "inspection/detection-alerts";
private static final int MAX_IDEMPOTENCY_KEY_LENGTH = 191;
private static final int MAX_EVENT_ID_LENGTH = 128;
private static final int MAX_IMAGE_BYTES = 10 * 1024 * 1024;
private static final int MAX_BASE64_LENGTH = (MAX_IMAGE_BYTES * 4 / 3) + 8;
private final InspectionDetectionAlertMapper alertMapper;
private final InspectionDetectionDetailMapper detailMapper;
private final MinioService minioService;
private final MinioProperties minioProperties;
private final ObjectMapper objectMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public boolean receive(AlertEnvelope envelope, String requestIdempotencyKey)
{
String idempotencyKey = validateAndResolveIdempotencyKey(envelope, requestIdempotencyKey);
String eventId = trimToNull(envelope.getPayload().getEventId());
if (exists(idempotencyKey, eventId))
{
log.info("忽略重复PPE报警idempotencyKey={}eventId={}", idempotencyKey, eventId);
return false;
}
InspectionDetectionAlert alert = buildAlert(envelope, idempotencyKey);
StoredImage storedImage = null;
try
{
alertMapper.insert(alert);
AlertImage image = envelope.getPayload().getImage();
if (image != null)
{
storedImage = uploadImage(alert.getId(), image);
alert.setImagePath(storedImage.url);
if (alertMapper.updateById(alert) != 1)
{
throw new DetectionAlertTemporaryException("更新报警图片地址失败");
}
}
saveDetectionDetails(alert.getId(), envelope.getPayload().getDetections());
log.info("PPE报警接收成功alertId={}eventId={}sourceId={}",
alert.getId(), eventId, envelope.getSourceId());
return true;
}
catch (RuntimeException ex)
{
// 数据库事务无法回滚 MinIO对后续步骤失败时主动删除已上传对象
if (storedImage != null)
{
deleteUploadedImageQuietly(storedImage.objectName);
}
throw ex;
}
}
/**
* 校验协议关键字段并按请求头event_idtrace_id 的顺序生成幂等键
*/
private String validateAndResolveIdempotencyKey(AlertEnvelope envelope, String requestIdempotencyKey)
{
if (envelope == null)
{
throw new DetectionAlertBadRequestException("请求体不能为空");
}
if (!SUPPORTED_SCHEMA.equals(envelope.getSchema()))
{
throw new DetectionAlertBadRequestException("schema必须为" + SUPPORTED_SCHEMA);
}
if (envelope.getPayload() == null)
{
throw new DetectionAlertBadRequestException("payload不能为空");
}
String eventId = trimToNull(envelope.getPayload().getEventId());
if (eventId != null && eventId.length() > MAX_EVENT_ID_LENGTH)
{
throw new DetectionAlertBadRequestException("payload.event_id长度不能超过" + MAX_EVENT_ID_LENGTH);
}
String idempotencyKey = trimToNull(requestIdempotencyKey);
if (idempotencyKey == null)
{
idempotencyKey = eventId;
}
if (idempotencyKey == null)
{
idempotencyKey = trimToNull(envelope.getTraceId());
}
if (idempotencyKey == null)
{
throw new DetectionAlertBadRequestException("Idempotency-Key、payload.event_id和trace_id不能同时为空");
}
if (idempotencyKey.length() > MAX_IDEMPOTENCY_KEY_LENGTH)
{
throw new DetectionAlertBadRequestException("幂等键长度不能超过" + MAX_IDEMPOTENCY_KEY_LENGTH);
}
validateImage(envelope.getPayload().getImage());
return idempotencyKey;
}
private void validateImage(AlertImage image)
{
if (image == null)
{
return;
}
if (!IMAGE_ENCODING_BASE64.equalsIgnoreCase(trimToEmpty(image.getEncoding())))
{
throw new DetectionAlertBadRequestException("payload.image.encoding必须为base64");
}
if (StringUtils.isBlank(image.getData()))
{
throw new DetectionAlertBadRequestException("payload.image.data不能为空");
}
if (image.getData().length() > MAX_BASE64_LENGTH)
{
throw new DetectionAlertBadRequestException("报警图片不能超过10MB");
}
String mediaType = trimToNull(image.getMediaType());
if (mediaType == null || !mediaType.toLowerCase(Locale.ROOT).startsWith("image/"))
{
throw new DetectionAlertBadRequestException("payload.image.media_type必须是图片类型");
}
}
private boolean exists(String idempotencyKey, String eventId)
{
LambdaQueryWrapper<InspectionDetectionAlert> query = new LambdaQueryWrapper<>();
query.eq(InspectionDetectionAlert::getIdempotencyKey, idempotencyKey);
if (eventId != null)
{
query.or().eq(InspectionDetectionAlert::getEventId, eventId);
}
return alertMapper.selectCount(query) > 0;
}
private InspectionDetectionAlert buildAlert(AlertEnvelope envelope, String idempotencyKey)
{
com.cmvr.inspection.domain.dto.alert.DetectionAlert payload = envelope.getPayload();
InspectionDetectionAlert alert = new InspectionDetectionAlert();
alert.setId(newId());
alert.setEventId(trimToNull(payload.getEventId()));
alert.setIdempotencyKey(idempotencyKey);
alert.setSchema(envelope.getSchema());
alert.setSourceId(envelope.getSourceId());
alert.setSequence(envelope.getSequence());
alert.setCapturedAtNs(envelope.getCapturedAtNs());
alert.setReceivedAtNs(envelope.getReceivedAtNs());
alert.setTraceId(envelope.getTraceId());
alert.setSessionId(envelope.getSessionId());
alert.setInputPort(envelope.getInputPort());
alert.setRuleId(payload.getRuleId());
alert.setModelId(payload.getModelId());
alert.setModelName(payload.getModelName());
alert.setLabelsJson(writeJson(payload.getLabels(), "序列化labels失败"));
alert.setScope(payload.getScope());
alert.setScopeId(payload.getScopeId());
alert.setHitCount(payload.getHitCount());
alert.setWindowMs(payload.getWindowMs());
alert.setFirstSeenNs(payload.getFirstSeenNs());
alert.setLastSeenNs(payload.getLastSeenNs());
alert.setTriggeredAtNs(payload.getTriggeredAtNs());
alert.setMaxConfidence(payload.getMaxConfidence());
alert.setRawJson(buildSanitizedRawJson(envelope));
alert.setCreatedAt(new Date());
return alert;
}
/**
* raw_json保留协议元数据但明确移除Base64图片避免图片以另一种形式进入数据库
*/
private String buildSanitizedRawJson(AlertEnvelope envelope)
{
AlertImage image = envelope.getPayload().getImage();
String imageData = image == null ? null : image.getData();
try
{
// 避免构建JSON树时复制一份大Base64字符串finally中恢复仅供随后上传MinIO
if (image != null)
{
image.setData(null);
}
return objectMapper.writeValueAsString(envelope);
}
catch (Exception ex)
{
throw new DetectionAlertTemporaryException("序列化报警原始数据失败", ex);
}
finally
{
if (image != null)
{
image.setData(imageData);
}
}
}
private String writeJson(Object value, String errorMessage)
{
if (value == null)
{
return null;
}
try
{
return objectMapper.writeValueAsString(value);
}
catch (Exception ex)
{
throw new DetectionAlertTemporaryException(errorMessage, ex);
}
}
private StoredImage uploadImage(String alertId, AlertImage image)
{
final byte[] imageBytes;
try
{
imageBytes = Base64.getDecoder().decode(image.getData().getBytes(StandardCharsets.US_ASCII));
}
catch (IllegalArgumentException ex)
{
throw new DetectionAlertBadRequestException("payload.image.data不是有效的Base64", ex);
}
if (imageBytes.length > MAX_IMAGE_BYTES)
{
throw new DetectionAlertBadRequestException("报警图片不能超过10MB");
}
String extension = extensionFor(image.getMediaType());
String datePath = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
String objectName = IMAGE_OBJECT_PREFIX + "/" + datePath + "/" + alertId + extension;
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(imageBytes))
{
minioService.uploadStream(minioProperties.getBucketName(), objectName, inputStream,
imageBytes.length, image.getMediaType());
return new StoredImage(objectName, buildMinioUrl(objectName));
}
catch (DetectionAlertBadRequestException ex)
{
throw ex;
}
catch (Exception ex)
{
throw new DetectionAlertTemporaryException("上传报警图片到MinIO失败", ex);
}
}
private void saveDetectionDetails(String alertId, List<Detection> detections)
{
if (detections == null || detections.isEmpty())
{
return;
}
for (Detection detection : detections)
{
if (detection == null)
{
throw new DetectionAlertBadRequestException("payload.detections不能包含null元素");
}
InspectionDetectionDetail detail = new InspectionDetectionDetail();
detail.setId(newId());
detail.setAlertId(alertId);
detail.setLabel(detection.getLabel());
detail.setConfidence(detection.getConfidence());
detail.setTrackId(detection.getTrackId());
BoundingBox box = detection.getBox();
if (box != null)
{
detail.setXMin(box.getXMin());
detail.setYMin(box.getYMin());
detail.setXMax(box.getXMax());
detail.setYMax(box.getYMax());
}
detailMapper.insert(detail);
}
}
private String buildMinioUrl(String objectName)
{
String baseUrl = StringUtils.removeEnd(minioProperties.getUrl(), "/");
return baseUrl + "/" + minioProperties.getBucketName() + "/" + objectName;
}
private void deleteUploadedImageQuietly(String objectName)
{
try
{
minioService.deleteFile(minioProperties.getBucketName(), objectName);
}
catch (Exception cleanupEx)
{
log.error("回滚PPE报警时删除MinIO对象失败objectName={}", objectName, cleanupEx);
}
}
private String extensionFor(String mediaType)
{
String normalized = trimToEmpty(mediaType).toLowerCase(Locale.ROOT);
if ("image/jpeg".equals(normalized) || "image/jpg".equals(normalized))
{
return ".jpg";
}
if ("image/png".equals(normalized))
{
return ".png";
}
if ("image/webp".equals(normalized))
{
return ".webp";
}
// 未知图片类型使用无执行语义的后缀实际Content-Type仍按协议上传
return ".img";
}
private String trimToNull(String value)
{
return StringUtils.trimToNull(value);
}
private String trimToEmpty(String value)
{
return StringUtils.trimToEmpty(value);
}
private String newId()
{
return UUID.randomUUID().toString().replace("-", "");
}
private static final class StoredImage
{
private final String objectName;
private final String url;
private StoredImage(String objectName, String url)
{
this.objectName = objectName;
this.url = url;
}
}
}

View File

@ -0,0 +1,54 @@
-- PPE违规报警接收表。
-- 本脚本用于已有数据库增量部署,不会删除现有巡检数据。
CREATE TABLE IF NOT EXISTS `inspection_detection_alert` (
`id` varchar(32) NOT NULL COMMENT '主键ID',
`event_id` varchar(128) DEFAULT NULL COMMENT '边缘端事件ID',
`idempotency_key` varchar(191) NOT NULL COMMENT 'HTTP幂等键',
`schema_version` varchar(64) NOT NULL COMMENT '消息结构版本',
`source_id` varchar(128) DEFAULT NULL COMMENT '视频源标识',
`source_sequence` bigint DEFAULT NULL COMMENT '视频源内消息序号',
`captured_at_ns` bigint DEFAULT NULL COMMENT '采集时间Unix纳秒',
`received_at_ns` bigint DEFAULT NULL COMMENT '边缘端接收时间Unix纳秒',
`trace_id` varchar(128) DEFAULT NULL COMMENT '链路追踪ID',
`session_id` varchar(128) DEFAULT NULL COMMENT '边缘推理会话ID',
`input_port` varchar(128) DEFAULT NULL COMMENT '消息输入端口',
`rule_id` varchar(128) DEFAULT NULL COMMENT '命中的规则ID',
`model_id` varchar(255) DEFAULT NULL COMMENT '模型版本ID',
`model_name` varchar(255) DEFAULT NULL COMMENT '模型名称',
`labels_json` text COMMENT '命中标签JSON',
`detection_scope` varchar(64) DEFAULT NULL COMMENT '规则统计范围',
`scope_id` varchar(128) DEFAULT NULL COMMENT '统计范围对象ID',
`hit_count` int DEFAULT NULL COMMENT '窗口内命中次数',
`window_ms` decimal(12,3) DEFAULT NULL COMMENT '统计窗口毫秒',
`first_seen_ns` bigint DEFAULT NULL COMMENT '首次命中时间Unix纳秒',
`last_seen_ns` bigint DEFAULT NULL COMMENT '最后命中时间Unix纳秒',
`triggered_at_ns` bigint DEFAULT NULL COMMENT '报警触发时间Unix纳秒',
`max_confidence` decimal(8,6) DEFAULT NULL COMMENT '最大置信度',
`image_path` varchar(1000) DEFAULT NULL COMMENT 'MinIO图片URL',
`raw_json` mediumtext COMMENT '已移除Base64图片数据的原始协议JSON',
`created_at` datetime(3) NOT NULL COMMENT '平台接收时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_detection_alert_event_id` (`event_id`),
UNIQUE KEY `uk_detection_alert_idempotency_key` (`idempotency_key`),
KEY `idx_detection_alert_source_id` (`source_id`),
KEY `idx_detection_alert_rule_id` (`rule_id`),
KEY `idx_detection_alert_triggered_at` (`triggered_at_ns`),
KEY `idx_detection_alert_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='巡检PPE违规报警主表';
CREATE TABLE IF NOT EXISTS `inspection_detection_detail` (
`id` varchar(32) NOT NULL COMMENT '主键ID',
`alert_id` varchar(32) NOT NULL COMMENT '报警主表ID',
`label` varchar(128) DEFAULT NULL COMMENT '检测标签',
`confidence` decimal(8,6) DEFAULT NULL COMMENT '检测置信度',
`x_min` decimal(12,4) DEFAULT NULL COMMENT '目标框左上X坐标',
`y_min` decimal(12,4) DEFAULT NULL COMMENT '目标框左上Y坐标',
`x_max` decimal(12,4) DEFAULT NULL COMMENT '目标框右下X坐标',
`y_max` decimal(12,4) DEFAULT NULL COMMENT '目标框右下Y坐标',
`track_id` bigint DEFAULT NULL COMMENT '目标跟踪ID',
PRIMARY KEY (`id`),
KEY `idx_detection_detail_alert_id` (`alert_id`),
CONSTRAINT `fk_detection_detail_alert` FOREIGN KEY (`alert_id`)
REFERENCES `inspection_detection_alert` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='巡检PPE报警检测框明细表';