mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-28 10:03:54 +08:00
Merge pull request #3308 from CaixyPromise/feature/native-mqtt-clean-pr
feature(mqtt): 新增原生MQTT与UDP传输支持
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
# Native MQTT/UDP integration
|
||||
|
||||
The server can accept device MQTT control messages and encrypted UDP audio
|
||||
without running `xiaozhi-mqtt-gateway`. Native MQTT is optional. The existing
|
||||
Gateway path and direct WebSocket path remain supported.
|
||||
|
||||
## Compatibility modes
|
||||
|
||||
| Mode | Device control | Device audio | Server business runtime |
|
||||
| --- | --- | --- | --- |
|
||||
| Direct WebSocket | WebSocket JSON | WebSocket binary | Shared runtime |
|
||||
| MQTT Gateway | MQTT via Gateway WebSocket | UDP via Gateway WebSocket | Shared runtime |
|
||||
| Native MQTT | MQTT 3.1.1 QoS 0 | AES-128-CTR UDP | Shared runtime |
|
||||
|
||||
Native MQTT keeps the physical MQTT connection alive between conversations.
|
||||
Each device `hello` creates a new logical conversation and UDP encryption
|
||||
session. `goodbye` ends only that logical conversation; MQTT is closed only by
|
||||
MQTT disconnect, duplicate client takeover, keepAlive expiry, server shutdown,
|
||||
or a protocol/authentication error.
|
||||
|
||||
## Enable Native MQTT
|
||||
|
||||
Native MQTT is disabled by default. Both the protocol and server switches must
|
||||
be enabled:
|
||||
|
||||
```yaml
|
||||
protocols:
|
||||
enabled_protocols: [websocket, mqtt]
|
||||
websocket_enabled: true
|
||||
mqtt_enabled: true
|
||||
|
||||
mqtt_server:
|
||||
enabled: true
|
||||
host: 0.0.0.0
|
||||
port: 1883
|
||||
udp_port: 1883
|
||||
udp_bind_host: ""
|
||||
public_endpoint: mqtt.example.com
|
||||
signature_key: replace-with-a-strong-secret
|
||||
max_connections: 1000
|
||||
max_pending_connections: 128
|
||||
heartbeat_interval: 30
|
||||
max_payload_size: 8192
|
||||
message_queue_size: 128
|
||||
business_ready_timeout: 30
|
||||
goodbye_timeout: 1
|
||||
close_timeout: 2
|
||||
|
||||
# Includes the common model and any Agent-specific local ASR variants.
|
||||
shared_asr_max_models: 3
|
||||
```
|
||||
|
||||
`host` is the local MQTT listen address. `public_endpoint` must be reachable by
|
||||
the device and may be either `host` or `host:port`; manager-api normalizes it
|
||||
and does not append the port twice. When `host` is a wildcard and
|
||||
`public_endpoint` is a local IPv4 address, the UDP socket binds that address so
|
||||
reply datagrams use the same source IP advertised to the device. Set
|
||||
`udp_bind_host` only when the local UDP bind address must differ from the
|
||||
advertised endpoint (for example, behind NAT); leaving it empty enables the
|
||||
automatic behavior. Open the configured MQTT TCP and UDP ports in the firewall.
|
||||
|
||||
When configuration is read from manager-api, configure the equivalent
|
||||
`protocols.*` and `mqtt_server.*` system parameters. The OTA response prefers a
|
||||
valid Native endpoint when Native is enabled. If Native is disabled or invalid,
|
||||
manager-api falls back to `server.mqtt_gateway`. If neither is configured, OTA
|
||||
returns only WebSocket information.
|
||||
|
||||
The current firmware persists the OTA `mqtt` object. The UDP server, key and
|
||||
nonce used by Native mode are negotiated in the MQTT `hello` response, so
|
||||
manager-api does not send a separate top-level OTA `udp` object.
|
||||
|
||||
Long-lived Native connections refresh Agent-private components at logical
|
||||
`hello` boundaries. Local ASR variants are shared by effective configuration
|
||||
instead of loaded once per device. `shared_asr_max_models` bounds resident
|
||||
models; when capacity is exhausted, the replacement runtime is rejected and
|
||||
the previous healthy runtime remains active.
|
||||
The default capacity reserves one transition slot so a single Agent can replace
|
||||
an active private local model without dropping its healthy runtime first. Idle
|
||||
variants are cached and evicted before another model is loaded; resident models
|
||||
never exceed this limit.
|
||||
|
||||
MQTT application packets are dispatched in order outside the socket read loop,
|
||||
so PINGREQ remains responsive while a logical Hello waits for private runtime
|
||||
refresh. `business_ready_timeout` bounds that wait and closes an unusable
|
||||
connection instead of leaving the device in a half-negotiated session.
|
||||
`max_connections` limits authenticated client identities, while
|
||||
`max_pending_connections` separately bounds sockets that have not completed
|
||||
CONNECT. A connection using an existing client id may therefore replace its
|
||||
previous owner even when active capacity is full. The server sends the success
|
||||
CONNACK only after the previous owner has been reclaimed. `goodbye_timeout` and
|
||||
`close_timeout` bound best-effort device reset and physical resource cleanup so
|
||||
a stalled peer cannot block heartbeat or shutdown indefinitely.
|
||||
|
||||
## Authentication and topics
|
||||
|
||||
manager-api generates the device credentials and topics:
|
||||
|
||||
- client id: `<group>@@@<mac_with_underscores>@@@<mac_with_underscores>`
|
||||
- publish topic: `device-server`
|
||||
- subscribe topic: `devices/p2p/<mac_with_underscores>`
|
||||
- username: Base64-encoded JSON metadata
|
||||
- password: Base64(HMAC-SHA256(`client_id + "|" + username`, signature key))
|
||||
|
||||
Use the same secret for manager-api `mqtt_server.signature_key` and
|
||||
xiaozhi-server `mqtt_server.signature_key`. The legacy
|
||||
`server.mqtt_signature_key` remains a fallback for Gateway compatibility.
|
||||
|
||||
Native MQTT accepts MQTT 3.1.1 CONNECT, SUBSCRIBE, PUBLISH QoS 0, PING and
|
||||
DISCONNECT. Unsupported protocol versions, QoS levels, malformed packets, or
|
||||
invalid credentials close the physical connection.
|
||||
|
||||
## Session and UDP flow
|
||||
|
||||
1. Device connects and subscribes over MQTT.
|
||||
2. Server validates CONNECT credentials and replaces any older connection with
|
||||
the same client id.
|
||||
3. Device publishes protocol version 3 `hello`.
|
||||
4. Server waits until private configuration and business components are ready.
|
||||
5. Server creates a logical session and returns the UDP server, AES key, nonce,
|
||||
output audio parameters and session id.
|
||||
6. The first valid UDP packet from the MQTT peer binds the UDP source tuple for
|
||||
that logical session. Source changes are rejected until the next `hello`.
|
||||
7. MQTT JSON and decrypted Opus frames enter the same message processors used
|
||||
by WebSocket and Gateway connections.
|
||||
8. Server `goodbye` returns the device to Idle and finalizes conversation tasks
|
||||
while preserving the MQTT connection.
|
||||
|
||||
The 16-byte UDP header is also the AES-CTR nonce:
|
||||
|
||||
| Bytes | Field |
|
||||
| --- | --- |
|
||||
| 0 | packet type (`1`) |
|
||||
| 1 | reserved |
|
||||
| 2..3 | encrypted payload length, big endian |
|
||||
| 4..7 | connection id, big endian |
|
||||
| 8..11 | timestamp, big endian |
|
||||
| 12..15 | sequence, big endian |
|
||||
|
||||
Datagrams with an invalid type, header, payload length, connection id, source
|
||||
address or stale sequence are discarded. Sequence gaps are tolerated because
|
||||
UDP can lose packets; late and duplicate packets are discarded. AES-CTR
|
||||
provides confidentiality but not integrity, so Native MQTT and UDP should be
|
||||
exposed only with a strong signature key and appropriate network controls.
|
||||
|
||||
## Verification
|
||||
|
||||
From `main/xiaozhi-server`:
|
||||
|
||||
```bash
|
||||
python -m compileall -q .
|
||||
```
|
||||
|
||||
From `main/manager-api` with JDK 21:
|
||||
|
||||
```bash
|
||||
mvn clean package
|
||||
```
|
||||
|
||||
Before deployment, verify Native and Gateway separately with real hardware:
|
||||
multiple conversations, TTS interruption, exit to Idle, duplicate reconnect,
|
||||
server restart, short network loss and a 30-60 minute keepAlive soak.
|
||||
@@ -106,6 +106,32 @@ public interface Constant {
|
||||
*/
|
||||
String SERVER_MQTT_GATEWAY = "server.mqtt_gateway";
|
||||
|
||||
/**
|
||||
* MQTT原生服务开关
|
||||
*/
|
||||
String SERVER_MQTT_ENABLED = "server.mqtt_enabled";
|
||||
|
||||
/**
|
||||
* 新版协议配置
|
||||
*/
|
||||
String PROTOCOLS_ENABLED = "protocols.enabled_protocols";
|
||||
String PROTOCOLS_WEBSOCKET_ENABLED = "protocols.websocket_enabled";
|
||||
String PROTOCOLS_MQTT_ENABLED = "protocols.mqtt_enabled";
|
||||
|
||||
/**
|
||||
* 新版原生MQTT配置
|
||||
*/
|
||||
String MQTT_SERVER_ENABLED = "mqtt_server.enabled";
|
||||
String MQTT_SERVER_HOST = "mqtt_server.host";
|
||||
String MQTT_SERVER_PORT = "mqtt_server.port";
|
||||
String MQTT_SERVER_UDP_PORT = "mqtt_server.udp_port";
|
||||
String MQTT_SERVER_UDP_BIND_HOST = "mqtt_server.udp_bind_host";
|
||||
String MQTT_SERVER_PUBLIC_ENDPOINT = "mqtt_server.public_endpoint";
|
||||
String MQTT_SERVER_SIGNATURE_KEY = "mqtt_server.signature_key";
|
||||
String MQTT_SERVER_MANAGER_API = "mqtt_server.manager_api";
|
||||
String MQTT_SERVER_MANAGER_API_SECRET = "mqtt_server.manager_api_secret";
|
||||
String SERVER_MQTT_MANAGER_API = "server.mqtt_manager_api";
|
||||
|
||||
/**
|
||||
* ota地址
|
||||
*/
|
||||
@@ -350,4 +376,4 @@ public interface Constant {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+46
-2
@@ -1,6 +1,7 @@
|
||||
package xiaozhi.modules.device.controller;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -78,8 +79,8 @@ public class OTAController {
|
||||
@Hidden
|
||||
public ResponseEntity<String> getOTA() {
|
||||
String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, false);
|
||||
if (StringUtils.isBlank(mqttUdpConfig)) {
|
||||
return ResponseEntity.ok("OTA接口不正常,缺少mqtt_gateway地址,请登录智控台,在参数管理找到【server.mqtt_gateway】配置");
|
||||
if (!isNativeMqttReady() && !isConfiguredValue(mqttUdpConfig)) {
|
||||
return ResponseEntity.ok("OTA接口不正常,缺少mqtt_gateway地址或未启用原生MQTT,请登录智控台,在参数管理找到【server.mqtt_gateway】或【mqtt_server.enabled/protocols.mqtt_enabled】配置");
|
||||
}
|
||||
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
|
||||
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
|
||||
@@ -92,6 +93,49 @@ public class OTAController {
|
||||
return ResponseEntity.ok("OTA接口运行正常,websocket集群数量:" + wsUrl.split(";").length);
|
||||
}
|
||||
|
||||
private boolean isNativeMqttEnabled() {
|
||||
String enabled = sysParamsService.getValue(Constant.MQTT_SERVER_ENABLED, false);
|
||||
boolean serverEnabled = isConfiguredValue(enabled)
|
||||
? isTrue(enabled)
|
||||
: isTrue(sysParamsService.getValue(Constant.SERVER_MQTT_ENABLED, false));
|
||||
|
||||
boolean protocolEnabled = isTrue(
|
||||
sysParamsService.getValue(Constant.PROTOCOLS_MQTT_ENABLED, false));
|
||||
if (!protocolEnabled) {
|
||||
String enabledProtocols = sysParamsService.getValue(Constant.PROTOCOLS_ENABLED, false);
|
||||
if (StringUtils.isNotBlank(enabledProtocols)) {
|
||||
String normalized = enabledProtocols
|
||||
.replace("[", "")
|
||||
.replace("]", "")
|
||||
.replace("\"", "");
|
||||
protocolEnabled = Arrays.stream(normalized.split("[;,\\s]+"))
|
||||
.anyMatch("mqtt"::equalsIgnoreCase);
|
||||
}
|
||||
}
|
||||
return serverEnabled && protocolEnabled;
|
||||
}
|
||||
|
||||
private boolean isNativeMqttReady() {
|
||||
if (!isNativeMqttEnabled()) {
|
||||
return false;
|
||||
}
|
||||
String signatureKey = sysParamsService.getValue(
|
||||
Constant.MQTT_SERVER_SIGNATURE_KEY, false);
|
||||
if (!isConfiguredValue(signatureKey)) {
|
||||
signatureKey = sysParamsService.getValue(
|
||||
Constant.SERVER_MQTT_SECRET, false);
|
||||
}
|
||||
return isConfiguredValue(signatureKey);
|
||||
}
|
||||
|
||||
private boolean isTrue(String value) {
|
||||
return "true".equalsIgnoreCase(value) || "1".equals(value);
|
||||
}
|
||||
|
||||
private boolean isConfiguredValue(String value) {
|
||||
return StringUtils.isNotBlank(value) && !"null".equalsIgnoreCase(value.trim());
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
private ResponseEntity<String> createResponse(DeviceReportRespDTO deviceReportRespDTO) {
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
+74
-25
@@ -18,6 +18,7 @@ import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.modules.device.dao.DeviceAddressBookDao;
|
||||
import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
|
||||
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||
import xiaozhi.modules.device.service.DeviceAddressBookService;
|
||||
import xiaozhi.modules.device.service.DeviceService;
|
||||
import xiaozhi.modules.sys.service.SysParamsService;
|
||||
@@ -59,7 +60,14 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
Map<String, Map<String, String>> allBooks = getAllAddressBooks();
|
||||
|
||||
if (isAnswer) {
|
||||
return postToMqtt("/api/call/accept", Map.of("mac", callerMac), "接听");
|
||||
DeviceEntity callerDevice =
|
||||
deviceService.getDeviceByMacAddress(callerMac);
|
||||
if (callerDevice == null) {
|
||||
return errorResult("接听失败,设备信息不存在");
|
||||
}
|
||||
return postCallAccept(
|
||||
buildMqttClientId(callerDevice),
|
||||
Map.of("mac", callerMac));
|
||||
}
|
||||
|
||||
// 主动呼叫模式
|
||||
@@ -79,6 +87,14 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
return errorResult("呼叫失败,您没有权限呼叫该设备");
|
||||
}
|
||||
|
||||
DeviceEntity callerDevice =
|
||||
deviceService.getDeviceByMacAddress(callerMac);
|
||||
DeviceEntity targetDevice =
|
||||
deviceService.getDeviceByMacAddress(targetMac);
|
||||
if (callerDevice == null || targetDevice == null) {
|
||||
return errorResult("呼叫失败,设备信息不存在");
|
||||
}
|
||||
|
||||
// 获取目标设备如何称呼主叫方
|
||||
Map<String, String> targetBook = allBooks.get(targetMac.toLowerCase());
|
||||
String callerNickname = null;
|
||||
@@ -86,15 +102,19 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
callerNickname = targetBook.get(callerMac.toLowerCase());
|
||||
}
|
||||
if (StringUtils.isBlank(callerNickname)) {
|
||||
callerNickname = deviceService.getDeviceByMacAddress(callerMac).getAlias();
|
||||
callerNickname = callerDevice.getAlias();
|
||||
if (StringUtils.isBlank(callerNickname)) {
|
||||
callerNickname = formatMacAsDeviceName(callerMac);
|
||||
}
|
||||
}
|
||||
|
||||
return postToMqtt("/api/call/request",
|
||||
Map.of("caller_mac", callerMac, "target_mac", targetMac, "caller_nickname", callerNickname),
|
||||
"呼叫");
|
||||
return postCallRequest(
|
||||
buildMqttClientId(callerDevice),
|
||||
buildMqttClientId(targetDevice),
|
||||
Map.of(
|
||||
"caller_mac", callerMac,
|
||||
"target_mac", targetMac,
|
||||
"caller_nickname", callerNickname));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -195,32 +215,50 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<String, Object> postToMqtt(String path, Map<String, Object> body, String action) {
|
||||
private Map<String, Object> postCallRequest(
|
||||
String callerClientId,
|
||||
String targetClientId,
|
||||
Map<String, Object> body) {
|
||||
try {
|
||||
MqttManagementHttpClient.Response response =
|
||||
createMqttManagementRouter().sendCallRequest(
|
||||
callerClientId, targetClientId, body);
|
||||
return parseCallResponse(response, "呼叫");
|
||||
} catch (Exception e) {
|
||||
return errorResult("呼叫失败,请稍后再试");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> postCallAccept(
|
||||
String clientId,
|
||||
Map<String, Object> body) {
|
||||
try {
|
||||
MqttManagementHttpClient.Response response =
|
||||
createMqttManagementRouter().sendCallAccept(
|
||||
clientId, body);
|
||||
return parseCallResponse(response, "接听");
|
||||
} catch (Exception e) {
|
||||
return errorResult("接听失败,请稍后再试");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> parseCallResponse(
|
||||
MqttManagementHttpClient.Response response,
|
||||
String action) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("status", "error");
|
||||
|
||||
String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true);
|
||||
String mqttSignatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, true);
|
||||
|
||||
if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)
|
||||
|| MqttGatewayAuthorization.isMissingSignatureKey(mqttSignatureKey)) {
|
||||
result.put("message", action + "失败,网关配置缺失");
|
||||
if (response == null || StringUtils.isBlank(response.body())) {
|
||||
result.put("message", action + "失败,MQTT管理配置缺失");
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
String url = "http://" + mqttGatewayUrl + path;
|
||||
String response = MqttGatewayAuthorization.postJson(
|
||||
url,
|
||||
JSONUtil.toJsonStr(body),
|
||||
mqttSignatureKey,
|
||||
Instant.now(),
|
||||
5000);
|
||||
|
||||
if (StringUtils.isNotBlank(response)) {
|
||||
Map<String, Object> gwResult = JSONUtil.parseObj(response);
|
||||
result.put("status", gwResult.get("status"));
|
||||
result.put("message", gwResult.get("message"));
|
||||
Map<String, Object> backendResult =
|
||||
JSONUtil.parseObj(response.body());
|
||||
result.put("status", backendResult.get("status"));
|
||||
result.put("message", backendResult.get("message"));
|
||||
if (backendResult.containsKey("code")) {
|
||||
result.put("code", backendResult.get("code"));
|
||||
}
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
@@ -229,6 +267,17 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
}
|
||||
}
|
||||
|
||||
MqttManagementRouter createMqttManagementRouter() {
|
||||
return new MqttManagementRouter(
|
||||
new MqttManagementEndpointResolver(sysParamsService),
|
||||
new MqttManagementHttpClient());
|
||||
}
|
||||
|
||||
private String buildMqttClientId(DeviceEntity device) {
|
||||
return MqttClientId.build(
|
||||
device.getBoard(), device.getMacAddress());
|
||||
}
|
||||
|
||||
private String formatMacAsDeviceName(String mac) {
|
||||
if (StringUtils.isBlank(mac) || mac.length() < 2) {
|
||||
return mac;
|
||||
|
||||
+310
-99
@@ -5,11 +5,13 @@ import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
@@ -157,32 +159,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
*/
|
||||
@Override
|
||||
public String getDeviceOnlineData(String agentId) {
|
||||
// 从系统参数中获取MQTT网关地址
|
||||
String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true);
|
||||
if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)) {
|
||||
return "";
|
||||
}
|
||||
// 构建完整的URL
|
||||
String url = StrUtil.format("http://{}/api/devices/status", mqttGatewayUrl);
|
||||
|
||||
// 获取当前用户的设备列表
|
||||
UserDetail user = SecurityUser.getUser();
|
||||
List<DeviceEntity> devices = getUserDevices(user.getId(), agentId);
|
||||
|
||||
// 构建deviceIds数组
|
||||
Set<String> deviceIds = devices.stream().map(o -> {
|
||||
String macAddress = Optional.ofNullable(o.getMacAddress()).orElse("unknown").replace(":", "_");
|
||||
String groupId = Optional.ofNullable(o.getBoard()).orElse("GID_default").replace(":", "_");
|
||||
return StrUtil.format("{}@@@{}@@@{}", groupId, macAddress, macAddress);
|
||||
}).collect(Collectors.toSet());
|
||||
Set<String> deviceIds = devices.stream()
|
||||
.map(device -> MqttClientId.build(
|
||||
device.getBoard(), device.getMacAddress()))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// 构建请求入参
|
||||
Map<String, Set<String>> params = MapUtil
|
||||
.builder(new HashMap<String, Set<String>>())
|
||||
.put("clientIds", deviceIds).build();
|
||||
|
||||
if (ToolUtil.isNotEmpty(deviceIds)) {
|
||||
return postToMqttGateway(url, params);
|
||||
return createMqttManagementRouter()
|
||||
.getMergedStatus(deviceIds);
|
||||
}
|
||||
// 返回响应
|
||||
return "";
|
||||
@@ -194,6 +184,15 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
response.setServer_time(buildServerTime());
|
||||
|
||||
DeviceEntity deviceById = getDeviceByMacAddress(macAddress);
|
||||
String reportedBoard = deviceReport.getBoard() == null
|
||||
? null
|
||||
: deviceReport.getBoard().getType();
|
||||
if (deviceById != null
|
||||
&& StringUtils.isBlank(deviceById.getBoard())
|
||||
&& StringUtils.isNotBlank(reportedBoard)) {
|
||||
deviceById.setBoard(reportedBoard);
|
||||
baseDao.updateById(deviceById);
|
||||
}
|
||||
|
||||
// 设备未绑定,则返回当前上传的固件信息(不更新)以此兼容旧固件版本
|
||||
if (deviceById == null) {
|
||||
@@ -204,8 +203,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
} else {
|
||||
// 只有在设备已绑定且明确开启自动升级时才返回固件升级信息
|
||||
if (Integer.valueOf(1).equals(deviceById.getAutoUpdate())) {
|
||||
String type = deviceReport.getBoard() == null ? null : deviceReport.getBoard().getType();
|
||||
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(type,
|
||||
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(reportedBoard,
|
||||
deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion());
|
||||
response.setFirmware(firmware);
|
||||
}
|
||||
@@ -248,20 +246,43 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
|
||||
response.setWebsocket(websocket);
|
||||
|
||||
// 添加MQTT UDP配置
|
||||
// 从系统参数获取MQTT Gateway地址,仅在配置有效时使用
|
||||
String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, true);
|
||||
if (mqttUdpConfig != null && !mqttUdpConfig.equals("null") && !mqttUdpConfig.isEmpty()) {
|
||||
// UDP key/nonce/endpoint is negotiated by the MQTT hello response.
|
||||
// OTA only needs to provide the MQTT connection parameters.
|
||||
String groupId = "GID_default";
|
||||
if (deviceById != null && StringUtils.isNotBlank(deviceById.getBoard())) {
|
||||
groupId = deviceById.getBoard();
|
||||
} else if (deviceReport.getBoard() != null && StringUtils.isNotBlank(deviceReport.getBoard().getType())) {
|
||||
groupId = deviceReport.getBoard().getType();
|
||||
}
|
||||
|
||||
boolean mqttConfigured = false;
|
||||
if (isNativeMqttEnabled()) {
|
||||
try {
|
||||
String groupId = deviceById != null && deviceById.getBoard() != null ? deviceById.getBoard()
|
||||
: "GID_default";
|
||||
DeviceReportRespDTO.MQTT mqtt = buildMqttConfig(macAddress, groupId);
|
||||
if (mqtt != null) {
|
||||
mqtt.setEndpoint(mqttUdpConfig);
|
||||
DeviceReportRespDTO.MQTT mqtt = buildNativeMqttConfig(macAddress, groupId, clientId);
|
||||
String endpoint = buildNativeMqttEndpoint();
|
||||
if (mqtt != null && StringUtils.isNotBlank(endpoint)) {
|
||||
mqtt.setEndpoint(endpoint);
|
||||
response.setMqtt(mqtt);
|
||||
mqttConfigured = true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("生成MQTT配置失败: {}", e.getMessage());
|
||||
log.error("生成原生MQTT配置失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (!mqttConfigured) {
|
||||
// Native 未启用或配置无效时继续兼容 MQTT Gateway。
|
||||
String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, true);
|
||||
if (mqttUdpConfig != null && !mqttUdpConfig.equals("null") && !mqttUdpConfig.isEmpty()) {
|
||||
try {
|
||||
DeviceReportRespDTO.MQTT mqtt = buildMqttConfig(macAddress, groupId);
|
||||
if (mqtt != null) {
|
||||
mqtt.setEndpoint(mqttUdpConfig);
|
||||
response.setMqtt(mqtt);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("生成MQTT配置失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,6 +654,210 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
return String.format("%s.%d", signatureBase64, timestamp);
|
||||
}
|
||||
|
||||
private boolean isNativeMqttEnabled() {
|
||||
String enabled = sysParamsService.getValue(Constant.MQTT_SERVER_ENABLED, true);
|
||||
String legacyEnabled = sysParamsService.getValue(Constant.SERVER_MQTT_ENABLED, true);
|
||||
boolean serverEnabled = isConfiguredValue(enabled)
|
||||
? isTrue(enabled)
|
||||
: isTrue(legacyEnabled);
|
||||
|
||||
String protoEnabled = sysParamsService.getValue(Constant.PROTOCOLS_MQTT_ENABLED, true);
|
||||
String enabledProtocols = sysParamsService.getValue(Constant.PROTOCOLS_ENABLED, true);
|
||||
boolean protocolEnabled = isTrue(protoEnabled);
|
||||
if (!protocolEnabled && StringUtils.isNotBlank(enabledProtocols)) {
|
||||
String normalizedProtocols = enabledProtocols
|
||||
.replace("[", "")
|
||||
.replace("]", "")
|
||||
.replace("\"", "");
|
||||
protocolEnabled = Arrays.stream(normalizedProtocols.split("[;,\\s]+"))
|
||||
.anyMatch("mqtt"::equalsIgnoreCase);
|
||||
}
|
||||
return serverEnabled && protocolEnabled;
|
||||
}
|
||||
|
||||
private boolean isTrue(String value) {
|
||||
return "true".equalsIgnoreCase(value) || "1".equals(value);
|
||||
}
|
||||
|
||||
private boolean isConfiguredValue(String value) {
|
||||
return StringUtils.isNotBlank(value)
|
||||
&& !"null".equalsIgnoreCase(value.trim())
|
||||
&& !value.contains("你");
|
||||
}
|
||||
|
||||
private String buildNativeMqttEndpoint() {
|
||||
String publicEndpoint = sysParamsService.getValue(Constant.MQTT_SERVER_PUBLIC_ENDPOINT, true);
|
||||
String host = sysParamsService.getValue(Constant.MQTT_SERVER_HOST, true);
|
||||
EndpointParts endpointParts = parseEndpoint(publicEndpoint);
|
||||
EndpointParts hostParts = parseEndpoint(host);
|
||||
if ((isConfiguredValue(publicEndpoint) && endpointParts == null)
|
||||
|| (isConfiguredValue(host) && hostParts == null)) {
|
||||
return null;
|
||||
}
|
||||
EndpointParts selectedEndpoint = null;
|
||||
if (endpointParts != null && isClientReachableMqttHost(endpointParts.host)) {
|
||||
selectedEndpoint = endpointParts;
|
||||
} else if (hostParts != null && isClientReachableMqttHost(hostParts.host)) {
|
||||
selectedEndpoint = hostParts;
|
||||
}
|
||||
if (selectedEndpoint == null) {
|
||||
return null;
|
||||
}
|
||||
String mqttHost = selectedEndpoint.host;
|
||||
Integer port = selectedEndpoint.port;
|
||||
if (port == null) {
|
||||
port = parseInt(sysParamsService.getValue(Constant.MQTT_SERVER_PORT, true), 1883);
|
||||
}
|
||||
if (StringUtils.isBlank(mqttHost) || port == null) {
|
||||
return null;
|
||||
}
|
||||
return formatEndpoint(mqttHost, port);
|
||||
}
|
||||
|
||||
private boolean isClientReachableMqttHost(String host) {
|
||||
if (StringUtils.isBlank(host)) {
|
||||
return false;
|
||||
}
|
||||
String normalized = host.trim().toLowerCase(Locale.ROOT);
|
||||
return !"null".equals(normalized)
|
||||
&& !"localhost".equals(normalized)
|
||||
&& !"localhost.localdomain".equals(normalized)
|
||||
&& !"0.0.0.0".equals(normalized)
|
||||
&& !normalized.startsWith("127.");
|
||||
}
|
||||
|
||||
private Integer parseInt(String value, Integer defaultValue) {
|
||||
if (StringUtils.isBlank(value) || "null".equalsIgnoreCase(value)) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
int port = Integer.parseInt(value);
|
||||
if (port < 1 || port > 65535) {
|
||||
return null;
|
||||
}
|
||||
return port;
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String formatEndpoint(String host, int port) {
|
||||
String normalizedHost = host.trim();
|
||||
if (normalizedHost.indexOf(':') >= 0) {
|
||||
return null;
|
||||
}
|
||||
return normalizedHost + ":" + port;
|
||||
}
|
||||
|
||||
private static class EndpointParts {
|
||||
private final String host;
|
||||
private final Integer port;
|
||||
|
||||
private EndpointParts(String host, Integer port) {
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
}
|
||||
}
|
||||
|
||||
private EndpointParts parseEndpoint(String raw) {
|
||||
if (StringUtils.isBlank(raw) || "null".equalsIgnoreCase(raw)) {
|
||||
return new EndpointParts(null, null);
|
||||
}
|
||||
String trimmed = raw.trim();
|
||||
trimmed = trimmed.replaceFirst("(?i)^(mqtt|tcp|ssl|ws|wss|http|https)://", "");
|
||||
int slashIdx = trimmed.indexOf('/');
|
||||
if (slashIdx >= 0) {
|
||||
trimmed = trimmed.substring(0, slashIdx);
|
||||
}
|
||||
if (StringUtils.isBlank(trimmed) || trimmed.startsWith("[")
|
||||
|| trimmed.indexOf(':') != trimmed.lastIndexOf(':')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String host = trimmed;
|
||||
Integer port = null;
|
||||
|
||||
int colon = trimmed.lastIndexOf(':');
|
||||
if (colon >= 0) {
|
||||
if (colon == 0 || colon == trimmed.length() - 1) {
|
||||
return null;
|
||||
}
|
||||
port = parseInt(trimmed.substring(colon + 1), null);
|
||||
if (port == null) {
|
||||
return null;
|
||||
}
|
||||
host = trimmed.substring(0, colon);
|
||||
}
|
||||
if (!isValidEndpointHost(host)) {
|
||||
return null;
|
||||
}
|
||||
return new EndpointParts(host, port);
|
||||
}
|
||||
|
||||
private boolean isValidEndpointHost(String host) {
|
||||
if (StringUtils.isBlank(host)
|
||||
|| host.length() > 253
|
||||
|| host.chars().anyMatch(Character::isWhitespace)
|
||||
|| host.indexOf('@') >= 0
|
||||
|| host.indexOf('?') >= 0
|
||||
|| host.indexOf('#') >= 0
|
||||
|| host.indexOf('\\') >= 0) {
|
||||
return false;
|
||||
}
|
||||
return Arrays.stream(host.split("\\.", -1))
|
||||
.allMatch(label -> !label.isEmpty()
|
||||
&& label.length() <= 63
|
||||
&& Character.isLetterOrDigit(label.charAt(0))
|
||||
&& Character.isLetterOrDigit(label.charAt(label.length() - 1))
|
||||
&& label.chars().allMatch(ch ->
|
||||
Character.isLetterOrDigit(ch)
|
||||
|| ch == '-'
|
||||
|| ch == '_'));
|
||||
}
|
||||
|
||||
private String buildMqttUsername() throws Exception {
|
||||
Map<String, String> userData = new HashMap<>();
|
||||
try {
|
||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
|
||||
.getRequestAttributes();
|
||||
if (attributes != null) {
|
||||
HttpServletRequest request = attributes.getRequest();
|
||||
String clientIp = request.getRemoteAddr();
|
||||
userData.put("ip", clientIp);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
userData.put("ip", "unknown");
|
||||
}
|
||||
String userDataJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(userData);
|
||||
return Base64.getEncoder().encodeToString(userDataJson.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private DeviceReportRespDTO.MQTT buildNativeMqttConfig(String macAddress, String groupId, String clientId)
|
||||
throws Exception {
|
||||
String deviceIdSafeStr = MqttClientId.normalizeDeviceId(macAddress);
|
||||
String mqttClientId = MqttClientId.build(groupId, macAddress);
|
||||
|
||||
String username = buildMqttUsername();
|
||||
String password = "";
|
||||
String signatureKey = sysParamsService.getValue(Constant.MQTT_SERVER_SIGNATURE_KEY, true);
|
||||
if (!isConfiguredValue(signatureKey)) {
|
||||
signatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, true);
|
||||
}
|
||||
if (!isConfiguredValue(signatureKey)) {
|
||||
log.error("原生MQTT已启用但未配置签名密钥,跳过原生MQTT配置下发");
|
||||
return null;
|
||||
}
|
||||
password = generatePasswordSignature(mqttClientId + "|" + username, signatureKey);
|
||||
|
||||
DeviceReportRespDTO.MQTT mqtt = new DeviceReportRespDTO.MQTT();
|
||||
mqtt.setClient_id(mqttClientId);
|
||||
mqtt.setUsername(username);
|
||||
mqtt.setPassword(password);
|
||||
mqtt.setPublish_topic("device-server");
|
||||
mqtt.setSubscribe_topic("devices/p2p/" + deviceIdSafeStr);
|
||||
return mqtt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建MQTT配置信息
|
||||
*
|
||||
@@ -650,28 +875,10 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
}
|
||||
|
||||
// 构建客户端ID格式:groupId@@@macAddress@@@uuid
|
||||
String groupIdSafeStr = groupId.replace(":", "_");
|
||||
String deviceIdSafeStr = macAddress.replace(":", "_");
|
||||
String mqttClientId = String.format("%s@@@%s@@@%s", groupIdSafeStr, deviceIdSafeStr, deviceIdSafeStr);
|
||||
String deviceIdSafeStr = MqttClientId.normalizeDeviceId(macAddress);
|
||||
String mqttClientId = MqttClientId.build(groupId, macAddress);
|
||||
|
||||
// 构建用户数据(包含IP等信息)
|
||||
Map<String, String> userData = new HashMap<>();
|
||||
// 尝试获取客户端IP
|
||||
try {
|
||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
|
||||
.getRequestAttributes();
|
||||
if (attributes != null) {
|
||||
HttpServletRequest request = attributes.getRequest();
|
||||
String clientIp = request.getRemoteAddr();
|
||||
userData.put("ip", clientIp);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
userData.put("ip", "unknown");
|
||||
}
|
||||
|
||||
// 将用户数据编码为Base64 JSON
|
||||
String userDataJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(userData);
|
||||
String username = Base64.getEncoder().encodeToString(userDataJson.getBytes(StandardCharsets.UTF_8));
|
||||
String username = buildMqttUsername();
|
||||
|
||||
// 生成密码签名
|
||||
String password = generatePasswordSignature(mqttClientId + "|" + username, signatureKey);
|
||||
@@ -687,23 +894,14 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
return mqtt;
|
||||
}
|
||||
|
||||
private String postToMqttGateway(String url, Object requestBody) {
|
||||
String signatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, false);
|
||||
return MqttGatewayAuthorization.postJson(
|
||||
url,
|
||||
JSONUtil.toJsonStr(requestBody),
|
||||
signatureKey,
|
||||
Instant.now());
|
||||
MqttManagementRouter createMqttManagementRouter() {
|
||||
return new MqttManagementRouter(
|
||||
new MqttManagementEndpointResolver(sysParamsService),
|
||||
new MqttManagementHttpClient());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getDeviceTools(String deviceId) {
|
||||
// 从系统参数中获取MQTT网关地址
|
||||
String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true);
|
||||
if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取设备信息
|
||||
DeviceEntity device = baseDao.selectById(deviceId);
|
||||
if (device == null) {
|
||||
@@ -717,19 +915,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
}
|
||||
|
||||
// 构建clientId
|
||||
String macAddress = Optional.ofNullable(device.getMacAddress()).orElse("unknown").replace(":", "_");
|
||||
String groupId = Optional.ofNullable(device.getBoard()).orElse("GID_default").replace(":", "_");
|
||||
String clientId = StrUtil.format("{}@@@{}@@@{}", groupId, macAddress, macAddress);
|
||||
|
||||
// 构建完整的URL
|
||||
String url = StrUtil.format("http://{}/api/commands/{}", mqttGatewayUrl, clientId);
|
||||
String clientId = MqttClientId.build(
|
||||
device.getBoard(), device.getMacAddress());
|
||||
|
||||
// 存储所有工具列表
|
||||
List<Object> allTools = new ArrayList<>();
|
||||
String cursor = null;
|
||||
Set<String> seenCursors = new java.util.HashSet<>();
|
||||
int pageCount = 0;
|
||||
MqttManagementRouter managementRouter =
|
||||
createMqttManagementRouter();
|
||||
MqttManagementEndpointResolver.Backend selectedBackend = null;
|
||||
|
||||
// 循环获取分页数据
|
||||
while (true) {
|
||||
while (pageCount++ < 32) {
|
||||
// 构建params
|
||||
Map<String, Object> paramsMap = MapUtil.builder(new HashMap<String, Object>())
|
||||
.put("withUserTools", true)
|
||||
@@ -754,26 +953,44 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
.put("payload", payload)
|
||||
.build();
|
||||
|
||||
String resultMessage = postToMqttGateway(url, requestBody);
|
||||
MqttManagementHttpClient.Response response =
|
||||
managementRouter.sendReadOnlyCommand(
|
||||
clientId, requestBody, selectedBackend);
|
||||
if (response == null || !response.isSuccessfulHttp()) {
|
||||
return null;
|
||||
}
|
||||
String resultMessage =
|
||||
response.body();
|
||||
if (selectedBackend == null) {
|
||||
selectedBackend = response.backend();
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
if (StringUtils.isBlank(resultMessage)) {
|
||||
break;
|
||||
return null;
|
||||
}
|
||||
|
||||
JSONObject jsonObject = JSONUtil.parseObj(resultMessage);
|
||||
JSONObject jsonObject;
|
||||
try {
|
||||
jsonObject = JSONUtil.parseObj(resultMessage);
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
if (!jsonObject.getBool("success", false)) {
|
||||
break;
|
||||
return null;
|
||||
}
|
||||
|
||||
JSONObject data = jsonObject.getJSONObject("data");
|
||||
if (data == null) {
|
||||
break;
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取当前页的工具列表
|
||||
JSONArray tools = data.getJSONArray("tools");
|
||||
if (tools != null && !tools.isEmpty()) {
|
||||
if (tools == null) {
|
||||
return null;
|
||||
}
|
||||
if (!tools.isEmpty()) {
|
||||
allTools.addAll(tools);
|
||||
}
|
||||
|
||||
@@ -781,29 +998,23 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
String nextCursor = data.getStr("nextCursor");
|
||||
if (StringUtils.isBlank(nextCursor)) {
|
||||
// 没有下一页了
|
||||
break;
|
||||
Map<String, Object> resultData = new HashMap<>();
|
||||
resultData.put("tools", allTools);
|
||||
return resultData;
|
||||
}
|
||||
if (!seenCursors.add(nextCursor)) {
|
||||
log.warn("MQTT设备工具列表返回重复cursor,终止分页: {}",
|
||||
nextCursor);
|
||||
return null;
|
||||
}
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
// 构建返回结果
|
||||
if (allTools.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, Object> resultData = new HashMap<>();
|
||||
resultData.put("tools", allTools);
|
||||
return resultData;
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object callDeviceTool(String deviceId, String toolName, Map<String, Object> arguments) {
|
||||
// 从系统参数中获取MQTT网关地址
|
||||
String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true);
|
||||
if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取设备信息
|
||||
DeviceEntity device = baseDao.selectById(deviceId);
|
||||
if (device == null) {
|
||||
@@ -817,12 +1028,8 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
}
|
||||
|
||||
// 构建clientId
|
||||
String macAddress = Optional.ofNullable(device.getMacAddress()).orElse("unknown").replace(":", "_");
|
||||
String groupId = Optional.ofNullable(device.getBoard()).orElse("GID_default").replace(":", "_");
|
||||
String clientId = StrUtil.format("{}@@@{}@@@{}", groupId, macAddress, macAddress);
|
||||
|
||||
// 构建完整的URL
|
||||
String url = StrUtil.format("http://{}/api/commands/{}", mqttGatewayUrl, clientId);
|
||||
String clientId = MqttClientId.build(
|
||||
device.getBoard(), device.getMacAddress());
|
||||
|
||||
// 构建请求体
|
||||
Map<String, Object> params = MapUtil
|
||||
@@ -845,7 +1052,11 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
.put("payload", payload)
|
||||
.build();
|
||||
|
||||
String resultMessage = postToMqttGateway(url, requestBody);
|
||||
MqttManagementHttpClient.Response response =
|
||||
createMqttManagementRouter()
|
||||
.sendMutatingCommand(clientId, requestBody);
|
||||
String resultMessage =
|
||||
response == null ? null : response.body();
|
||||
|
||||
// 解析响应
|
||||
if (StringUtils.isNotBlank(resultMessage)) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package xiaozhi.modules.device.service.impl;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
final class MqttClientId {
|
||||
|
||||
private MqttClientId() {
|
||||
}
|
||||
|
||||
static String build(String board, String macAddress) {
|
||||
String groupId = StringUtils.defaultIfBlank(
|
||||
board, "GID_default").trim().replace(":", "_");
|
||||
String deviceId = StringUtils.defaultIfBlank(macAddress, "unknown")
|
||||
.trim()
|
||||
.replace(":", "_");
|
||||
return groupId + "@@@" + deviceId + "@@@" + deviceId;
|
||||
}
|
||||
|
||||
static String normalizeDeviceId(String macAddress) {
|
||||
return StringUtils.defaultIfBlank(macAddress, "unknown")
|
||||
.trim()
|
||||
.toLowerCase(Locale.ROOT)
|
||||
.replace(":", "_")
|
||||
.replace("-", "_");
|
||||
}
|
||||
}
|
||||
+15
-4
@@ -27,10 +27,8 @@ final class MqttGatewayAuthorization {
|
||||
}
|
||||
|
||||
static String postJson(String url, String jsonBody, String signatureKey, Instant now, int timeoutMillis) {
|
||||
GatewayResponse response = executeWithDateFallback(
|
||||
signatureKey,
|
||||
now,
|
||||
token -> executeRequest(url, jsonBody, token, timeoutMillis));
|
||||
GatewayResponse response = postJsonResponse(
|
||||
url, jsonBody, signatureKey, now, timeoutMillis);
|
||||
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
throw new GatewayRequestException(
|
||||
@@ -40,6 +38,19 @@ final class MqttGatewayAuthorization {
|
||||
return response.body();
|
||||
}
|
||||
|
||||
static GatewayResponse postJsonResponse(
|
||||
String url,
|
||||
String jsonBody,
|
||||
String signatureKey,
|
||||
Instant now,
|
||||
int timeoutMillis) {
|
||||
return executeWithDateFallback(
|
||||
signatureKey,
|
||||
now,
|
||||
token -> executeRequest(
|
||||
url, jsonBody, token, timeoutMillis));
|
||||
}
|
||||
|
||||
static List<String> generateDailyTokens(String signatureKey, Instant now) {
|
||||
if (isMissingSignatureKey(signatureKey)) {
|
||||
throw new GatewayRequestException("MQTT Gateway signature key is empty", null);
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
package xiaozhi.modules.device.service.impl;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.modules.sys.service.SysParamsService;
|
||||
|
||||
class MqttManagementEndpointResolver {
|
||||
|
||||
enum Backend {
|
||||
NATIVE,
|
||||
GATEWAY
|
||||
}
|
||||
|
||||
record Endpoint(Backend backend, String baseUrl, String signatureKey) {
|
||||
}
|
||||
|
||||
private final SysParamsService sysParamsService;
|
||||
|
||||
MqttManagementEndpointResolver(SysParamsService sysParamsService) {
|
||||
this.sysParamsService = sysParamsService;
|
||||
}
|
||||
|
||||
List<Endpoint> resolve() {
|
||||
List<Endpoint> endpoints = new ArrayList<>(2);
|
||||
resolveNative().ifPresent(endpoints::add);
|
||||
resolveGateway().ifPresent(endpoints::add);
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
java.util.Optional<Endpoint> resolveNative() {
|
||||
if (!isNativeMqttEnabled()) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
String endpoint = normalizeHttpEndpoint(sysParamsService.getValue(
|
||||
Constant.MQTT_SERVER_MANAGER_API, true));
|
||||
String signatureKey = firstConfigured(
|
||||
sysParamsService.getValue(
|
||||
Constant.MQTT_SERVER_MANAGER_API_SECRET, true),
|
||||
sysParamsService.getValue(
|
||||
Constant.MQTT_SERVER_SIGNATURE_KEY, true),
|
||||
sysParamsService.getValue(
|
||||
Constant.SERVER_MQTT_SECRET, true));
|
||||
if (endpoint == null || signatureKey == null) {
|
||||
throw new ManagementConfigurationException(
|
||||
"原生MQTT已启用但管理端点或密钥无效");
|
||||
}
|
||||
return java.util.Optional.of(
|
||||
new Endpoint(Backend.NATIVE, endpoint, signatureKey));
|
||||
}
|
||||
|
||||
java.util.Optional<Endpoint> resolveGateway() {
|
||||
String rawGateway = sysParamsService.getValue(
|
||||
Constant.SERVER_MQTT_GATEWAY, true);
|
||||
String rawEndpoint = sysParamsService.getValue(
|
||||
Constant.SERVER_MQTT_MANAGER_API, true);
|
||||
if (configured(rawGateway) == null
|
||||
&& configured(rawEndpoint) == null) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
String endpoint = normalizeHttpEndpoint(rawEndpoint);
|
||||
String signatureKey = configured(
|
||||
sysParamsService.getValue(
|
||||
Constant.SERVER_MQTT_SECRET, true));
|
||||
if (endpoint == null || signatureKey == null) {
|
||||
throw new ManagementConfigurationException(
|
||||
"MQTT Gateway管理端点或密钥无效");
|
||||
}
|
||||
return java.util.Optional.of(
|
||||
new Endpoint(Backend.GATEWAY, endpoint, signatureKey));
|
||||
}
|
||||
|
||||
boolean isNativeMqttEnabled() {
|
||||
String enabled = sysParamsService.getValue(
|
||||
Constant.MQTT_SERVER_ENABLED, true);
|
||||
String legacyEnabled = sysParamsService.getValue(
|
||||
Constant.SERVER_MQTT_ENABLED, true);
|
||||
boolean serverEnabled = configured(enabled) != null
|
||||
? isTrue(enabled)
|
||||
: isTrue(legacyEnabled);
|
||||
|
||||
String protocolEnabled = sysParamsService.getValue(
|
||||
Constant.PROTOCOLS_MQTT_ENABLED, true);
|
||||
String enabledProtocols = sysParamsService.getValue(
|
||||
Constant.PROTOCOLS_ENABLED, true);
|
||||
boolean enabledByProtocol = isTrue(protocolEnabled);
|
||||
if (!enabledByProtocol && StringUtils.isNotBlank(enabledProtocols)) {
|
||||
String normalized = enabledProtocols
|
||||
.replace("[", "")
|
||||
.replace("]", "")
|
||||
.replace("\"", "");
|
||||
enabledByProtocol = Arrays.stream(
|
||||
normalized.split("[;,\\s]+"))
|
||||
.anyMatch("mqtt"::equalsIgnoreCase);
|
||||
}
|
||||
return serverEnabled && enabledByProtocol;
|
||||
}
|
||||
|
||||
static String normalizeHttpEndpoint(String raw) {
|
||||
String configured = configured(raw);
|
||||
if (configured == null) {
|
||||
return null;
|
||||
}
|
||||
boolean hasScheme = configured.matches(
|
||||
"^[A-Za-z][A-Za-z0-9+.-]*://.*");
|
||||
if (hasScheme
|
||||
&& !configured.matches("(?i)^https?://.*")) {
|
||||
return null;
|
||||
}
|
||||
String candidate = hasScheme ? configured : "http://" + configured;
|
||||
try {
|
||||
URI uri = new URI(candidate);
|
||||
String scheme = uri.getScheme();
|
||||
if (scheme == null
|
||||
|| (!"http".equalsIgnoreCase(scheme)
|
||||
&& !"https".equalsIgnoreCase(scheme))
|
||||
|| StringUtils.isBlank(uri.getHost())
|
||||
|| uri.getUserInfo() != null
|
||||
|| uri.getQuery() != null
|
||||
|| uri.getFragment() != null
|
||||
|| uri.getPort() == 0
|
||||
|| uri.getPort() > 65535) {
|
||||
return null;
|
||||
}
|
||||
String path = uri.getPath();
|
||||
if (path == null || "/".equals(path)) {
|
||||
path = "";
|
||||
} else {
|
||||
path = path.replaceAll("/+$", "");
|
||||
}
|
||||
return new URI(
|
||||
scheme.toLowerCase(Locale.ROOT),
|
||||
null,
|
||||
uri.getHost(),
|
||||
uri.getPort(),
|
||||
path,
|
||||
null,
|
||||
null).toString();
|
||||
} catch (URISyntaxException | IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String firstConfigured(String... values) {
|
||||
for (String value : values) {
|
||||
String normalized = configured(value);
|
||||
if (normalized != null) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String configured(String value) {
|
||||
if (StringUtils.isBlank(value)) {
|
||||
return null;
|
||||
}
|
||||
String normalized = value.trim();
|
||||
if ("null".equalsIgnoreCase(normalized)
|
||||
|| normalized.contains("你")) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static boolean isTrue(String value) {
|
||||
return "true".equalsIgnoreCase(value) || "1".equals(value);
|
||||
}
|
||||
|
||||
static final class ManagementConfigurationException
|
||||
extends RuntimeException {
|
||||
ManagementConfigurationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package xiaozhi.modules.device.service.impl;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import cn.hutool.json.JSONUtil;
|
||||
|
||||
class MqttManagementHttpClient {
|
||||
|
||||
private final int timeoutMillis;
|
||||
|
||||
MqttManagementHttpClient() {
|
||||
this(5000);
|
||||
}
|
||||
|
||||
MqttManagementHttpClient(int timeoutMillis) {
|
||||
this.timeoutMillis = timeoutMillis;
|
||||
}
|
||||
|
||||
Response post(
|
||||
MqttManagementEndpointResolver.Endpoint endpoint,
|
||||
String path,
|
||||
Object requestBody) {
|
||||
String url = appendPath(endpoint.baseUrl(), path);
|
||||
MqttGatewayAuthorization.GatewayResponse response =
|
||||
MqttGatewayAuthorization.postJsonResponse(
|
||||
url,
|
||||
JSONUtil.toJsonStr(requestBody),
|
||||
endpoint.signatureKey(),
|
||||
Instant.now(),
|
||||
timeoutMillis);
|
||||
return new Response(
|
||||
endpoint.backend(),
|
||||
response.statusCode(),
|
||||
response.body());
|
||||
}
|
||||
|
||||
static String appendPath(String baseUrl, String path) {
|
||||
String normalizedBase = baseUrl.endsWith("/")
|
||||
? baseUrl.substring(0, baseUrl.length() - 1)
|
||||
: baseUrl;
|
||||
String normalizedPath = path.startsWith("/")
|
||||
? path
|
||||
: "/" + path;
|
||||
return normalizedBase + normalizedPath;
|
||||
}
|
||||
|
||||
record Response(
|
||||
MqttManagementEndpointResolver.Backend backend,
|
||||
int statusCode,
|
||||
String body) {
|
||||
|
||||
boolean isSuccessfulHttp() {
|
||||
return statusCode >= 200 && statusCode < 300;
|
||||
}
|
||||
}
|
||||
}
|
||||
+431
@@ -0,0 +1,431 @@
|
||||
package xiaozhi.modules.device.service.impl;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
|
||||
final class MqttManagementRouter {
|
||||
|
||||
private final MqttManagementEndpointResolver endpointResolver;
|
||||
private final MqttManagementHttpClient httpClient;
|
||||
|
||||
MqttManagementRouter(
|
||||
MqttManagementEndpointResolver endpointResolver,
|
||||
MqttManagementHttpClient httpClient) {
|
||||
this.endpointResolver = endpointResolver;
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
String getMergedStatus(Set<String> clientIds) {
|
||||
List<MqttManagementEndpointResolver.Endpoint> endpoints =
|
||||
endpointResolver.resolve();
|
||||
if (endpoints.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
Map<String, MutableDeviceStatus> merged =
|
||||
initializeStatuses(clientIds);
|
||||
int successfulBackends = 0;
|
||||
RuntimeException lastFailure = null;
|
||||
for (MqttManagementEndpointResolver.Endpoint endpoint : endpoints) {
|
||||
try {
|
||||
MqttManagementHttpClient.Response response =
|
||||
httpClient.post(
|
||||
endpoint,
|
||||
"/api/devices/status",
|
||||
Map.of("clientIds", clientIds));
|
||||
if (!response.isSuccessfulHttp()) {
|
||||
throw new IllegalStateException(
|
||||
"MQTT管理状态查询失败: "
|
||||
+ response.statusCode());
|
||||
}
|
||||
mergeStatusResponse(
|
||||
merged, endpoint.backend(), response.body());
|
||||
successfulBackends++;
|
||||
} catch (RuntimeException e) {
|
||||
lastFailure = e;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastFailure != null
|
||||
|| successfulBackends != endpoints.size()) {
|
||||
throw new ManagementUnavailableException(
|
||||
"无法确认全部MQTT后端状态", lastFailure);
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
merged.forEach((clientId, status) ->
|
||||
result.put(clientId, status.toMap()));
|
||||
return JSONUtil.toJsonStr(result);
|
||||
}
|
||||
|
||||
MqttManagementHttpClient.Response sendReadOnlyCommand(
|
||||
String clientId, Object requestBody) {
|
||||
return sendReadOnlyCommand(clientId, requestBody, null);
|
||||
}
|
||||
|
||||
MqttManagementHttpClient.Response sendReadOnlyCommand(
|
||||
String clientId,
|
||||
Object requestBody,
|
||||
MqttManagementEndpointResolver.Backend preferredBackend) {
|
||||
List<MqttManagementEndpointResolver.Endpoint> endpoints =
|
||||
endpointResolver.resolve();
|
||||
if (endpoints.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (preferredBackend != null) {
|
||||
MqttManagementEndpointResolver.Endpoint endpoint =
|
||||
endpoints.stream()
|
||||
.filter(candidate ->
|
||||
candidate.backend()
|
||||
== preferredBackend)
|
||||
.findFirst()
|
||||
.orElseThrow(() ->
|
||||
new ManagementUnavailableException(
|
||||
"分页MQTT后端配置已变化",
|
||||
null));
|
||||
return sendCommand(endpoint, clientId, requestBody);
|
||||
}
|
||||
|
||||
MqttManagementHttpClient.Response lastResponse = null;
|
||||
RuntimeException lastFailure = null;
|
||||
for (MqttManagementEndpointResolver.Endpoint endpoint : endpoints) {
|
||||
try {
|
||||
MqttManagementHttpClient.Response response =
|
||||
sendCommand(endpoint, clientId, requestBody);
|
||||
lastResponse = response;
|
||||
if (isCommandSuccess(response)) {
|
||||
return response;
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
lastFailure = e;
|
||||
}
|
||||
}
|
||||
if (lastResponse != null) {
|
||||
return lastResponse;
|
||||
}
|
||||
throw new ManagementUnavailableException(
|
||||
"MQTT管理服务均不可用", lastFailure);
|
||||
}
|
||||
|
||||
MqttManagementHttpClient.Response sendMutatingCommand(
|
||||
String clientId, Object requestBody) {
|
||||
List<MqttManagementEndpointResolver.Endpoint> endpoints =
|
||||
endpointResolver.resolve();
|
||||
if (endpoints.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<MqttManagementEndpointResolver.Endpoint> online =
|
||||
new ArrayList<>();
|
||||
int successfulStatusBackends = 0;
|
||||
RuntimeException lastStatusFailure = null;
|
||||
for (MqttManagementEndpointResolver.Endpoint endpoint : endpoints) {
|
||||
try {
|
||||
MqttManagementHttpClient.Response statusResponse =
|
||||
httpClient.post(
|
||||
endpoint,
|
||||
"/api/devices/status",
|
||||
Map.of("clientIds", Set.of(clientId)));
|
||||
if (!statusResponse.isSuccessfulHttp()) {
|
||||
throw new IllegalStateException(
|
||||
"MQTT管理状态查询失败: "
|
||||
+ statusResponse.statusCode());
|
||||
}
|
||||
Map<String, BackendDeviceStatus> statuses =
|
||||
parseStatusResponse(
|
||||
statusResponse.body(),
|
||||
Set.of(clientId));
|
||||
successfulStatusBackends++;
|
||||
if (statuses.get(clientId).exists()) {
|
||||
online.add(endpoint);
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
lastStatusFailure = e;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastStatusFailure != null
|
||||
|| successfulStatusBackends != endpoints.size()) {
|
||||
throw new ManagementUnavailableException(
|
||||
"无法确认设备所在的MQTT后端",
|
||||
lastStatusFailure);
|
||||
}
|
||||
if (online.isEmpty()) {
|
||||
return offlineResponse(endpoints.get(0).backend());
|
||||
}
|
||||
if (online.size() > 1) {
|
||||
return commandErrorResponse(
|
||||
endpoints.get(0).backend(),
|
||||
409,
|
||||
"设备同时存在于多个MQTT后端",
|
||||
"DEVICE_BACKEND_AMBIGUOUS");
|
||||
}
|
||||
|
||||
return sendCommand(online.get(0), clientId, requestBody);
|
||||
}
|
||||
|
||||
MqttManagementHttpClient.Response sendCallRequest(
|
||||
String callerClientId,
|
||||
String targetClientId,
|
||||
Object requestBody) {
|
||||
Set<String> clientIds = new java.util.LinkedHashSet<>();
|
||||
clientIds.add(callerClientId);
|
||||
clientIds.add(targetClientId);
|
||||
return sendCallMutation(
|
||||
clientIds,
|
||||
"/api/call/request",
|
||||
requestBody);
|
||||
}
|
||||
|
||||
MqttManagementHttpClient.Response sendCallAccept(
|
||||
String clientId, Object requestBody) {
|
||||
return sendCallMutation(
|
||||
Set.of(clientId),
|
||||
"/api/call/accept",
|
||||
requestBody);
|
||||
}
|
||||
|
||||
private MqttManagementHttpClient.Response sendCallMutation(
|
||||
Set<String> clientIds,
|
||||
String path,
|
||||
Object requestBody) {
|
||||
List<MqttManagementEndpointResolver.Endpoint> endpoints =
|
||||
endpointResolver.resolve();
|
||||
if (endpoints.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, List<MqttManagementEndpointResolver.Endpoint>> owners =
|
||||
new LinkedHashMap<>();
|
||||
clientIds.forEach(clientId ->
|
||||
owners.put(clientId, new ArrayList<>()));
|
||||
|
||||
RuntimeException statusFailure = null;
|
||||
int successfulStatusBackends = 0;
|
||||
for (MqttManagementEndpointResolver.Endpoint endpoint : endpoints) {
|
||||
try {
|
||||
MqttManagementHttpClient.Response response =
|
||||
httpClient.post(
|
||||
endpoint,
|
||||
"/api/devices/status",
|
||||
Map.of("clientIds", clientIds));
|
||||
if (!response.isSuccessfulHttp()) {
|
||||
throw new IllegalStateException(
|
||||
"MQTT管理状态查询失败: "
|
||||
+ response.statusCode());
|
||||
}
|
||||
Map<String, BackendDeviceStatus> statuses =
|
||||
parseStatusResponse(response.body(), clientIds);
|
||||
owners.forEach((clientId, matches) -> {
|
||||
if (statuses.get(clientId).exists()) {
|
||||
matches.add(endpoint);
|
||||
}
|
||||
});
|
||||
successfulStatusBackends++;
|
||||
} catch (RuntimeException e) {
|
||||
statusFailure = e;
|
||||
}
|
||||
}
|
||||
|
||||
if (statusFailure != null
|
||||
|| successfulStatusBackends != endpoints.size()) {
|
||||
throw new ManagementUnavailableException(
|
||||
"无法确认通话设备所在的MQTT后端",
|
||||
statusFailure);
|
||||
}
|
||||
|
||||
for (Map.Entry<String, List<MqttManagementEndpointResolver.Endpoint>>
|
||||
owner : owners.entrySet()) {
|
||||
if (owner.getValue().isEmpty()) {
|
||||
return callErrorResponse(
|
||||
endpoints.get(0).backend(),
|
||||
404,
|
||||
"offline",
|
||||
"设备不在线",
|
||||
"DEVICE_OFFLINE");
|
||||
}
|
||||
if (owner.getValue().size() > 1) {
|
||||
return callErrorResponse(
|
||||
endpoints.get(0).backend(),
|
||||
409,
|
||||
"error",
|
||||
"设备同时存在于多个MQTT后端",
|
||||
"DEVICE_BACKEND_AMBIGUOUS");
|
||||
}
|
||||
}
|
||||
|
||||
MqttManagementEndpointResolver.Endpoint selected =
|
||||
owners.values().iterator().next().get(0);
|
||||
boolean sameBackend = owners.values().stream()
|
||||
.allMatch(matches -> matches.get(0).equals(selected));
|
||||
if (!sameBackend) {
|
||||
return callErrorResponse(
|
||||
selected.backend(),
|
||||
409,
|
||||
"error",
|
||||
"跨MQTT后端通话暂不支持",
|
||||
"CROSS_BACKEND_CALL_UNSUPPORTED");
|
||||
}
|
||||
return httpClient.post(selected, path, requestBody);
|
||||
}
|
||||
|
||||
private MqttManagementHttpClient.Response sendCommand(
|
||||
MqttManagementEndpointResolver.Endpoint endpoint,
|
||||
String clientId,
|
||||
Object requestBody) {
|
||||
String encodedClientId = URLEncoder.encode(
|
||||
clientId, StandardCharsets.UTF_8)
|
||||
.replace("+", "%20");
|
||||
return httpClient.post(
|
||||
endpoint,
|
||||
"/api/commands/" + encodedClientId,
|
||||
requestBody);
|
||||
}
|
||||
|
||||
private static Map<String, MutableDeviceStatus> initializeStatuses(
|
||||
Set<String> clientIds) {
|
||||
Map<String, MutableDeviceStatus> result = new LinkedHashMap<>();
|
||||
clientIds.forEach(clientId ->
|
||||
result.put(clientId, new MutableDeviceStatus()));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void mergeStatusResponse(
|
||||
Map<String, MutableDeviceStatus> merged,
|
||||
MqttManagementEndpointResolver.Backend backend,
|
||||
String body) {
|
||||
Map<String, BackendDeviceStatus> response =
|
||||
parseStatusResponse(body, merged.keySet());
|
||||
merged.forEach((clientId, status) -> {
|
||||
BackendDeviceStatus value = response.get(clientId);
|
||||
status.exists |= value.exists();
|
||||
status.alive |= value.alive();
|
||||
if (value.exists()) {
|
||||
status.backends.add(
|
||||
backend.name().toLowerCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Map<String, BackendDeviceStatus> parseStatusResponse(
|
||||
String body, Set<String> clientIds) {
|
||||
JSONObject response;
|
||||
try {
|
||||
response = JSONUtil.parseObj(body);
|
||||
} catch (RuntimeException e) {
|
||||
throw new IllegalStateException(
|
||||
"MQTT管理状态响应不是JSON对象", e);
|
||||
}
|
||||
Map<String, BackendDeviceStatus> statuses =
|
||||
new LinkedHashMap<>();
|
||||
for (String clientId : clientIds) {
|
||||
Object rawStatus = response.get(clientId);
|
||||
if (!(rawStatus instanceof JSONObject status)
|
||||
|| !status.containsKey("exists")
|
||||
|| !status.containsKey("isAlive")) {
|
||||
throw new IllegalStateException(
|
||||
"MQTT管理状态响应缺少设备状态: " + clientId);
|
||||
}
|
||||
Object rawExists = status.get("exists");
|
||||
Object rawAlive = status.get("isAlive");
|
||||
if (!(rawExists instanceof Boolean exists)
|
||||
|| !(rawAlive instanceof Boolean alive)
|
||||
|| (!exists && alive)) {
|
||||
throw new IllegalStateException(
|
||||
"MQTT管理状态响应字段无效: " + clientId);
|
||||
}
|
||||
statuses.put(
|
||||
clientId,
|
||||
new BackendDeviceStatus(exists, alive));
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
private static boolean isCommandSuccess(
|
||||
MqttManagementHttpClient.Response response) {
|
||||
if (response == null || !response.isSuccessfulHttp()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return JSONUtil.parseObj(response.body())
|
||||
.getBool("success", false);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static MqttManagementHttpClient.Response offlineResponse(
|
||||
MqttManagementEndpointResolver.Backend backend) {
|
||||
return new MqttManagementHttpClient.Response(
|
||||
backend,
|
||||
404,
|
||||
JSONUtil.toJsonStr(Map.of(
|
||||
"success", false,
|
||||
"error", "设备未连接",
|
||||
"code", "DEVICE_OFFLINE",
|
||||
"dispatchAttempted", false)));
|
||||
}
|
||||
|
||||
private static MqttManagementHttpClient.Response callErrorResponse(
|
||||
MqttManagementEndpointResolver.Backend backend,
|
||||
int statusCode,
|
||||
String status,
|
||||
String message,
|
||||
String code) {
|
||||
return new MqttManagementHttpClient.Response(
|
||||
backend,
|
||||
statusCode,
|
||||
JSONUtil.toJsonStr(Map.of(
|
||||
"status", status,
|
||||
"message", message,
|
||||
"code", code)));
|
||||
}
|
||||
|
||||
private static MqttManagementHttpClient.Response commandErrorResponse(
|
||||
MqttManagementEndpointResolver.Backend backend,
|
||||
int statusCode,
|
||||
String error,
|
||||
String code) {
|
||||
return new MqttManagementHttpClient.Response(
|
||||
backend,
|
||||
statusCode,
|
||||
JSONUtil.toJsonStr(Map.of(
|
||||
"success", false,
|
||||
"error", error,
|
||||
"code", code,
|
||||
"dispatchAttempted", false)));
|
||||
}
|
||||
|
||||
static final class ManagementUnavailableException
|
||||
extends RuntimeException {
|
||||
ManagementUnavailableException(
|
||||
String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class MutableDeviceStatus {
|
||||
private boolean exists;
|
||||
private boolean alive;
|
||||
private final List<String> backends = new ArrayList<>();
|
||||
|
||||
Map<String, Object> toMap() {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("isAlive", alive);
|
||||
result.put("exists", exists);
|
||||
result.put("backends", backends);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private record BackendDeviceStatus(boolean exists, boolean alive) {
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -85,6 +85,7 @@ public class SysParamsController {
|
||||
public Result<Void> save(@RequestBody SysParamsDTO dto) {
|
||||
// 效验数据
|
||||
ValidatorUtils.validateEntity(dto, AddGroup.class, DefaultGroup.class);
|
||||
validateMqttSecretLength(dto.getParamCode(), dto.getParamValue());
|
||||
|
||||
sysParamsService.save(dto);
|
||||
configService.getConfig(false);
|
||||
@@ -277,7 +278,10 @@ public class SysParamsController {
|
||||
|
||||
// 校验mqtt密钥长度和复杂度
|
||||
private void validateMqttSecretLength(String paramCode, String secret) {
|
||||
if (!paramCode.equals(Constant.SERVER_MQTT_SECRET)) {
|
||||
if (!paramCode.equals(Constant.SERVER_MQTT_SECRET)
|
||||
&& !paramCode.equals(Constant.MQTT_SERVER_SIGNATURE_KEY)
|
||||
&& !paramCode.equals(
|
||||
Constant.MQTT_SERVER_MANAGER_API_SECRET)) {
|
||||
return;
|
||||
}
|
||||
if (StringUtils.isBlank(secret) || secret.equals("null")) {
|
||||
|
||||
+6
-1
@@ -205,9 +205,14 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
|
||||
public void initServerSecret() {
|
||||
// 获取服务器密钥
|
||||
String secretParam = getValue(Constant.SERVER_SECRET, false);
|
||||
if (StringUtils.isBlank(secretParam) || "null".equals(secretParam)) {
|
||||
if (StringUtils.isBlank(secretParam)
|
||||
|| "null".equalsIgnoreCase(secretParam.trim())) {
|
||||
String newSecret = UUID.randomUUID().toString();
|
||||
updateValueByCode(Constant.SERVER_SECRET, newSecret);
|
||||
} else {
|
||||
// Startup must repair a stale Redis value because authentication
|
||||
// reads this parameter from cache while initialization reads DB.
|
||||
sysParamsRedis.set(Constant.SERVER_SECRET, secretParam);
|
||||
}
|
||||
|
||||
// 初始化SM2密钥对
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
insert ignore into `sys_params`
|
||||
(id, param_code, param_value, value_type, param_type, remark)
|
||||
values
|
||||
(775, 'protocols.enabled_protocols', '["websocket"]', 'string', 1, '启用的协议列表'),
|
||||
(776, 'protocols.websocket_enabled', 'true', 'boolean', 1, 'WebSocket协议开关'),
|
||||
(777, 'protocols.mqtt_enabled', 'false', 'boolean', 1, 'MQTT协议开关'),
|
||||
(778, 'mqtt_server.enabled', 'false', 'boolean', 1, '是否启用MQTT服务器'),
|
||||
(779, 'mqtt_server.host', '0.0.0.0', 'string', 1, 'MQTT服务器监听地址'),
|
||||
(780, 'mqtt_server.port', '1883', 'number', 1, 'MQTT服务器端口'),
|
||||
(781, 'mqtt_server.udp_port', '1883', 'number', 1, 'UDP音频端口'),
|
||||
(782, 'mqtt_server.public_endpoint', '127.0.0.1', 'string', 1, 'MQTT公网/局域网访问地址'),
|
||||
(783, 'mqtt_server.max_connections', '1000', 'number', 1, '最大连接数'),
|
||||
(784, 'mqtt_server.heartbeat_interval', '30', 'number', 1, '心跳间隔(秒)'),
|
||||
(785, 'mqtt_server.max_payload_size', '8192', 'number', 1, '最大消息大小'),
|
||||
(786, 'mqtt_server.signature_key', 'null', 'string', 1, 'MQTT签名密钥');
|
||||
@@ -0,0 +1,9 @@
|
||||
insert ignore into `sys_params`
|
||||
(id, param_code, param_value, value_type, param_type, remark)
|
||||
values
|
||||
(787, 'mqtt_server.udp_bind_host', '', 'string', 1, 'UDP音频监听地址(留空时自动选择)'),
|
||||
(788, 'mqtt_server.message_queue_size', '128', 'number', 1, 'MQTT应用消息队列上限'),
|
||||
(789, 'mqtt_server.business_ready_timeout', '30', 'number', 1, 'Hello等待业务运行时就绪超时(秒)'),
|
||||
(790, 'mqtt_server.max_pending_connections', '128', 'number', 1, 'MQTT待认证连接上限'),
|
||||
(791, 'mqtt_server.goodbye_timeout', '1', 'number', 1, 'MQTT goodbye发送超时(秒)'),
|
||||
(792, 'mqtt_server.close_timeout', '2', 'number', 1, 'MQTT连接清理超时(秒)');
|
||||
@@ -0,0 +1,11 @@
|
||||
insert ignore into `sys_params`
|
||||
(id, param_code, param_value, value_type, param_type, remark)
|
||||
values
|
||||
(793, 'mqtt_server.manager_api', 'null', 'string', 1, '原生MQTT管理API地址'),
|
||||
(794, 'mqtt_server.manager_api_secret', 'null', 'string', 1, '原生MQTT管理API签名密钥');
|
||||
|
||||
update `sys_params`
|
||||
set param_value = '',
|
||||
remark = 'MQTT公网/局域网访问地址(需配置为设备可达地址)'
|
||||
where param_code = 'mqtt_server.public_endpoint'
|
||||
and param_value = '127.0.0.1';
|
||||
@@ -711,3 +711,26 @@ databaseChangeLog:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202607101200.sql
|
||||
- changeSet:
|
||||
id: 202607190300
|
||||
author: CAIXYPROMISE
|
||||
validCheckSum:
|
||||
- "8:5df1e1f3654e6271889d5579a3908f77"
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202607190300.sql
|
||||
- changeSet:
|
||||
id: 202607260900
|
||||
author: CAIXYPROMISE
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202607260900.sql
|
||||
- changeSet:
|
||||
id: 202607262100
|
||||
author: CAIXYPROMISE
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202607262100.sql
|
||||
|
||||
@@ -69,7 +69,7 @@ async def main():
|
||||
gc_manager = get_gc_manager(interval_seconds=300)
|
||||
await gc_manager.start()
|
||||
|
||||
# 启动小智服务器门面
|
||||
# 启动小智服务器门面(支持WebSocket和MQTT)
|
||||
xiaozhi_server = XiaozhiServerFacade(config)
|
||||
ota_server = None
|
||||
ota_task = None
|
||||
@@ -77,7 +77,9 @@ async def main():
|
||||
# Facade.start() returns after every listener is ready. Await it so bind
|
||||
# or configuration failures stop startup instead of leaving a partial process.
|
||||
await xiaozhi_server.start()
|
||||
ota_server = SimpleHttpServer(config)
|
||||
ota_server = SimpleHttpServer(
|
||||
config, management_owner=xiaozhi_server
|
||||
)
|
||||
ota_task = asyncio.create_task(
|
||||
ota_server.start(), name="xiaozhi-http-server"
|
||||
)
|
||||
@@ -149,6 +151,22 @@ async def main():
|
||||
websocket_port,
|
||||
)
|
||||
|
||||
# MQTT信息
|
||||
mqtt_info = connection_info.get("mqtt", {})
|
||||
if mqtt_info.get("enabled", False):
|
||||
mqtt_port = mqtt_info.get("port", 1883)
|
||||
udp_port = mqtt_info.get("udp_port", 1883)
|
||||
logger.bind(tag=TAG).info(
|
||||
"MQTT地址是\t\tmqtt://{}:{}",
|
||||
get_local_ip(),
|
||||
mqtt_port,
|
||||
)
|
||||
logger.bind(tag=TAG).info(
|
||||
"UDP音频端口是\t{}:{}",
|
||||
get_local_ip(),
|
||||
udp_port,
|
||||
)
|
||||
|
||||
# 显示启用的协议
|
||||
enabled_protocols = xiaozhi_server.config.get("enabled_protocols", [])
|
||||
logger.bind(tag=TAG).info(f"启用的协议: {', '.join(enabled_protocols)}")
|
||||
@@ -161,6 +179,10 @@ async def main():
|
||||
"如想测试WebSocket请启动digital-human模块,打开浏览器交互测试"
|
||||
)
|
||||
|
||||
if "mqtt" in enabled_protocols:
|
||||
logger.bind(tag=TAG).info(
|
||||
"=======MQTT客户端ID格式: GID_test@@@mac_address@@@uuid======="
|
||||
)
|
||||
logger.bind(tag=TAG).info(
|
||||
"=============================================================\n"
|
||||
)
|
||||
|
||||
@@ -35,12 +35,75 @@ server:
|
||||
# 如果属于白名单内的设备,不校验token,直接放行
|
||||
allowed_devices:
|
||||
- "11:22:33:44:55:66"
|
||||
# MQTT网关配置,用于通过OTA下发到设备,根据mqtt_gateway的.env文件配置,格式为host:port
|
||||
# MQTT网关配置,用于通过OTA下发到设备,根据mqtt_gateway的.env文件配置,格式为host:port
|
||||
mqtt_gateway: null
|
||||
# MQTT签名密钥,用于生成MQTT连接密码,根据mqtt_gateway的.env文件配置
|
||||
mqtt_signature_key: null
|
||||
# UDP网关配置
|
||||
udp_gateway: null
|
||||
|
||||
# #####################################################################################
|
||||
# #############################协议配置(Protocol Configuration)########################
|
||||
# 支持WebSocket和MQTT两种协议,可以单独启用或同时启用
|
||||
protocols:
|
||||
# 启用的协议列表,可选值: ["websocket", "mqtt"]
|
||||
enabled_protocols: ["websocket"] # 默认只启用WebSocket
|
||||
# WebSocket协议开关
|
||||
websocket_enabled: true
|
||||
# MQTT协议开关
|
||||
mqtt_enabled: false
|
||||
|
||||
# 同时驻留的本地共享ASR模型上限(包含公共模型)。Agent差异化ASR会按配置复用。
|
||||
shared_asr_max_models: 3
|
||||
|
||||
# MQTT服务器配置(仅在mqtt_enabled为true时生效)
|
||||
mqtt_server:
|
||||
# 是否启用MQTT服务器
|
||||
enabled: false
|
||||
# MQTT服务器监听地址
|
||||
host: 0.0.0.0
|
||||
# MQTT服务器端口
|
||||
port: 1883
|
||||
# UDP音频传输端口(通常与MQTT端口相同)
|
||||
udp_port: 1883
|
||||
# UDP监听地址。留空时若public_endpoint是本机IPv4,会优先绑定该地址,
|
||||
# 确保设备收到的UDP回包源地址与hello中声明的地址一致。
|
||||
udp_bind_host: ""
|
||||
# 公网/局域网可达地址(设备连接时使用)
|
||||
public_endpoint: ""
|
||||
# MQTT签名密钥(用于生成连接密码)
|
||||
signature_key: ""
|
||||
# 最大连接数
|
||||
max_connections: 1000
|
||||
# 尚未完成CONNECT认证的连接上限;重复clientId替换不占用新的活跃名额
|
||||
max_pending_connections: 128
|
||||
# 心跳检查间隔(秒)
|
||||
heartbeat_interval: 30
|
||||
# 最大消息载荷大小(字节)
|
||||
max_payload_size: 8192
|
||||
# MQTT应用控制消息队列上限
|
||||
message_queue_size: 128
|
||||
# Hello等待业务运行时就绪的最长时间(秒)
|
||||
business_ready_timeout: 30
|
||||
# 发送goodbye和释放物理连接的最长等待时间(秒)
|
||||
goodbye_timeout: 1
|
||||
close_timeout: 2
|
||||
|
||||
# MQTT协议使用说明:
|
||||
# 1. 客户端ID格式:GID_test@@@mac_address@@@uuid 或 GID_test@@@mac_address
|
||||
# 例如:GID_test@@@aa:bb:cc:dd:ee:ff@@@unique_uuid_123
|
||||
# 2. 连接地址:mqtt://your.server.ip:1883
|
||||
# 3. 音频传输:通过UDP加密传输,配置信息在hello消息中返回
|
||||
# 4. 消息格式:JSON格式,支持hello、音频、文本等消息类型
|
||||
#
|
||||
# 启用MQTT的配置示例:
|
||||
# protocols:
|
||||
# enabled_protocols: ["websocket", "mqtt"] # 同时启用两种协议
|
||||
# mqtt_enabled: true
|
||||
# mqtt_server:
|
||||
# enabled: true
|
||||
# port: 1883
|
||||
# public_endpoint: "your.server.ip" # 替换为实际IP
|
||||
log:
|
||||
# 设置控制台输出的日志格式,时间、日志级别、标签、消息
|
||||
log_format: "<green>{time:YYMMDD HH:mm:ss}</green>[{version}_{selected_module}][<light-blue>{extra[tag]}</light-blue>]-<level>{level}</level>-<light-green>{message}</light-green>"
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import copy
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from config.logger import setup_logging
|
||||
from core.providers.tools.device_mcp import call_mcp_tool
|
||||
from core.utils.mqtt_auth import normalize_signature_key
|
||||
from core.utils.util import sanitize_tool_name
|
||||
|
||||
TAG = __name__
|
||||
|
||||
|
||||
class NativeMqttManagementHandler:
|
||||
def __init__(self, config: Dict[str, Any], management_owner: Any):
|
||||
self.config = config
|
||||
self.management_owner = management_owner
|
||||
self.logger = setup_logging()
|
||||
mqtt_config = config.get("mqtt_server", {})
|
||||
server_config = config.get("server", {})
|
||||
self.signature_key = normalize_signature_key(
|
||||
mqtt_config.get("manager_api_secret")
|
||||
or mqtt_config.get("signature_key")
|
||||
or server_config.get("mqtt_signature_key")
|
||||
)
|
||||
self.command_timeout = max(
|
||||
0.1, float(mqtt_config.get("manager_command_timeout", 5) or 5)
|
||||
)
|
||||
self.max_status_ids = max(
|
||||
1, int(mqtt_config.get("manager_max_status_ids", 1000) or 1000)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def generate_daily_tokens(
|
||||
signature_key: str, now: Optional[datetime] = None
|
||||
) -> set[str]:
|
||||
normalized = normalize_signature_key(signature_key)
|
||||
if not normalized:
|
||||
return set()
|
||||
current = now or datetime.now(timezone.utc)
|
||||
utc_date = current.astimezone(timezone.utc).date()
|
||||
return {
|
||||
hashlib.sha256(
|
||||
f"{utc_date + timedelta(days=offset)}{normalized}".encode(
|
||||
"utf-8"
|
||||
)
|
||||
).hexdigest()
|
||||
for offset in (-1, 0, 1)
|
||||
}
|
||||
|
||||
def _is_authorized(self, authorization: str) -> bool:
|
||||
if not self.signature_key:
|
||||
return False
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
return False
|
||||
provided = authorization[len("Bearer ") :].strip()
|
||||
return any(
|
||||
hmac.compare_digest(provided, expected)
|
||||
for expected in self.generate_daily_tokens(self.signature_key)
|
||||
)
|
||||
|
||||
def _authorize(self, request) -> Optional[web.Response]:
|
||||
if not self.signature_key:
|
||||
return self._error(
|
||||
503,
|
||||
"Native MQTT管理API未配置签名密钥",
|
||||
"MANAGEMENT_AUTH_NOT_CONFIGURED",
|
||||
False,
|
||||
)
|
||||
if not self._is_authorized(request.headers.get("Authorization", "")):
|
||||
return self._error(
|
||||
401, "无效的授权令牌", "UNAUTHORIZED", False
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _error(
|
||||
status: int,
|
||||
message: str,
|
||||
code: str,
|
||||
dispatch_attempted: bool,
|
||||
) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": message,
|
||||
"code": code,
|
||||
"dispatchAttempted": dispatch_attempted,
|
||||
},
|
||||
status=status,
|
||||
)
|
||||
|
||||
async def _read_json_object(self, request) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return None
|
||||
return body if isinstance(body, dict) else None
|
||||
|
||||
async def handle_device_status(self, request) -> web.Response:
|
||||
unauthorized = self._authorize(request)
|
||||
if unauthorized is not None:
|
||||
return unauthorized
|
||||
|
||||
body = await self._read_json_object(request)
|
||||
client_ids = body.get("clientIds") if body else None
|
||||
if (
|
||||
not isinstance(client_ids, list)
|
||||
or not client_ids
|
||||
or len(client_ids) > self.max_status_ids
|
||||
or any(
|
||||
not isinstance(client_id, str) or not client_id
|
||||
for client_id in client_ids
|
||||
)
|
||||
):
|
||||
return self._error(
|
||||
400,
|
||||
"clientIds必须是非空字符串数组且未超过数量限制",
|
||||
"INVALID_CLIENT_IDS",
|
||||
False,
|
||||
)
|
||||
|
||||
get_status = getattr(
|
||||
self.management_owner, "get_native_mqtt_status", None
|
||||
)
|
||||
if not callable(get_status):
|
||||
return self._error(
|
||||
503,
|
||||
"Native MQTT管理服务未就绪",
|
||||
"MANAGEMENT_NOT_READY",
|
||||
False,
|
||||
)
|
||||
return web.json_response(await get_status(client_ids))
|
||||
|
||||
async def handle_command(self, request) -> web.Response:
|
||||
unauthorized = self._authorize(request)
|
||||
if unauthorized is not None:
|
||||
return unauthorized
|
||||
|
||||
body = await self._read_json_object(request)
|
||||
payload = (
|
||||
body.get("payload")
|
||||
if body and body.get("type") == "mcp"
|
||||
else None
|
||||
)
|
||||
if not isinstance(payload, dict):
|
||||
return self._error(
|
||||
400, "指令类型无效", "INVALID_COMMAND", False
|
||||
)
|
||||
|
||||
resolver = getattr(
|
||||
self.management_owner, "resolve_native_mqtt_connection", None
|
||||
)
|
||||
if not callable(resolver):
|
||||
return self._error(
|
||||
503,
|
||||
"Native MQTT管理服务未就绪",
|
||||
"MANAGEMENT_NOT_READY",
|
||||
False,
|
||||
)
|
||||
|
||||
connection = await resolver(request.match_info.get("client_id", ""))
|
||||
if connection is None:
|
||||
return self._error(
|
||||
404, "设备未连接", "DEVICE_OFFLINE", False
|
||||
)
|
||||
|
||||
method = payload.get("method")
|
||||
params = payload.get("params") or {}
|
||||
if not isinstance(params, dict):
|
||||
return self._error(
|
||||
400, "MCP参数格式无效", "INVALID_MCP_PARAMS", False
|
||||
)
|
||||
if method == "tools/list":
|
||||
return await self._list_tools(connection.context)
|
||||
if method == "tools/call":
|
||||
return await self._call_tool(connection.context, params)
|
||||
return self._error(
|
||||
422, "不支持的MCP方法", "UNSUPPORTED_MCP_METHOD", False
|
||||
)
|
||||
|
||||
async def handle_call_request(self, request) -> web.Response:
|
||||
unauthorized = self._authorize(request)
|
||||
if unauthorized is not None:
|
||||
return unauthorized
|
||||
|
||||
body = await self._read_json_object(request)
|
||||
caller_mac = body.get("caller_mac") if body else None
|
||||
target_mac = body.get("target_mac") if body else None
|
||||
caller_nickname = body.get("caller_nickname", "") if body else ""
|
||||
if (
|
||||
not isinstance(caller_mac, str)
|
||||
or not caller_mac.strip()
|
||||
or not isinstance(target_mac, str)
|
||||
or not target_mac.strip()
|
||||
or not isinstance(caller_nickname, str)
|
||||
):
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "缺少必要参数: caller_mac, target_mac",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
request_call = getattr(
|
||||
self.management_owner, "request_native_mqtt_call", None
|
||||
)
|
||||
if not callable(request_call):
|
||||
return web.json_response(
|
||||
{"status": "error", "message": "Native MQTT呼叫服务未就绪"},
|
||||
status=503,
|
||||
)
|
||||
result = await request_call(
|
||||
caller_mac, target_mac, caller_nickname
|
||||
)
|
||||
return web.json_response(result)
|
||||
|
||||
async def handle_call_accept(self, request) -> web.Response:
|
||||
unauthorized = self._authorize(request)
|
||||
if unauthorized is not None:
|
||||
return unauthorized
|
||||
|
||||
body = await self._read_json_object(request)
|
||||
device_id = body.get("mac") if body else None
|
||||
if not isinstance(device_id, str) or not device_id.strip():
|
||||
return web.json_response(
|
||||
{"status": "error", "message": "缺少必要参数: mac"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
accept_call = getattr(
|
||||
self.management_owner, "accept_native_mqtt_call", None
|
||||
)
|
||||
if not callable(accept_call):
|
||||
return web.json_response(
|
||||
{"status": "error", "message": "Native MQTT呼叫服务未就绪"},
|
||||
status=503,
|
||||
)
|
||||
return web.json_response(await accept_call(device_id))
|
||||
|
||||
async def _list_tools(self, context) -> web.Response:
|
||||
mcp_client = getattr(context, "mcp_client", None)
|
||||
if mcp_client is None or not await mcp_client.is_ready():
|
||||
return self._error(
|
||||
503,
|
||||
"设备MCP尚未准备就绪",
|
||||
"MCP_NOT_READY",
|
||||
False,
|
||||
)
|
||||
|
||||
async with mcp_client.lock:
|
||||
tools = [
|
||||
copy.deepcopy(tool)
|
||||
for tool in mcp_client.tools.values()
|
||||
]
|
||||
return web.json_response(
|
||||
{"success": True, "data": {"tools": tools}}
|
||||
)
|
||||
|
||||
async def _call_tool(self, context, params: Dict[str, Any]) -> web.Response:
|
||||
tool_name = params.get("name")
|
||||
arguments = params.get("arguments", {})
|
||||
if not isinstance(tool_name, str) or not tool_name:
|
||||
return self._error(
|
||||
422, "工具名称不能为空", "INVALID_TOOL_NAME", False
|
||||
)
|
||||
if not isinstance(arguments, dict):
|
||||
return self._error(
|
||||
422, "工具参数必须是对象", "INVALID_TOOL_ARGUMENTS", False
|
||||
)
|
||||
|
||||
mcp_client = getattr(context, "mcp_client", None)
|
||||
if mcp_client is None or not await mcp_client.is_ready():
|
||||
return self._error(
|
||||
503,
|
||||
"设备MCP尚未准备就绪",
|
||||
"MCP_NOT_READY",
|
||||
False,
|
||||
)
|
||||
|
||||
sanitized_name = sanitize_tool_name(tool_name)
|
||||
if not mcp_client.has_tool(sanitized_name):
|
||||
return self._error(
|
||||
422, "设备不存在该工具", "TOOL_NOT_FOUND", False
|
||||
)
|
||||
|
||||
try:
|
||||
result = await call_mcp_tool(
|
||||
context,
|
||||
mcp_client,
|
||||
sanitized_name,
|
||||
arguments,
|
||||
timeout=self.command_timeout,
|
||||
return_raw=True,
|
||||
)
|
||||
except TimeoutError:
|
||||
return self._error(
|
||||
504, "工具调用请求超时", "COMMAND_TIMEOUT", True
|
||||
)
|
||||
except ConnectionError:
|
||||
return self._error(
|
||||
503, "设备连接已关闭", "DEVICE_DISCONNECTED", True
|
||||
)
|
||||
except ValueError as error:
|
||||
return self._error(422, str(error), "INVALID_TOOL_CALL", False)
|
||||
except Exception as error:
|
||||
self.logger.bind(tag=TAG).warning(
|
||||
"Native MQTT设备工具调用失败: {}", error
|
||||
)
|
||||
return self._error(
|
||||
502, str(error), "TOOL_CALL_FAILED", True
|
||||
)
|
||||
|
||||
data = (
|
||||
result
|
||||
if isinstance(result, dict)
|
||||
else {"content": [{"type": "text", "text": str(result)}]}
|
||||
)
|
||||
return web.json_response({"success": True, "data": data})
|
||||
@@ -1,8 +1,6 @@
|
||||
import json
|
||||
import time
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
import glob
|
||||
@@ -11,6 +9,11 @@ from aiohttp import web
|
||||
|
||||
from core.auth import AuthManager
|
||||
from core.utils.util import get_local_ip, get_vision_url
|
||||
from core.utils.mqtt_auth import (
|
||||
generate_password_signature,
|
||||
normalize_signature_key,
|
||||
parse_mqtt_endpoint,
|
||||
)
|
||||
from core.api.base_handler import BaseHandler
|
||||
|
||||
TAG = __name__
|
||||
@@ -102,26 +105,6 @@ class OTAHandler(BaseHandler):
|
||||
self.logger.bind(tag=TAG).error(f"刷新固件缓存失败: {e}")
|
||||
# keep previous cache if any
|
||||
|
||||
def generate_password_signature(self, content: str, secret_key: str) -> str:
|
||||
"""生成MQTT密码签名
|
||||
|
||||
Args:
|
||||
content: 签名内容 (clientId + '|' + username)
|
||||
secret_key: 密钥
|
||||
|
||||
Returns:
|
||||
str: Base64编码的HMAC-SHA256签名
|
||||
"""
|
||||
try:
|
||||
hmac_obj = hmac.new(
|
||||
secret_key.encode("utf-8"), content.encode("utf-8"), hashlib.sha256
|
||||
)
|
||||
signature = hmac_obj.digest()
|
||||
return base64.b64encode(signature).decode("utf-8")
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"生成MQTT密码签名失败: {e}")
|
||||
return ""
|
||||
|
||||
def _get_websocket_url(self, local_ip: str, port: int) -> str:
|
||||
"""获取websocket地址
|
||||
|
||||
@@ -231,11 +214,32 @@ class OTAHandler(BaseHandler):
|
||||
},
|
||||
}
|
||||
|
||||
# existing mqtt/websocket logic (unchanged)
|
||||
mqtt_gateway_endpoint = server_config.get("mqtt_gateway")
|
||||
# ========== 协议下发逻辑 ==========
|
||||
# 按照原版逻辑:总是下发 WebSocket,如果启用了 MQTT 则额外下发 MQTT 和 UDP
|
||||
# 这样设备有回退能力:如果 MQTT 连接失败,还可以使用 WebSocket
|
||||
|
||||
if mqtt_gateway_endpoint: # 如果配置了非空字符串
|
||||
# 尝试从请求数据中获取设备型号(已解析 above)
|
||||
mqtt_server_config = self.config.get("mqtt_server", {})
|
||||
enabled_protocols = self.config.get("enabled_protocols")
|
||||
if isinstance(enabled_protocols, list):
|
||||
mqtt_protocol_enabled = "mqtt" in enabled_protocols
|
||||
else:
|
||||
protocol_config = self.config.get("protocols", {})
|
||||
requested_protocols = protocol_config.get(
|
||||
"enabled_protocols", []
|
||||
)
|
||||
mqtt_protocol_enabled = (
|
||||
protocol_config.get("mqtt_enabled") is True
|
||||
or "mqtt" in requested_protocols
|
||||
)
|
||||
mqtt_server_enabled = bool(
|
||||
mqtt_server_config.get("enabled") and mqtt_protocol_enabled
|
||||
)
|
||||
mqtt_gateway_endpoint = server_config.get("mqtt_gateway")
|
||||
if not mqtt_gateway_endpoint or str(mqtt_gateway_endpoint).lower() == "null":
|
||||
mqtt_gateway_endpoint = None
|
||||
|
||||
# 生成通用的 MQTT 凭证信息
|
||||
def _build_mqtt_credentials():
|
||||
try:
|
||||
group_id = f"GID_{device_model}".replace(":", "_").replace(" ", "_")
|
||||
except Exception as e:
|
||||
@@ -246,56 +250,116 @@ class OTAHandler(BaseHandler):
|
||||
mqtt_client_id = f"{group_id}@@@{mac_address_safe}@@@{mac_address_safe}"
|
||||
|
||||
# 构建用户数据
|
||||
user_data = {"ip": "unknown"}
|
||||
user_data = {"ip": local_ip}
|
||||
try:
|
||||
user_data_json = json.dumps(user_data)
|
||||
username = base64.b64encode(user_data_json.encode("utf-8")).decode(
|
||||
"utf-8"
|
||||
)
|
||||
username = base64.b64encode(user_data_json.encode("utf-8")).decode("utf-8")
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"生成用户名失败: {e}")
|
||||
username = ""
|
||||
|
||||
return group_id, mac_address_safe, mqtt_client_id, username
|
||||
|
||||
# ========== 1. 总是下发 WebSocket 配置(作为基础/回退方案)==========
|
||||
ws_token = ""
|
||||
if self.auth_enable:
|
||||
if self.allowed_devices:
|
||||
if device_id not in self.allowed_devices:
|
||||
ws_token = self.auth.generate_token(client_id, device_id)
|
||||
else:
|
||||
ws_token = self.auth.generate_token(client_id, device_id)
|
||||
|
||||
return_json["websocket"] = {
|
||||
"url": self._get_websocket_url(local_ip, websocket_port),
|
||||
"token": ws_token,
|
||||
}
|
||||
|
||||
# ========== 2. 如果启用了原生 MQTT 服务器,额外下发 MQTT 配置 ==========
|
||||
signature_key = normalize_signature_key(
|
||||
mqtt_server_config.get("signature_key")
|
||||
or server_config.get("mqtt_signature_key")
|
||||
)
|
||||
native_mqtt_ready = bool(mqtt_server_enabled and signature_key)
|
||||
if mqtt_server_enabled and not signature_key:
|
||||
self.logger.bind(tag=TAG).error(
|
||||
"原生MQTT已启用但未配置签名密钥,跳过Native配置下发"
|
||||
)
|
||||
|
||||
if native_mqtt_ready:
|
||||
try:
|
||||
mqtt_host, mqtt_port = parse_mqtt_endpoint(
|
||||
mqtt_server_config.get("public_endpoint"),
|
||||
int(mqtt_server_config.get("port", 1883)),
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
self.logger.bind(tag=TAG).error(f"MQTT endpoint配置无效: {exc}")
|
||||
mqtt_host, mqtt_port = "", None
|
||||
|
||||
placeholder_keywords = ("localhost", "0.0.0.0", "your", "example")
|
||||
if not mqtt_host or any(
|
||||
keyword in mqtt_host.lower() for keyword in placeholder_keywords
|
||||
):
|
||||
mqtt_host = local_ip
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"检测到 public_endpoint 为占位符,自动使用本地IP: {local_ip}"
|
||||
)
|
||||
|
||||
if mqtt_port is None:
|
||||
native_mqtt_ready = False
|
||||
|
||||
if native_mqtt_ready:
|
||||
|
||||
group_id, mac_address_safe, mqtt_client_id, username = _build_mqtt_credentials()
|
||||
|
||||
mqtt_password = generate_password_signature(
|
||||
mqtt_client_id + "|" + username, signature_key
|
||||
)
|
||||
|
||||
return_json["mqtt"] = {
|
||||
"endpoint": f"{mqtt_host}:{mqtt_port}",
|
||||
"client_id": mqtt_client_id,
|
||||
"username": username,
|
||||
"password": mqtt_password,
|
||||
"publish_topic": "device-server",
|
||||
"subscribe_topic": f"devices/p2p/{mac_address_safe}",
|
||||
}
|
||||
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"为设备 {device_id} 下发原生MQTT配置: {mqtt_host}:{mqtt_port}"
|
||||
)
|
||||
|
||||
# ========== 3. 如果配置了外部 MQTT 网关,额外下发 MQTT 配置 ==========
|
||||
elif mqtt_gateway_endpoint:
|
||||
group_id, mac_address_safe, mqtt_client_id, username = _build_mqtt_credentials()
|
||||
|
||||
# 生成密码
|
||||
password = ""
|
||||
mqtt_password = ""
|
||||
signature_key = server_config.get("mqtt_signature_key", "")
|
||||
if signature_key:
|
||||
password = self.generate_password_signature(
|
||||
mqtt_password = generate_password_signature(
|
||||
mqtt_client_id + "|" + username, signature_key
|
||||
)
|
||||
if not password:
|
||||
password = "" # 签名失败则留空,由设备决定是否允许无密码
|
||||
if not mqtt_password:
|
||||
mqtt_password = ""
|
||||
else:
|
||||
self.logger.bind(tag=TAG).warning("缺少MQTT签名密钥,密码留空")
|
||||
|
||||
# 构建MQTT配置(直接使用 mqtt_gateway 字符串)
|
||||
return_json["mqtt"] = {
|
||||
"endpoint": mqtt_gateway_endpoint,
|
||||
"client_id": mqtt_client_id,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"password": mqtt_password,
|
||||
"publish_topic": "device-server",
|
||||
"subscribe_topic": f"devices/p2p/{mac_address_safe}",
|
||||
}
|
||||
self.logger.bind(tag=TAG).info(f"为设备 {device_id} 下发MQTT网关配置")
|
||||
|
||||
else: # 未配置 mqtt_gateway,下发 WebSocket
|
||||
# 如果开启了认证,则进行认证校验
|
||||
token = ""
|
||||
if self.auth_enable:
|
||||
if self.allowed_devices:
|
||||
if device_id not in self.allowed_devices:
|
||||
token = self.auth.generate_token(client_id, device_id)
|
||||
else:
|
||||
token = self.auth.generate_token(client_id, device_id)
|
||||
# NOTE: use websocket_port here
|
||||
return_json["websocket"] = {
|
||||
"url": self._get_websocket_url(local_ip, websocket_port),
|
||||
"token": token,
|
||||
}
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"未配置MQTT网关,为设备 {device_id} 下发WebSocket配置"
|
||||
)
|
||||
self.logger.bind(tag=TAG).info(f"为设备 {device_id} 下发MQTT网关配置: {mqtt_gateway_endpoint}")
|
||||
|
||||
# 记录最终下发的协议
|
||||
protocols = ["websocket"]
|
||||
if "mqtt" in return_json:
|
||||
protocols.append("mqtt")
|
||||
self.logger.bind(tag=TAG).info(f"为设备 {device_id} 下发协议配置: {', '.join(protocols)}")
|
||||
|
||||
# Now check firmware files for updates
|
||||
try:
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
from config.logger import setup_logging
|
||||
from core.api.native_mqtt_management_handler import (
|
||||
NativeMqttManagementHandler,
|
||||
)
|
||||
from core.api.ota_handler import OTAHandler
|
||||
from core.api.vision_handler import VisionHandler
|
||||
|
||||
@@ -8,17 +11,32 @@ TAG = __name__
|
||||
|
||||
|
||||
class SimpleHttpServer:
|
||||
def __init__(self, config: dict):
|
||||
def __init__(self, config: dict, management_owner=None):
|
||||
self.config = config
|
||||
self.management_owner = management_owner
|
||||
self.logger = setup_logging()
|
||||
self.ota_handler = OTAHandler(config)
|
||||
self.vision_handler = VisionHandler(config)
|
||||
self.native_mqtt_management_handler = (
|
||||
NativeMqttManagementHandler(config, management_owner)
|
||||
if management_owner is not None and self._native_mqtt_enabled()
|
||||
else None
|
||||
)
|
||||
self._started_event = asyncio.Event()
|
||||
self._stop_event = asyncio.Event()
|
||||
self._runner = None
|
||||
self._start_active = False
|
||||
self._cleanup_lock = asyncio.Lock()
|
||||
|
||||
def _native_mqtt_enabled(self) -> bool:
|
||||
mqtt_config = self.config.get("mqtt_server", {})
|
||||
enabled_protocols = self.config.get("enabled_protocols", [])
|
||||
return (
|
||||
isinstance(mqtt_config, dict)
|
||||
and mqtt_config.get("enabled") is True
|
||||
and "mqtt" in enabled_protocols
|
||||
)
|
||||
|
||||
async def wait_started(self, task: asyncio.Task, timeout: float = 10) -> None:
|
||||
"""Wait until the HTTP listener is bound or surface startup failure."""
|
||||
event_waiter = asyncio.create_task(self._started_event.wait())
|
||||
@@ -71,7 +89,17 @@ class SimpleHttpServer:
|
||||
port = int(server_config.get("http_port", 8003))
|
||||
|
||||
if port:
|
||||
app = web.Application()
|
||||
mqtt_config = self.config.get("mqtt_server", {})
|
||||
client_max_size = max(
|
||||
1024,
|
||||
int(
|
||||
mqtt_config.get(
|
||||
"manager_max_request_size", 64 * 1024
|
||||
)
|
||||
or 64 * 1024
|
||||
),
|
||||
)
|
||||
app = web.Application(client_max_size=client_max_size)
|
||||
|
||||
if not read_config_from_api:
|
||||
# 如果没有开启智控台,只是单模块运行,就需要再添加简单OTA接口,用于下发websocket接口
|
||||
@@ -105,6 +133,28 @@ class SimpleHttpServer:
|
||||
),
|
||||
]
|
||||
)
|
||||
if self.native_mqtt_management_handler is not None:
|
||||
app.add_routes(
|
||||
[
|
||||
web.post(
|
||||
"/api/devices/status",
|
||||
self.native_mqtt_management_handler.handle_device_status,
|
||||
),
|
||||
web.post(
|
||||
"/api/commands/{client_id}",
|
||||
self.native_mqtt_management_handler.handle_command,
|
||||
),
|
||||
web.post(
|
||||
"/api/call/request",
|
||||
self.native_mqtt_management_handler.handle_call_request,
|
||||
),
|
||||
web.post(
|
||||
"/api/call/accept",
|
||||
self.native_mqtt_management_handler.handle_call_accept,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# 运行服务
|
||||
runner = web.AppRunner(app)
|
||||
self._runner = runner
|
||||
|
||||
@@ -23,8 +23,20 @@ class GoodbyeProcessor(MessageProcessor):
|
||||
|
||||
if isinstance(msg_json, dict) and msg_json.get("type") == "goodbye":
|
||||
logger.info(f"收到goodbye: session_id={msg_json.get('session_id')}")
|
||||
# 长连接传输仅结束逻辑会话,短连接传输关闭连接。
|
||||
# WebSocket 直接关闭连接;MQTT/UDP 仅结束音频会话,保持连接
|
||||
if transport.keeps_connection_between_sessions:
|
||||
end_call = getattr(
|
||||
getattr(context, "server", None),
|
||||
"end_native_mqtt_call",
|
||||
None,
|
||||
)
|
||||
if callable(end_call):
|
||||
await end_call(
|
||||
context.device_id,
|
||||
"设备结束通话",
|
||||
notify_device=False,
|
||||
expected_session_id=msg_json.get("session_id"),
|
||||
)
|
||||
end_conversation = getattr(context, "end_conversation", None)
|
||||
if callable(end_conversation):
|
||||
await end_conversation(msg_json.get("session_id"))
|
||||
|
||||
@@ -15,6 +15,7 @@ from core.processors.abort_processor import AbortProcessor
|
||||
from core.processors.goodbye_processor import GoodbyeProcessor
|
||||
from core.processors.text_processor import TextProcessor
|
||||
from core.processors.ping_processor import PingProcessor
|
||||
from core.processors.udp_timeout_processor import UdpTimeoutProcessor
|
||||
from config.logger import setup_logging
|
||||
|
||||
logger = setup_logging()
|
||||
@@ -40,6 +41,7 @@ class MessageRouter(MessageProcessor):
|
||||
self.audio_receive_processor = AudioReceiveProcessor()
|
||||
self.text_processor = TextProcessor()
|
||||
self.ping_processor = PingProcessor()
|
||||
self.udp_timeout_processor = UdpTimeoutProcessor()
|
||||
|
||||
# 按优先级排序的processor列表
|
||||
self.processors: List[MessageProcessor] = [
|
||||
@@ -48,6 +50,7 @@ class MessageRouter(MessageProcessor):
|
||||
self.server_processor, # 服务器消息(管理端下发动作)
|
||||
self.abort_processor, # 中断消息
|
||||
self.goodbye_processor, # goodbye消息
|
||||
self.udp_timeout_processor,
|
||||
self.hello_processor, # hello消息
|
||||
self.ping_processor, # 可选JSON ping/pong心跳
|
||||
self.listen_processor, # listen消息
|
||||
|
||||
@@ -19,7 +19,7 @@ class TimeoutProcessor(MessageProcessor):
|
||||
context: SessionContext,
|
||||
transport: TransportInterface,
|
||||
) -> bool:
|
||||
"""End an expired logical conversation without closing a long connection."""
|
||||
"""End an expired logical conversation without conflating it with MQTT."""
|
||||
if not getattr(context, "conversation_active", False):
|
||||
return False
|
||||
|
||||
@@ -29,7 +29,7 @@ class TimeoutProcessor(MessageProcessor):
|
||||
|
||||
try:
|
||||
if transport.keeps_connection_between_sessions:
|
||||
logger.info(f"会话超时,结束长连接逻辑会话: {context.session_id}")
|
||||
logger.info(f"会话超时,结束MQTT逻辑会话: {context.session_id}")
|
||||
from core.processors.audio_receive_processor import (
|
||||
AudioReceiveProcessor,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from core.context.session_context import SessionContext
|
||||
from core.pipeline.message_pipeline import MessageProcessor
|
||||
from core.transport.transport_interface import TransportInterface
|
||||
from config.logger import setup_logging
|
||||
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
class UdpTimeoutProcessor(MessageProcessor):
|
||||
async def process(
|
||||
self,
|
||||
context: SessionContext,
|
||||
transport: TransportInterface,
|
||||
message: Any,
|
||||
) -> bool:
|
||||
if isinstance(message, str):
|
||||
try:
|
||||
message = json.loads(message)
|
||||
except json.JSONDecodeError:
|
||||
return False
|
||||
if not isinstance(message, dict) or message.get("type") != "udp_timeout":
|
||||
return False
|
||||
if not transport.keeps_connection_between_sessions:
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
"收到udp_timeout: session_id={}", message.get("session_id")
|
||||
)
|
||||
end_call = getattr(
|
||||
getattr(context, "server", None),
|
||||
"end_native_mqtt_call",
|
||||
None,
|
||||
)
|
||||
call_ended = False
|
||||
if callable(end_call):
|
||||
call_ended = await end_call(
|
||||
context.device_id,
|
||||
"设备UDP接收超时",
|
||||
notify_device=True,
|
||||
expected_session_id=message.get("session_id"),
|
||||
)
|
||||
if not call_ended:
|
||||
end_conversation = getattr(context, "end_conversation", None)
|
||||
if callable(end_conversation):
|
||||
await end_conversation(message.get("session_id"))
|
||||
await transport.end_session(
|
||||
message.get("session_id") or context.session_id
|
||||
)
|
||||
return True
|
||||
@@ -0,0 +1,649 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Dict, Any, Optional, Callable
|
||||
from config.logger import setup_logging
|
||||
from core.utils.mqtt_auth import validate_mqtt_credentials
|
||||
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
class MQTTConnection:
|
||||
"""
|
||||
MQTT连接处理类:管理单个MQTT客户端连接
|
||||
处理MQTT协议消息和会话管理
|
||||
"""
|
||||
|
||||
def __init__(self, socket, connection_id: int, mqtt_server, udp_handler=None, reader=None, writer=None):
|
||||
self.socket = socket
|
||||
self.reader = reader
|
||||
self.writer = writer
|
||||
self.connection_id = connection_id
|
||||
self.mqtt_server = mqtt_server
|
||||
self.udp_handler = udp_handler
|
||||
|
||||
# 连接信息
|
||||
self.client_id = None
|
||||
self.device_id = None
|
||||
self.username = None
|
||||
self.password = None
|
||||
self.session_id = None
|
||||
|
||||
# 协议状态
|
||||
self.is_connected_flag = False
|
||||
self.keep_alive_interval = 0
|
||||
self.last_activity = time.monotonic()
|
||||
|
||||
# 消息处理
|
||||
self.message_callback = None
|
||||
self.reply_topic = None
|
||||
|
||||
# UDP相关
|
||||
self.udp_config = None
|
||||
self.message_queue_size = int(
|
||||
getattr(mqtt_server, "message_queue_size", 128) or 128
|
||||
)
|
||||
self.business_ready_timeout = float(
|
||||
getattr(mqtt_server, "business_ready_timeout", 30) or 30
|
||||
)
|
||||
self.close_timeout = max(
|
||||
0.1, float(getattr(mqtt_server, "close_timeout", 2) or 2)
|
||||
)
|
||||
self.goodbye_timeout = max(
|
||||
0.1, float(getattr(mqtt_server, "goodbye_timeout", 1) or 1)
|
||||
)
|
||||
|
||||
# 任务管理
|
||||
self.keep_alive_task = None
|
||||
self.business_task = None
|
||||
self._closed = False
|
||||
self._close_task = None
|
||||
self._close_initiator = None
|
||||
self._close_complete = asyncio.Event()
|
||||
self.connect_processed_event = asyncio.Event()
|
||||
self.connect_accepted = False
|
||||
self.business_ready_event = asyncio.Event()
|
||||
self._hello_business_ready_event = None
|
||||
self._hello_business_session_id = None
|
||||
self._logical_hello_received = False
|
||||
self._startup_recovery_task = None
|
||||
self._session_transition_lock = asyncio.Lock()
|
||||
self._last_goodbye_session_id = None
|
||||
self._goodbye_lock = asyncio.Lock()
|
||||
|
||||
# 创建MQTT协议处理器
|
||||
from core.protocols.mqtt_protocol import MQTTProtocol
|
||||
self.protocol = MQTTProtocol(
|
||||
socket=socket,
|
||||
reader=reader,
|
||||
writer=writer,
|
||||
max_payload_size=getattr(mqtt_server, "max_payload_size", 8192),
|
||||
event_queue_size=self.message_queue_size,
|
||||
close_timeout=self.close_timeout,
|
||||
)
|
||||
self._setup_protocol_handlers()
|
||||
|
||||
def _setup_protocol_handlers(self):
|
||||
"""设置协议事件处理"""
|
||||
self.protocol.on('connect', self._handle_connect)
|
||||
self.protocol.on('publish', self._handle_publish)
|
||||
self.protocol.on('subscribe', self._handle_subscribe)
|
||||
self.protocol.on('disconnect', self._handle_disconnect)
|
||||
self.protocol.on('close', self._handle_close)
|
||||
self.protocol.on('error', self._handle_error)
|
||||
self.protocol.on('protocolError', self._handle_error)
|
||||
self.protocol.on('activity', self._handle_activity)
|
||||
|
||||
def _handle_activity(self):
|
||||
"""更新最近活动时间(用于心跳保活)"""
|
||||
self.last_activity = time.monotonic()
|
||||
|
||||
async def _handle_connect(self, connect_data: Dict[str, Any]):
|
||||
"""处理CONNECT消息"""
|
||||
try:
|
||||
if self.connect_processed_event.is_set() or self.is_connected_flag:
|
||||
logger.warning("同一TCP连接收到重复CONNECT,关闭连接")
|
||||
await self.close()
|
||||
return
|
||||
self.client_id = connect_data['clientId']
|
||||
self.username = connect_data.get('username')
|
||||
self.password = connect_data.get('password')
|
||||
self.keep_alive_interval = connect_data.get('keepAlive', 0) * 1000 # 转换为毫秒
|
||||
|
||||
logger.info(f"MQTT客户端连接: {self.client_id}")
|
||||
|
||||
try:
|
||||
validate_mqtt_credentials(
|
||||
self.client_id,
|
||||
self.username,
|
||||
self.password,
|
||||
self.mqtt_server.signature_key,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(f"MQTT客户端认证失败: {self.client_id}, {e}")
|
||||
await self.protocol.send_connack(4)
|
||||
self.connect_processed_event.set()
|
||||
await self.close()
|
||||
return False
|
||||
|
||||
# 解析客户端ID获取设备信息
|
||||
if not self._parse_client_id():
|
||||
await self.protocol.send_connack(1) # 连接被拒绝
|
||||
self.connect_processed_event.set()
|
||||
await self.close()
|
||||
return False
|
||||
|
||||
# 生成会话ID
|
||||
self.session_id = str(uuid.uuid4())
|
||||
|
||||
# 设置回复主题
|
||||
self.reply_topic = f"devices/p2p/{self.device_id.replace(':', '_')}"
|
||||
|
||||
async def complete_acceptance():
|
||||
# Keep this inside the server's per-client takeover lock. A
|
||||
# newer connection cannot reclaim this clientId between the
|
||||
# success CONNACK and publishing the active owner.
|
||||
self.is_connected_flag = True
|
||||
try:
|
||||
await self.protocol.send_connack(0)
|
||||
except Exception:
|
||||
self.is_connected_flag = False
|
||||
raise
|
||||
if self.keep_alive_interval > 0:
|
||||
self.keep_alive_task = asyncio.create_task(
|
||||
self._keep_alive_check()
|
||||
)
|
||||
self.connect_accepted = True
|
||||
|
||||
accepted = await self.mqtt_server.on_client_connected(
|
||||
self, complete_acceptance
|
||||
)
|
||||
if not accepted:
|
||||
if not self._closed:
|
||||
await self.protocol.send_connack(3) # 服务端暂不可用
|
||||
self.connect_processed_event.set()
|
||||
await self.close()
|
||||
return False
|
||||
self.connect_processed_event.set()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理CONNECT消息失败: {e}")
|
||||
self.connect_processed_event.set()
|
||||
await self.close()
|
||||
return False
|
||||
|
||||
def _parse_client_id(self) -> bool:
|
||||
"""解析客户端ID获取设备信息"""
|
||||
try:
|
||||
# 支持格式: GID_test@@@mac_address@@@uuid 或 GID_test@@@mac_address
|
||||
parts = self.client_id.split('@@@')
|
||||
|
||||
if len(parts) >= 2:
|
||||
self.group_id = parts[0]
|
||||
# 将设备标识统一为服务端使用的MAC地址格式
|
||||
self.device_id = (
|
||||
parts[1].replace('_', ':').replace('-', ':').lower()
|
||||
)
|
||||
|
||||
if len(parts) >= 3:
|
||||
self.uuid = parts[2]
|
||||
|
||||
return True
|
||||
else:
|
||||
logger.error(f"无效的客户端ID格式: {self.client_id}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"解析客户端ID失败: {e}")
|
||||
return False
|
||||
|
||||
async def _handle_publish(self, publish_data: Dict[str, Any]):
|
||||
"""处理PUBLISH消息"""
|
||||
try:
|
||||
if publish_data.get('qos', 0) != 0:
|
||||
logger.warning(
|
||||
f"不支持的MQTT QoS级别: {publish_data.get('qos')}"
|
||||
)
|
||||
await self.close()
|
||||
return
|
||||
topic = publish_data['topic']
|
||||
payload = publish_data['payload']
|
||||
|
||||
logger.debug(f"收到MQTT发布消息: topic={topic}, payload={payload}")
|
||||
|
||||
# 更新活动时间
|
||||
self.last_activity = time.monotonic()
|
||||
|
||||
# 解析JSON消息
|
||||
try:
|
||||
message_data = json.loads(payload)
|
||||
|
||||
# 处理不同类型的消息
|
||||
if message_data.get('type') == 'hello':
|
||||
if message_data.get('version', 3) != 3:
|
||||
logger.warning(
|
||||
f"不支持的MQTT协议版本: {message_data.get('version')}"
|
||||
)
|
||||
await self.close()
|
||||
return
|
||||
await self._handle_hello_message(message_data)
|
||||
else:
|
||||
# 其他消息通过回调处理
|
||||
if self.message_callback:
|
||||
self.message_callback(topic, payload)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"MQTT消息JSON解析失败: {payload}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理PUBLISH消息失败: {e}")
|
||||
|
||||
async def _handle_hello_message(self, message_data: Dict[str, Any]):
|
||||
"""处理hello消息,初始化UDP配置"""
|
||||
async with self._session_transition_lock:
|
||||
self._logical_hello_received = True
|
||||
try:
|
||||
handle_logical_hello = getattr(
|
||||
self.mqtt_server, "handle_logical_hello", None
|
||||
)
|
||||
if callable(handle_logical_hello):
|
||||
await handle_logical_hello(self)
|
||||
# Match the gateway contract: do not advertise a usable audio
|
||||
# channel until private config and runtime components are ready.
|
||||
await asyncio.wait_for(
|
||||
self.business_ready_event.wait(),
|
||||
timeout=self.business_ready_timeout,
|
||||
)
|
||||
if self._closed or not self.is_connected_flag:
|
||||
return
|
||||
hello_reply = self._prepare_hello_reply(
|
||||
message_data.get('audio_params', {}),
|
||||
message_data.get('version', 3),
|
||||
)
|
||||
hello_ready = asyncio.Event()
|
||||
self._hello_business_ready_event = hello_ready
|
||||
self._hello_business_session_id = self.session_id
|
||||
|
||||
# Enqueue the logical-session boundary before the device can react
|
||||
# to the reply with UDP audio.
|
||||
if self.message_callback:
|
||||
try:
|
||||
self.message_callback(self.reply_topic, json.dumps(message_data))
|
||||
except Exception as e:
|
||||
logger.error(f"转发hello消息失败: {e}")
|
||||
|
||||
# Long-lived MQTT connections can change Agent configuration at
|
||||
# each logical Hello. Do not expose the new UDP session until the
|
||||
# business runtime has either refreshed or deliberately retained
|
||||
# the previous healthy runtime.
|
||||
await asyncio.wait_for(
|
||||
hello_ready.wait(),
|
||||
timeout=self.business_ready_timeout,
|
||||
)
|
||||
if self._closed or not self.is_connected_flag:
|
||||
return
|
||||
await self.send_message(self.reply_topic, json.dumps(hello_reply))
|
||||
|
||||
logger.info(f"MQTT Hello消息处理完成: {self.client_id}")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(
|
||||
f"MQTT Hello等待业务运行时超时: {self.client_id}, "
|
||||
f"timeout={self.business_ready_timeout}s"
|
||||
)
|
||||
await self.close()
|
||||
except Exception as e:
|
||||
logger.error(f"处理hello消息失败: {e}")
|
||||
finally:
|
||||
self._hello_business_ready_event = None
|
||||
self._hello_business_session_id = None
|
||||
|
||||
def schedule_stale_session_recovery(self, delay: float = 1.0) -> None:
|
||||
"""Return a reconnected device with a stale UDP session to Idle."""
|
||||
if self._startup_recovery_task is not None:
|
||||
return
|
||||
self._startup_recovery_task = asyncio.create_task(
|
||||
self._recover_stale_session(delay)
|
||||
)
|
||||
|
||||
async def _recover_stale_session(self, delay: float) -> None:
|
||||
try:
|
||||
await asyncio.sleep(max(0.0, delay))
|
||||
async with self._session_transition_lock:
|
||||
if (
|
||||
self._closed
|
||||
or not self.is_connected_flag
|
||||
or self._logical_hello_received
|
||||
or not self.reply_topic
|
||||
):
|
||||
return
|
||||
# No session id is intentional: firmware accepts this as a
|
||||
# connection-level reset and discards an UDP session owned by
|
||||
# a previous server process. Serialize it with Hello so this
|
||||
# reset can never overtake a newly negotiated session.
|
||||
await self.send_message(
|
||||
self.reply_topic,
|
||||
json.dumps({"type": "goodbye"}),
|
||||
)
|
||||
logger.info("已通知重连MQTT设备清理旧UDP会话: {}", self.client_id)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("通知重连MQTT设备清理旧会话失败: {}", e)
|
||||
|
||||
def mark_business_session_ready(self, session_id: str = None) -> None:
|
||||
"""Acknowledge readiness for the currently pending logical Hello."""
|
||||
event = self._hello_business_ready_event
|
||||
pending_session_id = self._hello_business_session_id
|
||||
if event is None:
|
||||
return
|
||||
if session_id is not None and pending_session_id != session_id:
|
||||
return
|
||||
event.set()
|
||||
|
||||
async def send_hello_reply(self, audio_params: Dict[str, Any], version: int = 3):
|
||||
"""发送hello回复(可在未收到设备hello时调用)"""
|
||||
hello_reply = self._prepare_hello_reply(audio_params, version)
|
||||
await self.send_message(self.reply_topic, json.dumps(hello_reply))
|
||||
|
||||
def _prepare_hello_reply(self, audio_params: Dict[str, Any], version: int = 3):
|
||||
"""Create and install one UDP session without publishing it yet."""
|
||||
import os
|
||||
|
||||
self.session_id = str(uuid.uuid4())
|
||||
self._last_goodbye_session_id = None
|
||||
udp_session_id = self.mqtt_server.bind_udp_session(
|
||||
self, self.udp_handler
|
||||
)
|
||||
nonce = self._generate_udp_header(
|
||||
0, 0, 0, connection_id=udp_session_id
|
||||
)
|
||||
self.udp_config = {
|
||||
'key': os.urandom(16),
|
||||
'encryption': 'aes-128-ctr',
|
||||
'server': self.mqtt_server.public_endpoint,
|
||||
'port': self.mqtt_server.udp_port,
|
||||
'nonce': nonce,
|
||||
'local_sequence': 0,
|
||||
'remote_sequence': 0
|
||||
}
|
||||
if self.udp_handler:
|
||||
self.udp_handler.configure_encryption(self.udp_config)
|
||||
|
||||
configured_audio_params = (
|
||||
getattr(self.mqtt_server, 'config', {})
|
||||
.get('xiaozhi', {})
|
||||
.get('audio_params', {})
|
||||
)
|
||||
hello_reply = {
|
||||
'type': 'hello',
|
||||
'version': version,
|
||||
'session_id': self.session_id,
|
||||
'transport': 'udp',
|
||||
'udp': {
|
||||
'server': self.udp_config['server'],
|
||||
'port': self.udp_config['port'],
|
||||
'encryption': self.udp_config['encryption'],
|
||||
'key': self.udp_config['key'].hex(),
|
||||
'nonce': nonce.hex()
|
||||
},
|
||||
'audio_params': configured_audio_params or audio_params or {}
|
||||
}
|
||||
|
||||
return hello_reply
|
||||
|
||||
def _generate_udp_header(
|
||||
self, length: int, timestamp: int, sequence: int,
|
||||
connection_id: int = None
|
||||
) -> bytes:
|
||||
header = bytearray(16)
|
||||
header[0] = 1 # type
|
||||
header[2:4] = length.to_bytes(2, 'big')
|
||||
udp_connection_id = connection_id or self.connection_id
|
||||
header[4:8] = udp_connection_id.to_bytes(4, 'big')
|
||||
header[8:12] = timestamp.to_bytes(4, 'big')
|
||||
header[12:16] = sequence.to_bytes(4, 'big')
|
||||
return bytes(header)
|
||||
|
||||
async def _handle_subscribe(self, subscribe_data: Dict[str, Any]):
|
||||
"""处理SUBSCRIBE消息"""
|
||||
try:
|
||||
topic = subscribe_data['topic']
|
||||
packet_id = subscribe_data['packetId']
|
||||
|
||||
logger.debug(f"客户端订阅主题: {topic}")
|
||||
|
||||
# 发送订阅确认
|
||||
await self.protocol.send_suback(packet_id, 0)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理SUBSCRIBE消息失败: {e}")
|
||||
|
||||
async def _handle_disconnect(self):
|
||||
"""处理DISCONNECT消息"""
|
||||
logger.info(f"客户端主动断开连接: {self.client_id}")
|
||||
await self.close()
|
||||
|
||||
async def _handle_close(self):
|
||||
"""处理连接关闭"""
|
||||
logger.info(f"MQTT连接关闭: {self.client_id}")
|
||||
await self.close()
|
||||
|
||||
async def _handle_error(self, error):
|
||||
"""处理连接错误"""
|
||||
logger.error(f"MQTT连接错误: {self.client_id}, error: {error}")
|
||||
await self.close()
|
||||
|
||||
async def _keep_alive_check(self):
|
||||
"""心跳检查任务"""
|
||||
try:
|
||||
while self.is_connected_flag and not self._closed:
|
||||
await asyncio.sleep(self.keep_alive_interval / 1000 / 2) # 检查间隔为心跳间隔的一半
|
||||
|
||||
current_time = time.monotonic()
|
||||
if current_time - self.last_activity > self.keep_alive_interval / 1000 * 1.5:
|
||||
logger.info(f"MQTT客户端心跳超时: {self.client_id}")
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self.notify_device_idle(),
|
||||
timeout=self.goodbye_timeout,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"MQTT心跳超时发送goodbye失败,继续关闭连接: {}", e
|
||||
)
|
||||
finally:
|
||||
await self.close()
|
||||
break
|
||||
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"心跳检查任务出错: {e}")
|
||||
|
||||
def set_message_callback(self, callback: Callable[[str, str], None]):
|
||||
"""设置消息接收回调"""
|
||||
self.message_callback = callback
|
||||
|
||||
async def send_message(self, topic: str, payload: str):
|
||||
"""发送MQTT消息"""
|
||||
if self._closed or not self.is_connected_flag:
|
||||
raise RuntimeError("MQTT connection is closed")
|
||||
|
||||
try:
|
||||
await self.protocol.send_publish(topic, payload, qos=0)
|
||||
logger.debug(f"发送MQTT消息: topic={topic}, payload={payload}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送MQTT消息失败: {e}")
|
||||
raise
|
||||
|
||||
async def notify_device_idle(self, session_id: str = None) -> bool:
|
||||
"""Send one session-scoped goodbye before the physical MQTT close."""
|
||||
async with self._goodbye_lock:
|
||||
target_session_id = session_id or self.session_id
|
||||
if (
|
||||
self._closed
|
||||
or not self.is_connected_flag
|
||||
or not self.udp_config
|
||||
or not self.reply_topic
|
||||
or not target_session_id
|
||||
or self._last_goodbye_session_id == target_session_id
|
||||
):
|
||||
return False
|
||||
|
||||
await self.send_message(
|
||||
self.reply_topic,
|
||||
json.dumps(
|
||||
{"type": "goodbye", "session_id": target_session_id}
|
||||
),
|
||||
)
|
||||
self._last_goodbye_session_id = target_session_id
|
||||
return True
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""检查连接状态"""
|
||||
return self.is_connected_flag and not self._closed
|
||||
|
||||
async def close(self):
|
||||
"""关闭连接"""
|
||||
if not self._closed:
|
||||
self._closed = True
|
||||
self.is_connected_flag = False
|
||||
self.connect_processed_event.set()
|
||||
self.business_ready_event.set()
|
||||
if self._hello_business_ready_event is not None:
|
||||
self._hello_business_ready_event.set()
|
||||
# Run cleanup in a dedicated task. Shielding it lets a cancelled
|
||||
# first caller leave without falsely completing the close barrier;
|
||||
# later callers can still await the same cleanup owner.
|
||||
self._close_initiator = asyncio.current_task()
|
||||
self._close_task = asyncio.create_task(self._close_impl())
|
||||
|
||||
close_task = self._close_task
|
||||
if close_task is None:
|
||||
return
|
||||
|
||||
current_task = asyncio.current_task()
|
||||
dependency_tasks = {
|
||||
close_task,
|
||||
getattr(self.protocol, "_processing_task", None),
|
||||
getattr(self.protocol, "_dispatch_task", None),
|
||||
}
|
||||
if self.business_task is not self._close_initiator:
|
||||
dependency_tasks.add(self.business_task)
|
||||
if self.keep_alive_task is not self._close_initiator:
|
||||
dependency_tasks.add(self.keep_alive_task)
|
||||
# The dedicated closer can be waiting for these tasks. Let them unwind
|
||||
# instead of creating a reverse wait cycle.
|
||||
if current_task in dependency_tasks:
|
||||
return
|
||||
await asyncio.shield(close_task)
|
||||
|
||||
async def _close_impl(self):
|
||||
"""Own and complete physical cleanup independently of caller lifetime."""
|
||||
try:
|
||||
current_task = asyncio.current_task()
|
||||
|
||||
# The socket/protocol task can time out while ConnectionService is
|
||||
# blocked in private config or component initialization. Cancel the
|
||||
# owning server task so its finally block releases the SessionContext
|
||||
# and any partially initialized runtime instead of leaking per retry.
|
||||
if (
|
||||
self.business_task
|
||||
and self.business_task is not current_task
|
||||
and self.business_task is not self._close_initiator
|
||||
and not self.business_task.done()
|
||||
):
|
||||
self.business_task.cancel()
|
||||
# The business owner's finally block calls back into server
|
||||
# cleanup. Waiting for it here would create a close cycle:
|
||||
# close -> business finally -> transport.close -> close.
|
||||
# Give cancellation one loop turn, then let it unwind
|
||||
# independently while the physical socket is released.
|
||||
await asyncio.sleep(0)
|
||||
if not self.business_task.done():
|
||||
tracker = getattr(
|
||||
self.mqtt_server, "track_draining_task", None
|
||||
)
|
||||
if callable(tracker):
|
||||
tracker(self.business_task, "MQTT业务任务")
|
||||
else:
|
||||
self.business_task.add_done_callback(
|
||||
lambda task: self._consume_background_task(
|
||||
task, "MQTT业务任务"
|
||||
)
|
||||
)
|
||||
|
||||
# 取消心跳检查任务
|
||||
if (
|
||||
self.keep_alive_task
|
||||
and self.keep_alive_task is not current_task
|
||||
and self.keep_alive_task is not self._close_initiator
|
||||
and not self.keep_alive_task.done()
|
||||
):
|
||||
self.keep_alive_task.cancel()
|
||||
await self._wait_cancelled_task(
|
||||
self.keep_alive_task, "MQTT心跳任务"
|
||||
)
|
||||
|
||||
if (
|
||||
self._startup_recovery_task
|
||||
and self._startup_recovery_task is not current_task
|
||||
and not self._startup_recovery_task.done()
|
||||
):
|
||||
self._startup_recovery_task.cancel()
|
||||
await self._wait_cancelled_task(
|
||||
self._startup_recovery_task, "MQTT会话恢复任务"
|
||||
)
|
||||
|
||||
# 关闭协议处理器
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self.protocol.close(),
|
||||
timeout=self.close_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("关闭MQTT协议处理器超时,强制中止socket")
|
||||
abort = getattr(self.protocol, "abort", None)
|
||||
if callable(abort):
|
||||
abort()
|
||||
except (asyncio.CancelledError, Exception) as e:
|
||||
logger.error(f"关闭MQTT协议处理器失败: {e}")
|
||||
|
||||
# Publish disconnect only after the physical protocol close barrier.
|
||||
try:
|
||||
await self.mqtt_server.on_client_disconnected(self)
|
||||
except (asyncio.CancelledError, Exception) as e:
|
||||
logger.error(f"通知服务器连接关闭失败: {e}")
|
||||
|
||||
logger.info(f"MQTT连接已关闭: {self.client_id}")
|
||||
finally:
|
||||
self._close_complete.set()
|
||||
|
||||
async def _wait_cancelled_task(self, task: asyncio.Task, label: str) -> None:
|
||||
done, _ = await asyncio.wait({task}, timeout=self.close_timeout)
|
||||
if task not in done:
|
||||
logger.warning(
|
||||
"{}取消后{}秒仍未退出,继续释放物理连接",
|
||||
label,
|
||||
self.close_timeout,
|
||||
)
|
||||
return
|
||||
try:
|
||||
task.result()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.error(f"{label}退出失败: {exc}")
|
||||
|
||||
@staticmethod
|
||||
def _consume_background_task(task: asyncio.Task, label: str) -> None:
|
||||
try:
|
||||
task.result()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.error(f"{label}退出失败: {exc}")
|
||||
@@ -0,0 +1,651 @@
|
||||
import asyncio
|
||||
from typing import Dict, Any, Callable
|
||||
from config.logger import setup_logging
|
||||
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
# MQTT 固定头部的类型
|
||||
class PacketType:
|
||||
CONNECT = 1
|
||||
CONNACK = 2
|
||||
PUBLISH = 3
|
||||
SUBSCRIBE = 8
|
||||
SUBACK = 9
|
||||
PINGREQ = 12
|
||||
PINGRESP = 13
|
||||
DISCONNECT = 14
|
||||
|
||||
|
||||
class MQTTProtocol:
|
||||
"""
|
||||
MQTT协议处理器:负责MQTT协议的解析和封装
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
socket=None,
|
||||
reader=None,
|
||||
writer=None,
|
||||
max_payload_size=8192,
|
||||
event_queue_size=128,
|
||||
close_timeout=2,
|
||||
):
|
||||
self.socket = socket
|
||||
self.reader = reader
|
||||
self.writer = writer
|
||||
self.buffer = b''
|
||||
self.event_handlers = {}
|
||||
self.is_connected = False
|
||||
self.keep_alive_interval = 0
|
||||
self.last_activity = 0
|
||||
self.max_payload_size = int(max_payload_size or 0)
|
||||
self.close_timeout = max(0.1, float(close_timeout or 2))
|
||||
self._closed = False
|
||||
self._application_queue = asyncio.Queue(
|
||||
maxsize=max(1, int(event_queue_size or 128))
|
||||
)
|
||||
|
||||
# Application publishes stay ordered, while PINGREQ remains on the read
|
||||
# loop so a slow Hello/runtime refresh cannot starve MQTT keepalive.
|
||||
self._dispatch_task = asyncio.create_task(
|
||||
self._dispatch_application_messages()
|
||||
)
|
||||
self._processing_task = asyncio.create_task(self._process_messages())
|
||||
|
||||
def on(self, event: str, handler: Callable):
|
||||
"""注册事件处理器"""
|
||||
self.event_handlers[event] = handler
|
||||
|
||||
def emit(self, event: str, *args, **kwargs):
|
||||
"""触发事件"""
|
||||
handler = self.event_handlers.get(event)
|
||||
if handler:
|
||||
if asyncio.iscoroutinefunction(handler):
|
||||
asyncio.create_task(handler(*args, **kwargs))
|
||||
else:
|
||||
handler(*args, **kwargs)
|
||||
|
||||
async def emit_async(self, event: str, *args, **kwargs):
|
||||
"""Emit protocol events in packet order."""
|
||||
handler = self.event_handlers.get(event)
|
||||
if not handler:
|
||||
return None
|
||||
result = handler(*args, **kwargs)
|
||||
if asyncio.iscoroutine(result):
|
||||
return await result
|
||||
return result
|
||||
|
||||
async def _process_messages(self):
|
||||
"""处理消息的主循环"""
|
||||
try:
|
||||
while not self._closed:
|
||||
# 从socket读取数据
|
||||
data = await self._read_socket()
|
||||
if not data:
|
||||
break
|
||||
|
||||
# 添加到缓冲区
|
||||
self.buffer += data
|
||||
|
||||
# 处理缓冲区中的消息
|
||||
await self._process_buffer()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"MQTT消息处理循环出错: {e}")
|
||||
self.emit('error', e)
|
||||
finally:
|
||||
if not self._closed:
|
||||
if self._dispatch_task.done():
|
||||
await self.emit_async('close')
|
||||
else:
|
||||
# Preserve parsed QoS0 publishes before a normal peer EOF.
|
||||
# The Hello/runtime barrier is separately time-bounded.
|
||||
await self._application_queue.put({'type': 'peer_close'})
|
||||
|
||||
async def _dispatch_application_messages(self):
|
||||
"""Dispatch non-heartbeat packets sequentially outside the read loop."""
|
||||
try:
|
||||
while not self._closed:
|
||||
message = await self._application_queue.get()
|
||||
try:
|
||||
await self._dispatch_application_message(message)
|
||||
finally:
|
||||
self._application_queue.task_done()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"MQTT应用消息处理循环出错: {e}")
|
||||
self.emit('error', e)
|
||||
|
||||
async def _dispatch_application_message(self, message: Dict[str, Any]):
|
||||
message_type = message.get('type')
|
||||
if message_type == 'publish':
|
||||
await self.emit_async('publish', message)
|
||||
elif message_type == 'disconnect':
|
||||
await self.emit_async('disconnect')
|
||||
self.is_connected = False
|
||||
elif message_type == 'peer_close':
|
||||
await self.emit_async('close')
|
||||
else:
|
||||
raise ValueError(f"不支持的MQTT应用消息类型: {message_type}")
|
||||
|
||||
def _enqueue_application_message(self, message: Dict[str, Any]) -> None:
|
||||
try:
|
||||
self._application_queue.put_nowait(message)
|
||||
except asyncio.QueueFull as exc:
|
||||
raise ValueError("MQTT应用消息队列已满") from exc
|
||||
|
||||
async def _read_socket(self) -> bytes:
|
||||
"""从socket读取数据"""
|
||||
try:
|
||||
if self.reader is not None:
|
||||
return await self.reader.read(4096)
|
||||
# 使用asyncio的socket读取
|
||||
loop = asyncio.get_event_loop()
|
||||
data = await loop.sock_recv(self.socket, 4096)
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.error(f"读取socket数据失败: {e}")
|
||||
return b''
|
||||
|
||||
async def _process_buffer(self):
|
||||
"""处理缓冲区中的消息"""
|
||||
while len(self.buffer) >= 2: # 至少需要2字节开始解析
|
||||
try:
|
||||
# 解析消息
|
||||
message_length, message = self._parse_message()
|
||||
if message_length == 0:
|
||||
break # 消息不完整,等待更多数据
|
||||
|
||||
# 从缓冲区移除已处理的消息
|
||||
self.buffer = self.buffer[message_length:]
|
||||
|
||||
# 处理消息
|
||||
await self._handle_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理MQTT消息失败: {e}")
|
||||
self.emit('protocolError', e)
|
||||
break
|
||||
|
||||
def _parse_message(self) -> tuple[int, Dict[str, Any]]:
|
||||
"""解析MQTT消息"""
|
||||
if len(self.buffer) < 2:
|
||||
return 0, {}
|
||||
|
||||
# 获取消息类型
|
||||
first_byte = self.buffer[0]
|
||||
packet_type = (first_byte >> 4)
|
||||
client_packet_types = {
|
||||
PacketType.CONNECT,
|
||||
PacketType.PUBLISH,
|
||||
PacketType.SUBSCRIBE,
|
||||
PacketType.PINGREQ,
|
||||
PacketType.DISCONNECT,
|
||||
}
|
||||
if packet_type not in client_packet_types:
|
||||
raise ValueError(f"不支持的客户端MQTT消息类型: {packet_type}")
|
||||
fixed_flags = first_byte & 0x0F
|
||||
expected_flags = {
|
||||
PacketType.CONNECT: 0,
|
||||
PacketType.SUBSCRIBE: 2,
|
||||
PacketType.PINGREQ: 0,
|
||||
PacketType.DISCONNECT: 0,
|
||||
}
|
||||
if (
|
||||
packet_type in expected_flags
|
||||
and fixed_flags != expected_flags[packet_type]
|
||||
):
|
||||
raise ValueError(
|
||||
f"MQTT packet type {packet_type} has invalid fixed-header flags "
|
||||
f"0x{fixed_flags:x}"
|
||||
)
|
||||
if packet_type == PacketType.PUBLISH and ((first_byte >> 1) & 0x03) == 3:
|
||||
raise ValueError("MQTT PUBLISH QoS 3 is invalid")
|
||||
|
||||
# 解析剩余长度
|
||||
remaining_length, bytes_read = self._decode_remaining_length()
|
||||
if remaining_length == -1:
|
||||
return 0, {} # 长度解析失败,等待更多数据
|
||||
max_payload_size = getattr(self, "max_payload_size", 0)
|
||||
if max_payload_size > 0 and remaining_length > max_payload_size:
|
||||
raise ValueError(
|
||||
f"MQTT remaining length {remaining_length} exceeds limit {max_payload_size}"
|
||||
)
|
||||
|
||||
# 计算完整消息长度
|
||||
total_length = 1 + bytes_read + remaining_length
|
||||
|
||||
if len(self.buffer) < total_length:
|
||||
return 0, {} # 消息不完整
|
||||
|
||||
if (
|
||||
packet_type in (PacketType.PINGREQ, PacketType.DISCONNECT)
|
||||
and remaining_length != 0
|
||||
):
|
||||
raise ValueError(
|
||||
f"MQTT packet type {packet_type} requires remaining length 0"
|
||||
)
|
||||
|
||||
# 提取消息数据
|
||||
message_data = self.buffer[:total_length]
|
||||
|
||||
# 根据消息类型解析
|
||||
if packet_type == PacketType.CONNECT:
|
||||
message = self._parse_connect(message_data)
|
||||
elif packet_type == PacketType.PUBLISH:
|
||||
message = self._parse_publish(message_data)
|
||||
elif packet_type == PacketType.SUBSCRIBE:
|
||||
message = self._parse_subscribe(message_data)
|
||||
elif packet_type == PacketType.PINGREQ:
|
||||
message = {'type': 'pingreq'}
|
||||
elif packet_type == PacketType.DISCONNECT:
|
||||
message = {'type': 'disconnect'}
|
||||
else:
|
||||
logger.warning(f"未处理的MQTT消息类型: {packet_type}")
|
||||
message = {'type': 'unknown', 'packet_type': packet_type}
|
||||
|
||||
return total_length, message
|
||||
|
||||
def _decode_remaining_length(self) -> tuple[int, int]:
|
||||
"""解码剩余长度字段"""
|
||||
multiplier = 1
|
||||
value = 0
|
||||
bytes_read = 0
|
||||
|
||||
while bytes_read < 4:
|
||||
if bytes_read + 1 >= len(self.buffer):
|
||||
return -1, 0
|
||||
digit = self.buffer[bytes_read + 1]
|
||||
bytes_read += 1
|
||||
|
||||
value += (digit & 127) * multiplier
|
||||
multiplier *= 128
|
||||
|
||||
if (digit & 128) == 0:
|
||||
return value, bytes_read
|
||||
if bytes_read == 4:
|
||||
raise ValueError("MQTT remaining length字段超过4字节")
|
||||
|
||||
raise ValueError("MQTT remaining length字段无效")
|
||||
|
||||
def _encode_remaining_length(self, length: int) -> bytes:
|
||||
"""编码剩余长度字段"""
|
||||
result = bytearray()
|
||||
|
||||
while True:
|
||||
digit = length % 128
|
||||
length = length // 128
|
||||
|
||||
if length > 0:
|
||||
digit |= 0x80
|
||||
|
||||
result.append(digit)
|
||||
|
||||
if length == 0:
|
||||
break
|
||||
|
||||
return bytes(result)
|
||||
|
||||
def _parse_connect(self, message_data: bytes) -> Dict[str, Any]:
|
||||
"""解析CONNECT消息"""
|
||||
try:
|
||||
# 跳过固定头部和剩余长度
|
||||
_, bytes_read = self._decode_remaining_length()
|
||||
pos = 1 + bytes_read
|
||||
|
||||
def read_bytes(binary=False):
|
||||
nonlocal pos
|
||||
if pos + 2 > len(message_data):
|
||||
raise ValueError("MQTT CONNECT字符串长度字段不完整")
|
||||
value_length = int.from_bytes(message_data[pos:pos + 2], 'big')
|
||||
pos += 2
|
||||
if pos + value_length > len(message_data):
|
||||
raise ValueError("MQTT CONNECT字符串内容不完整")
|
||||
value = message_data[pos:pos + value_length]
|
||||
pos += value_length
|
||||
return value if binary else value.decode('utf-8')
|
||||
|
||||
protocol = read_bytes()
|
||||
|
||||
# 协议级别
|
||||
if pos + 4 > len(message_data):
|
||||
raise ValueError("MQTT CONNECT可变头部不完整")
|
||||
protocol_level = message_data[pos]
|
||||
pos += 1
|
||||
if protocol != 'MQTT' or protocol_level != 4:
|
||||
raise ValueError(
|
||||
f"不支持的MQTT协议: {protocol}/{protocol_level}"
|
||||
)
|
||||
|
||||
# 连接标志
|
||||
connect_flags = message_data[pos]
|
||||
if connect_flags & 0x01:
|
||||
raise ValueError("MQTT CONNECT保留标志必须为0")
|
||||
has_username = (connect_flags & 0x80) != 0
|
||||
has_password = (connect_flags & 0x40) != 0
|
||||
will_retain = (connect_flags & 0x20) != 0
|
||||
will_qos = (connect_flags >> 3) & 0x03
|
||||
has_will = (connect_flags & 0x04) != 0
|
||||
clean_session = (connect_flags & 0x02) != 0
|
||||
if has_password and not has_username:
|
||||
raise ValueError("MQTT CONNECT密码标志要求用户名标志")
|
||||
if will_qos == 3:
|
||||
raise ValueError("MQTT CONNECT Will QoS 3无效")
|
||||
if not has_will and (will_retain or will_qos):
|
||||
raise ValueError("MQTT CONNECT未启用Will但设置了Will标志")
|
||||
pos += 1
|
||||
|
||||
# 保持连接时间
|
||||
keep_alive = int.from_bytes(message_data[pos:pos+2], 'big')
|
||||
pos += 2
|
||||
|
||||
client_id = read_bytes()
|
||||
if not client_id and not clean_session:
|
||||
raise ValueError("MQTT CONNECT空clientId必须启用clean session")
|
||||
|
||||
if has_will:
|
||||
read_bytes() # Will topic
|
||||
read_bytes(binary=True) # Will payload
|
||||
|
||||
# 用户名(如果存在)
|
||||
username = ''
|
||||
if has_username:
|
||||
username = read_bytes()
|
||||
|
||||
# 密码(如果存在)
|
||||
password = ''
|
||||
if has_password:
|
||||
password = read_bytes(binary=True).decode('utf-8')
|
||||
|
||||
if pos != len(message_data):
|
||||
raise ValueError("MQTT CONNECT包含未解析的尾部数据")
|
||||
|
||||
return {
|
||||
'type': 'connect',
|
||||
'protocol': protocol,
|
||||
'protocolLevel': protocol_level,
|
||||
'clientId': client_id,
|
||||
'keepAlive': keep_alive,
|
||||
'username': username,
|
||||
'password': password
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"解析CONNECT消息失败: {e}")
|
||||
raise
|
||||
|
||||
def _parse_publish(self, message_data: bytes) -> Dict[str, Any]:
|
||||
"""解析PUBLISH消息"""
|
||||
try:
|
||||
# 获取QoS等标志
|
||||
first_byte = message_data[0]
|
||||
qos = (first_byte & 0x06) >> 1
|
||||
dup = (first_byte & 0x08) != 0
|
||||
retain = (first_byte & 0x01) != 0
|
||||
|
||||
# 跳过固定头部和剩余长度
|
||||
_, bytes_read = self._decode_remaining_length()
|
||||
pos = 1 + bytes_read
|
||||
|
||||
# 主题长度
|
||||
if pos + 2 > len(message_data):
|
||||
raise ValueError("MQTT PUBLISH缺少主题长度")
|
||||
topic_length = int.from_bytes(message_data[pos:pos+2], 'big')
|
||||
pos += 2
|
||||
if topic_length == 0 or pos + topic_length > len(message_data):
|
||||
raise ValueError("MQTT PUBLISH主题为空或不完整")
|
||||
|
||||
# 主题
|
||||
topic = message_data[pos:pos+topic_length].decode('utf-8')
|
||||
pos += topic_length
|
||||
if "\x00" in topic or "+" in topic or "#" in topic:
|
||||
raise ValueError("MQTT PUBLISH主题名称无效")
|
||||
|
||||
# 消息ID(QoS > 0时存在)
|
||||
packet_id = None
|
||||
if qos > 0:
|
||||
if pos + 2 > len(message_data):
|
||||
raise ValueError("MQTT PUBLISH缺少packetId")
|
||||
packet_id = int.from_bytes(message_data[pos:pos+2], 'big')
|
||||
pos += 2
|
||||
if packet_id == 0:
|
||||
raise ValueError("MQTT PUBLISH packetId不能为0")
|
||||
|
||||
# 有效载荷
|
||||
payload = message_data[pos:].decode('utf-8')
|
||||
|
||||
return {
|
||||
'type': 'publish',
|
||||
'topic': topic,
|
||||
'payload': payload,
|
||||
'qos': qos,
|
||||
'dup': dup,
|
||||
'retain': retain,
|
||||
'packetId': packet_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"解析PUBLISH消息失败: {e}")
|
||||
raise
|
||||
|
||||
def _parse_subscribe(self, message_data: bytes) -> Dict[str, Any]:
|
||||
"""解析SUBSCRIBE消息"""
|
||||
try:
|
||||
# 跳过固定头部和剩余长度
|
||||
_, bytes_read = self._decode_remaining_length()
|
||||
pos = 1 + bytes_read
|
||||
|
||||
# 消息ID
|
||||
if pos + 2 > len(message_data):
|
||||
raise ValueError("MQTT SUBSCRIBE缺少packetId")
|
||||
packet_id = int.from_bytes(message_data[pos:pos+2], 'big')
|
||||
pos += 2
|
||||
if packet_id == 0:
|
||||
raise ValueError("MQTT SUBSCRIBE packetId不能为0")
|
||||
|
||||
# 主题长度
|
||||
if pos + 2 > len(message_data):
|
||||
raise ValueError("MQTT SUBSCRIBE缺少主题长度")
|
||||
topic_length = int.from_bytes(message_data[pos:pos+2], 'big')
|
||||
pos += 2
|
||||
if topic_length == 0 or pos + topic_length > len(message_data):
|
||||
raise ValueError("MQTT SUBSCRIBE主题为空或不完整")
|
||||
|
||||
# 主题
|
||||
topic = message_data[pos:pos+topic_length].decode('utf-8')
|
||||
pos += topic_length
|
||||
if "\x00" in topic:
|
||||
raise ValueError("MQTT SUBSCRIBE主题过滤器无效")
|
||||
|
||||
# QoS
|
||||
if pos >= len(message_data):
|
||||
raise ValueError("MQTT SUBSCRIBE缺少请求QoS")
|
||||
qos = message_data[pos]
|
||||
pos += 1
|
||||
if qos > 2:
|
||||
raise ValueError("MQTT SUBSCRIBE请求QoS无效")
|
||||
if pos != len(message_data):
|
||||
raise ValueError("MQTT SUBSCRIBE当前仅支持单个主题过滤器")
|
||||
|
||||
return {
|
||||
'type': 'subscribe',
|
||||
'packetId': packet_id,
|
||||
'topic': topic,
|
||||
'qos': qos
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"解析SUBSCRIBE消息失败: {e}")
|
||||
raise
|
||||
|
||||
async def _handle_message(self, message: Dict[str, Any]):
|
||||
"""处理解析后的消息"""
|
||||
message_type = message.get('type')
|
||||
|
||||
if message_type == 'connect':
|
||||
if self.is_connected:
|
||||
raise ValueError("MQTT连接只能发送一次CONNECT")
|
||||
self.keep_alive_interval = message.get('keepAlive', 0)
|
||||
accepted = await self.emit_async('connect', message)
|
||||
self.is_connected = accepted is not False
|
||||
if self.is_connected:
|
||||
self.emit('activity')
|
||||
return
|
||||
|
||||
if not self.is_connected:
|
||||
raise ValueError("MQTT客户端必须先发送CONNECT")
|
||||
|
||||
if message_type == 'publish':
|
||||
self.emit('activity')
|
||||
self._enqueue_application_message(message)
|
||||
elif message_type == 'subscribe':
|
||||
self.emit('activity')
|
||||
await self.emit_async('subscribe', message)
|
||||
elif message_type == 'pingreq':
|
||||
self.emit('activity')
|
||||
await self.send_pingresp()
|
||||
elif message_type == 'disconnect':
|
||||
self.emit('activity')
|
||||
self._enqueue_application_message(message)
|
||||
else:
|
||||
raise ValueError(f"不支持的MQTT消息类型: {message_type}")
|
||||
|
||||
async def send_connack(self, return_code: int = 0, session_present: bool = False):
|
||||
"""发送CONNACK消息"""
|
||||
packet = bytearray([
|
||||
PacketType.CONNACK << 4, # 固定头部
|
||||
2, # 剩余长度
|
||||
1 if session_present else 0, # 连接确认标志
|
||||
return_code # 返回码
|
||||
])
|
||||
|
||||
await self._send_packet(packet)
|
||||
|
||||
async def send_publish(self, topic: str, payload: str, qos: int = 0,
|
||||
dup: bool = False, retain: bool = False, packet_id: int = None):
|
||||
"""发送PUBLISH消息"""
|
||||
# 构造固定头部
|
||||
first_byte = PacketType.PUBLISH << 4
|
||||
if dup:
|
||||
first_byte |= 0x08
|
||||
if qos > 0:
|
||||
first_byte |= (qos << 1)
|
||||
if retain:
|
||||
first_byte |= 0x01
|
||||
|
||||
# 构造可变头部和载荷
|
||||
topic_bytes = topic.encode('utf-8')
|
||||
payload_bytes = payload.encode('utf-8')
|
||||
|
||||
variable_header = bytearray()
|
||||
variable_header.extend(len(topic_bytes).to_bytes(2, 'big'))
|
||||
variable_header.extend(topic_bytes)
|
||||
|
||||
if qos > 0 and packet_id is not None:
|
||||
variable_header.extend(packet_id.to_bytes(2, 'big'))
|
||||
|
||||
# 计算剩余长度
|
||||
remaining_length = len(variable_header) + len(payload_bytes)
|
||||
remaining_length_bytes = self._encode_remaining_length(remaining_length)
|
||||
|
||||
# 构造完整消息
|
||||
packet = bytearray([first_byte])
|
||||
packet.extend(remaining_length_bytes)
|
||||
packet.extend(variable_header)
|
||||
packet.extend(payload_bytes)
|
||||
|
||||
await self._send_packet(packet)
|
||||
|
||||
async def send_suback(self, packet_id: int, return_code: int = 0):
|
||||
"""发送SUBACK消息"""
|
||||
packet = bytearray([
|
||||
PacketType.SUBACK << 4, # 固定头部
|
||||
3, # 剩余长度
|
||||
packet_id >> 8, # 消息ID高字节
|
||||
packet_id & 0xFF, # 消息ID低字节
|
||||
return_code # 返回码
|
||||
])
|
||||
|
||||
await self._send_packet(packet)
|
||||
|
||||
async def send_pingresp(self):
|
||||
"""发送PINGRESP消息"""
|
||||
packet = bytearray([
|
||||
PacketType.PINGRESP << 4, # 固定头部
|
||||
0 # 剩余长度
|
||||
])
|
||||
|
||||
await self._send_packet(packet)
|
||||
|
||||
async def _send_packet(self, packet: bytearray):
|
||||
"""发送数据包"""
|
||||
try:
|
||||
if self.writer is not None:
|
||||
self.writer.write(bytes(packet))
|
||||
await self.writer.drain()
|
||||
else:
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.sock_sendall(self.socket, bytes(packet))
|
||||
except Exception as e:
|
||||
logger.error(f"发送MQTT数据包失败: {e}")
|
||||
raise
|
||||
|
||||
async def close(self):
|
||||
"""关闭协议处理器"""
|
||||
self._closed = True
|
||||
current_task = asyncio.current_task()
|
||||
if (
|
||||
hasattr(self, '_processing_task')
|
||||
and self._processing_task is not current_task
|
||||
and not self._processing_task.done()
|
||||
):
|
||||
self._processing_task.cancel()
|
||||
try:
|
||||
await self._processing_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if (
|
||||
hasattr(self, '_dispatch_task')
|
||||
and self._dispatch_task is not current_task
|
||||
and not self._dispatch_task.done()
|
||||
):
|
||||
self._dispatch_task.cancel()
|
||||
try:
|
||||
await self._dispatch_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
try:
|
||||
if self.writer is not None:
|
||||
self.writer.close()
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self.writer.wait_closed(),
|
||||
timeout=self.close_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"等待MQTT socket关闭超时,强制中止transport"
|
||||
)
|
||||
self.abort()
|
||||
except Exception:
|
||||
pass
|
||||
elif self.socket:
|
||||
self.socket.close()
|
||||
except Exception as e:
|
||||
logger.error(f"关闭socket失败: {e}")
|
||||
|
||||
def abort(self):
|
||||
"""Force-close the underlying transport when graceful close stalls."""
|
||||
if self.writer is not None:
|
||||
transport = getattr(self.writer, "transport", None)
|
||||
if transport is not None:
|
||||
transport.abort()
|
||||
return
|
||||
if self.socket:
|
||||
self.socket.close()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,14 @@ import asyncio
|
||||
from typing import Dict, Any, List, Optional
|
||||
from config.logger import setup_logging
|
||||
from core.websocket_server_new import NewWebSocketServer
|
||||
from core.servers.mqtt_server import MQTTServer
|
||||
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
class MultiProtocolServer:
|
||||
"""
|
||||
协议服务器管理器
|
||||
多协议服务器管理器:统一管理WebSocket和MQTT服务器
|
||||
提供统一的启动、停止和状态监控接口
|
||||
"""
|
||||
|
||||
@@ -42,6 +43,11 @@ class MultiProtocolServer:
|
||||
self.servers['websocket'] = NewWebSocketServer(self.config)
|
||||
logger.info("WebSocket服务器已初始化")
|
||||
|
||||
# 初始化MQTT服务器
|
||||
if 'mqtt' in enabled_protocols:
|
||||
self.servers['mqtt'] = MQTTServer(self.config)
|
||||
logger.info("MQTT服务器已初始化")
|
||||
|
||||
if not self.servers:
|
||||
logger.warning("没有启用任何协议服务器")
|
||||
|
||||
@@ -333,6 +339,14 @@ class MultiProtocolServer:
|
||||
# 检查服务器端口配置
|
||||
server_configs = {
|
||||
'server': ('port', 'host', 'ip'),
|
||||
'mqtt_server': (
|
||||
'port',
|
||||
'udp_port',
|
||||
'host',
|
||||
'ip',
|
||||
'public_endpoint',
|
||||
'udp_bind_host',
|
||||
),
|
||||
}
|
||||
for config_key, listener_keys in server_configs.items():
|
||||
old_server_config = old_config.get(config_key, {})
|
||||
@@ -443,7 +457,7 @@ class MultiProtocolServer:
|
||||
|
||||
def get_supported_protocols(self) -> List[str]:
|
||||
"""获取支持的协议列表"""
|
||||
return ['websocket']
|
||||
return ['websocket', 'mqtt']
|
||||
|
||||
def is_protocol_enabled(self, protocol: str) -> bool:
|
||||
"""检查协议是否启用"""
|
||||
|
||||
@@ -229,6 +229,23 @@ class ConnectionService:
|
||||
await initialization_task
|
||||
initialization_task = None
|
||||
|
||||
is_native_hello = (
|
||||
transport.keeps_connection_between_sessions
|
||||
and is_hello
|
||||
)
|
||||
if is_native_hello:
|
||||
try:
|
||||
# Every Native Hello is a logical-session boundary.
|
||||
# Always query Agent config here; the refresh helper
|
||||
# cheaply retains an unchanged healthy runtime.
|
||||
component_manager = await self._refresh_private_runtime(
|
||||
context,
|
||||
component_manager,
|
||||
bind_completed_event,
|
||||
)
|
||||
finally:
|
||||
await transport.mark_session_ready(transport.session_id)
|
||||
|
||||
if getattr(context, "init_error", None):
|
||||
# 配置错误时,允许hello/listen触发默认语音(节流)
|
||||
msg_type = msg_json.get("type") if isinstance(msg_json, dict) else None
|
||||
@@ -337,7 +354,9 @@ class ConnectionService:
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"会话清理失败: {e}")
|
||||
|
||||
# Clean the manager currently owned by the context.
|
||||
# A cancelled Native runtime refresh may have already installed a
|
||||
# healthy replacement before this local variable was reassigned.
|
||||
# Always clean the manager currently owned by the context.
|
||||
active_component_manager = (
|
||||
getattr(context, "component_manager", None) or component_manager
|
||||
)
|
||||
@@ -785,6 +804,201 @@ class ConnectionService:
|
||||
return None
|
||||
return None
|
||||
|
||||
async def _refresh_private_runtime(
|
||||
self,
|
||||
context: SessionContext,
|
||||
component_manager,
|
||||
bind_completed_event: asyncio.Event,
|
||||
):
|
||||
"""Refresh Agent config at a Native MQTT logical-session boundary."""
|
||||
if not context.read_config_from_api:
|
||||
return component_manager
|
||||
|
||||
try:
|
||||
private_config = await get_private_config_from_api(
|
||||
context.common_config,
|
||||
context.device_id,
|
||||
context.headers.get("client-id", context.device_id),
|
||||
)
|
||||
except DeviceNotFoundException:
|
||||
context.need_bind = True
|
||||
return component_manager
|
||||
except DeviceBindException as exc:
|
||||
context.need_bind = True
|
||||
context.bind_code = getattr(exc, "bind_code", None)
|
||||
return component_manager
|
||||
except Exception as exc:
|
||||
logger.bind(tag=TAG).warning(f"刷新差异化配置失败,继续使用当前配置: {exc}")
|
||||
return component_manager
|
||||
|
||||
if not private_config:
|
||||
return component_manager
|
||||
|
||||
private_config["delete_audio"] = bool(
|
||||
context.common_config.get("delete_audio", True)
|
||||
)
|
||||
config_changed = private_config != context.private_config
|
||||
if not config_changed and getattr(context, "init_error", None) is None:
|
||||
context.need_bind = False
|
||||
context.bind_code = None
|
||||
bind_completed_event.set()
|
||||
return component_manager
|
||||
|
||||
await self._finalize_conversation_session(context, context.session_id)
|
||||
await context.cancel_conversation_tasks()
|
||||
|
||||
try:
|
||||
staged_context = copy.copy(context)
|
||||
staged_context.config = copy.deepcopy(context.common_config)
|
||||
staged_context.private_config = copy.deepcopy(private_config)
|
||||
self._reset_config_derived_state(staged_context)
|
||||
staged_context.config.update(copy.deepcopy(private_config))
|
||||
self._reset_config_derived_state(staged_context)
|
||||
self._merge_private_modules(
|
||||
staged_context, copy.deepcopy(private_config)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.bind(tag=TAG).error(
|
||||
f"物化刷新配置失败,继续使用旧运行时: {exc}"
|
||||
)
|
||||
return component_manager
|
||||
|
||||
try:
|
||||
new_manager = ComponentRegistry.create_component_manager(
|
||||
staged_context.config
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.bind(tag=TAG).error(f"创建刷新业务组件失败,保留旧运行时: {exc}")
|
||||
return component_manager
|
||||
|
||||
runtime_fields = (
|
||||
"config",
|
||||
"private_config",
|
||||
"component_manager",
|
||||
"asr",
|
||||
"tts",
|
||||
"func_handler",
|
||||
"voiceprint_provider",
|
||||
"prompt",
|
||||
"intent_type",
|
||||
"load_function_plugin",
|
||||
"inject_tool_call_fewshot",
|
||||
"dialogue",
|
||||
"max_output_size",
|
||||
"chat_history_conf",
|
||||
"cmd_exit",
|
||||
"need_bind",
|
||||
"bind_code",
|
||||
"init_error",
|
||||
"init_error_notified",
|
||||
"_init_error_last_audio_ts",
|
||||
)
|
||||
missing = object()
|
||||
previous = {
|
||||
field: getattr(context, field, missing) for field in runtime_fields
|
||||
}
|
||||
previous_callbacks = list(context._cleanup_callbacks)
|
||||
old_func_handler = context.func_handler
|
||||
|
||||
context.config = staged_context.config
|
||||
context.private_config = staged_context.private_config
|
||||
context.max_output_size = staged_context.max_output_size
|
||||
context.chat_history_conf = staged_context.chat_history_conf
|
||||
context.cmd_exit = staged_context.cmd_exit
|
||||
context.component_manager = new_manager
|
||||
context.asr = None
|
||||
context.tts = None
|
||||
context.func_handler = None
|
||||
context.voiceprint_provider = None
|
||||
context.prompt = None
|
||||
context.intent_type = "nointent"
|
||||
context.load_function_plugin = False
|
||||
context.inject_tool_call_fewshot = None
|
||||
context.dialogue = Dialogue()
|
||||
context.init_error = None
|
||||
|
||||
try:
|
||||
await self._initialize_components(context, new_manager)
|
||||
except (asyncio.CancelledError, Exception) as exc:
|
||||
was_cancelled = isinstance(exc, asyncio.CancelledError)
|
||||
retry_callbacks = []
|
||||
new_callbacks = [
|
||||
callback
|
||||
for callback in context._cleanup_callbacks
|
||||
if callback not in previous_callbacks
|
||||
]
|
||||
for callback in reversed(new_callbacks):
|
||||
try:
|
||||
result = callback()
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except (asyncio.CancelledError, Exception) as cleanup_exc:
|
||||
retry_callbacks.append(callback)
|
||||
logger.bind(tag=TAG).warning(
|
||||
f"回滚刷新工具处理器失败: {cleanup_exc}"
|
||||
)
|
||||
try:
|
||||
await new_manager.cleanup_all()
|
||||
except (asyncio.CancelledError, Exception) as cleanup_exc:
|
||||
retry_callbacks.append(new_manager.cleanup_all)
|
||||
logger.bind(tag=TAG).warning(
|
||||
f"回滚刷新业务组件失败: {cleanup_exc}"
|
||||
)
|
||||
for field, value in previous.items():
|
||||
if value is missing:
|
||||
if hasattr(context, field):
|
||||
delattr(context, field)
|
||||
else:
|
||||
setattr(context, field, value)
|
||||
context._cleanup_callbacks = previous_callbacks + retry_callbacks
|
||||
if was_cancelled:
|
||||
logger.bind(tag=TAG).info(
|
||||
"刷新业务组件被取消,已回滚并保留旧运行时"
|
||||
)
|
||||
raise
|
||||
logger.bind(tag=TAG).error(
|
||||
f"刷新业务组件失败,继续使用旧运行时: {exc}"
|
||||
)
|
||||
return component_manager
|
||||
|
||||
# Only retire the previous runtime after the replacement is healthy.
|
||||
# Register the old manager before the first await so cancellation at
|
||||
# any point leaves a cleanup owner for handle_connection.finally.
|
||||
if (
|
||||
component_manager
|
||||
and component_manager.cleanup_all not in context._cleanup_callbacks
|
||||
):
|
||||
context.register_cleanup(component_manager.cleanup_all)
|
||||
if old_func_handler and hasattr(old_func_handler, "cleanup"):
|
||||
try:
|
||||
await old_func_handler.cleanup()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.bind(tag=TAG).warning(f"清理旧工具处理器失败: {exc}")
|
||||
else:
|
||||
context.unregister_cleanup(old_func_handler.cleanup)
|
||||
if component_manager:
|
||||
try:
|
||||
await component_manager.cleanup_all()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.bind(tag=TAG).warning(f"清理旧业务组件失败: {exc}")
|
||||
else:
|
||||
context.unregister_cleanup(component_manager.cleanup_all)
|
||||
|
||||
context.init_error = None
|
||||
context.init_error_notified = False
|
||||
context._init_error_last_audio_ts = 0.0
|
||||
context.need_bind = False
|
||||
context.bind_code = None
|
||||
bind_completed_event.set()
|
||||
logger.bind(tag=TAG).info(
|
||||
"Native MQTT逻辑会话已原子刷新私有配置和业务组件"
|
||||
)
|
||||
return new_manager
|
||||
|
||||
@staticmethod
|
||||
def _reset_config_derived_state(context: SessionContext) -> None:
|
||||
"""Reset fields derived from config before applying a replacement."""
|
||||
|
||||
@@ -0,0 +1,726 @@
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config.logger import setup_logging
|
||||
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
def normalize_device_id(device_id: Optional[str]) -> Optional[str]:
|
||||
if not isinstance(device_id, str):
|
||||
return None
|
||||
normalized = device_id.strip().lower().replace("-", ":")
|
||||
return normalized or None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PendingCall:
|
||||
caller_mac: str
|
||||
target_mac: str
|
||||
caller_nickname: str
|
||||
created_at: float
|
||||
generation: int
|
||||
|
||||
|
||||
class NativeMqttCallManager:
|
||||
def __init__(
|
||||
self,
|
||||
connection_registry,
|
||||
timeout_seconds: float = 60,
|
||||
silence_frame: Optional[bytes] = None,
|
||||
clock=time.monotonic,
|
||||
):
|
||||
self.connection_registry = connection_registry
|
||||
self.timeout_seconds = max(1.0, float(timeout_seconds))
|
||||
self.silence_frame = silence_frame
|
||||
self.clock = clock
|
||||
self.pending_calls: Dict[str, PendingCall] = {}
|
||||
self.active_calls: Dict[str, str] = {}
|
||||
self.call_session_ids: Dict[str, str] = {}
|
||||
self.call_generations: Dict[str, int] = {}
|
||||
self._next_generation = 1
|
||||
self._generation_end_events: Dict[int, asyncio.Event] = {}
|
||||
self._end_tasks: Dict[tuple[str, int], asyncio.Task] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def request_call(
|
||||
self,
|
||||
caller_mac: str,
|
||||
target_mac: str,
|
||||
caller_nickname: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
caller = normalize_device_id(caller_mac)
|
||||
target = normalize_device_id(target_mac)
|
||||
if not caller or not target or caller == target:
|
||||
return {"status": "error", "message": "呼叫设备参数无效"}
|
||||
|
||||
caller_entry = self.connection_registry.resolve_device_now(caller)
|
||||
target_entry = self.connection_registry.resolve_device_now(target)
|
||||
if target_entry is None:
|
||||
return {"status": "offline", "message": "对方设备不在线,请稍后重试"}
|
||||
if caller_entry is None:
|
||||
return {"status": "error", "message": "主叫设备不在线"}
|
||||
|
||||
async with self._lock:
|
||||
if caller in self.active_calls or target in self.active_calls:
|
||||
return {"status": "error", "message": "设备已在通话中"}
|
||||
caller_pending = self._pending_owner_locked(caller)
|
||||
target_pending = self._pending_owner_locked(target)
|
||||
reverse = (
|
||||
caller_pending == target
|
||||
and target_pending == target
|
||||
and self.pending_calls[target].target_mac == caller
|
||||
)
|
||||
if reverse:
|
||||
pending = self.pending_calls.pop(target)
|
||||
self.active_calls[caller] = target
|
||||
self.active_calls[target] = caller
|
||||
generation = pending.generation
|
||||
status = "bridged"
|
||||
elif caller_pending or target_pending:
|
||||
return {"status": "error", "message": "设备已有等待中的通话"}
|
||||
else:
|
||||
generation = self._allocate_generation_locked()
|
||||
self.pending_calls[caller] = PendingCall(
|
||||
caller_mac=caller,
|
||||
target_mac=target,
|
||||
caller_nickname=caller_nickname or "",
|
||||
created_at=self.clock(),
|
||||
generation=generation,
|
||||
)
|
||||
status = "pending"
|
||||
self.call_generations[caller] = generation
|
||||
self.call_generations[target] = generation
|
||||
self._capture_session(caller, caller_entry)
|
||||
self._capture_session(target, target_entry)
|
||||
self._set_call_state(caller_entry, True)
|
||||
if status == "bridged":
|
||||
self._set_call_state(target_entry, True)
|
||||
|
||||
try:
|
||||
await self._stop_ai_session(
|
||||
caller_entry, self.call_session_ids.get(caller)
|
||||
)
|
||||
if status == "bridged":
|
||||
await self._stop_ai_session(
|
||||
target_entry, self.call_session_ids.get(target)
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
await self.end_call(
|
||||
caller,
|
||||
notify_device=False,
|
||||
notify_peer=False,
|
||||
expected_generation=generation,
|
||||
)
|
||||
raise
|
||||
except Exception as error:
|
||||
await self.end_call(
|
||||
caller,
|
||||
notify_device=True,
|
||||
notify_peer=status == "bridged",
|
||||
expected_generation=generation,
|
||||
)
|
||||
logger.warning(
|
||||
"Native MQTT停止AI会话失败: caller={}, target={}, error={}",
|
||||
caller,
|
||||
target,
|
||||
error,
|
||||
)
|
||||
return {"status": "error", "message": "停止AI会话失败"}
|
||||
|
||||
async with self._lock:
|
||||
if status == "pending":
|
||||
valid = self._pending_matches_locked(
|
||||
caller, target, generation
|
||||
)
|
||||
else:
|
||||
valid = self._active_matches_locked(
|
||||
caller, target, generation
|
||||
)
|
||||
if not valid:
|
||||
return {"status": "error", "message": "通话状态已变化"}
|
||||
|
||||
if status == "pending":
|
||||
try:
|
||||
sent = await self._send_while_generation_active(
|
||||
target_entry.transport,
|
||||
{
|
||||
"type": "mcp",
|
||||
"payload": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 9999,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "self.remote_wakeup",
|
||||
"arguments": {
|
||||
"reason": (
|
||||
"[device_call]您收到来自"
|
||||
f"{caller_nickname or '未知'}的来电,是否接听?"
|
||||
),
|
||||
"action": "listen",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
generation,
|
||||
)
|
||||
if not sent:
|
||||
return {"status": "error", "message": "通话状态已变化"}
|
||||
except asyncio.CancelledError:
|
||||
await self.end_call(
|
||||
caller,
|
||||
notify_device=False,
|
||||
notify_peer=False,
|
||||
expected_generation=generation,
|
||||
)
|
||||
raise
|
||||
except Exception as error:
|
||||
await self.end_call(
|
||||
caller,
|
||||
"发送来电通知失败",
|
||||
notify_device=True,
|
||||
notify_peer=False,
|
||||
expected_generation=generation,
|
||||
)
|
||||
logger.warning(
|
||||
"Native MQTT来电通知发送失败: caller={}, target={}, error={}",
|
||||
caller,
|
||||
target,
|
||||
error,
|
||||
)
|
||||
return {"status": "error", "message": "发送来电通知失败"}
|
||||
return {"status": status}
|
||||
|
||||
async def accept_call(self, callee_mac: str) -> Dict[str, Any]:
|
||||
callee = normalize_device_id(callee_mac)
|
||||
if not callee:
|
||||
return {"status": "error", "message": "接听设备参数无效"}
|
||||
|
||||
callee_entry = self.connection_registry.resolve_device_now(callee)
|
||||
if callee_entry is None:
|
||||
return {"status": "offline", "message": "接听设备不在线"}
|
||||
|
||||
async with self._lock:
|
||||
if callee in self.active_calls:
|
||||
return {"status": "error", "message": "设备已在通话中"}
|
||||
pending = next(
|
||||
(
|
||||
entry
|
||||
for entry in self.pending_calls.values()
|
||||
if entry.target_mac == callee
|
||||
),
|
||||
None,
|
||||
)
|
||||
if pending is None:
|
||||
return {"status": "no_pending", "message": "没有等待中的通话"}
|
||||
|
||||
caller = pending.caller_mac
|
||||
caller_entry = self.connection_registry.resolve_device_now(caller)
|
||||
if caller_entry is None:
|
||||
self._remove_call_locked(caller)
|
||||
self._set_call_state(callee_entry, False)
|
||||
return {
|
||||
"status": "caller_gone",
|
||||
"message": "主叫方已离开或通话已超时",
|
||||
}
|
||||
|
||||
self.pending_calls.pop(caller, None)
|
||||
self.active_calls[caller] = callee
|
||||
self.active_calls[callee] = caller
|
||||
generation = pending.generation
|
||||
self.call_generations[caller] = generation
|
||||
self.call_generations[callee] = generation
|
||||
self._capture_session(caller, caller_entry)
|
||||
self._capture_session(callee, callee_entry)
|
||||
self._set_call_state(caller_entry, True)
|
||||
self._set_call_state(callee_entry, True)
|
||||
|
||||
try:
|
||||
await self._stop_ai_session(
|
||||
callee_entry, self.call_session_ids.get(callee)
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
await self.end_call(
|
||||
callee,
|
||||
notify_device=False,
|
||||
notify_peer=False,
|
||||
expected_generation=generation,
|
||||
)
|
||||
raise
|
||||
except Exception as error:
|
||||
await self.end_call(
|
||||
callee,
|
||||
notify_device=True,
|
||||
notify_peer=True,
|
||||
expected_generation=generation,
|
||||
)
|
||||
logger.warning(
|
||||
"Native MQTT停止接听方AI会话失败: caller={}, callee={}, error={}",
|
||||
caller,
|
||||
callee,
|
||||
error,
|
||||
)
|
||||
return {"status": "error", "message": "停止AI会话失败"}
|
||||
|
||||
async with self._lock:
|
||||
valid = self._active_matches_locked(
|
||||
caller, callee, generation
|
||||
)
|
||||
if not valid:
|
||||
return {"status": "error", "message": "通话状态已变化"}
|
||||
|
||||
try:
|
||||
sent = await self._send_while_generation_active(
|
||||
caller_entry.transport,
|
||||
{"type": "call_accepted", "from": callee},
|
||||
generation,
|
||||
)
|
||||
if not sent:
|
||||
return {"status": "error", "message": "通话状态已变化"}
|
||||
except asyncio.CancelledError:
|
||||
await self.end_call(
|
||||
callee,
|
||||
notify_device=False,
|
||||
notify_peer=False,
|
||||
expected_generation=generation,
|
||||
)
|
||||
raise
|
||||
except Exception as error:
|
||||
await self.end_call(
|
||||
callee,
|
||||
"发送接听确认失败",
|
||||
notify_device=True,
|
||||
notify_peer=True,
|
||||
expected_generation=generation,
|
||||
)
|
||||
logger.warning(
|
||||
"Native MQTT接听确认发送失败: caller={}, callee={}, error={}",
|
||||
caller,
|
||||
callee,
|
||||
error,
|
||||
)
|
||||
return {"status": "error", "message": "发送接听确认失败"}
|
||||
return {"status": "bridged", "peerMac": caller}
|
||||
|
||||
def route_audio(
|
||||
self, source_device_id: str, payload: bytes, timestamp: int
|
||||
) -> bool:
|
||||
source = normalize_device_id(source_device_id)
|
||||
if not source:
|
||||
return False
|
||||
|
||||
peer = self.active_calls.get(source)
|
||||
if peer:
|
||||
target = self.connection_registry.resolve_device_now(peer)
|
||||
if target is None:
|
||||
self._schedule_end(source, "对方已离开")
|
||||
return True
|
||||
handler = getattr(target.transport, "_udp_handler", None)
|
||||
try:
|
||||
sent = (
|
||||
handler is not None
|
||||
and handler.send_audio_nowait(payload, 0)
|
||||
)
|
||||
except Exception:
|
||||
sent = False
|
||||
if not sent:
|
||||
self._schedule_end(source, "对方音频通道不可用")
|
||||
return True
|
||||
|
||||
if source in self.pending_calls:
|
||||
source_entry = self.connection_registry.resolve_device_now(source)
|
||||
handler = (
|
||||
getattr(source_entry.transport, "_udp_handler", None)
|
||||
if source_entry
|
||||
else None
|
||||
)
|
||||
if handler is not None and self.silence_frame:
|
||||
try:
|
||||
handler.send_audio_nowait(self.silence_frame, 0)
|
||||
except Exception:
|
||||
self._schedule_end(source, "主叫音频通道不可用")
|
||||
return True
|
||||
return False
|
||||
|
||||
async def end_call(
|
||||
self,
|
||||
device_id: str,
|
||||
reason: str = "",
|
||||
notify_device: bool = False,
|
||||
notify_peer: bool = True,
|
||||
expected_session_id: Optional[str] = None,
|
||||
expected_generation: Optional[int] = None,
|
||||
) -> bool:
|
||||
device = normalize_device_id(device_id)
|
||||
if not device:
|
||||
return False
|
||||
|
||||
observed_generation = self.call_generations.get(device)
|
||||
generation = (
|
||||
expected_generation
|
||||
if expected_generation is not None
|
||||
else observed_generation
|
||||
)
|
||||
if observed_generation is None or observed_generation != generation:
|
||||
return False
|
||||
if (
|
||||
expected_session_id is not None
|
||||
and self.call_session_ids.get(device) != expected_session_id
|
||||
):
|
||||
return False
|
||||
end_event = self._generation_end_events.get(generation)
|
||||
if end_event is not None:
|
||||
end_event.set()
|
||||
|
||||
async with self._lock:
|
||||
current_generation = self.call_generations.get(device)
|
||||
if current_generation != generation:
|
||||
return False
|
||||
if expected_session_id is not None:
|
||||
current_session_id = self.call_session_ids.get(device)
|
||||
if current_session_id != expected_session_id:
|
||||
return False
|
||||
related = self._remove_call_locked(device)
|
||||
if related is None:
|
||||
return False
|
||||
|
||||
peer = related.get("peer")
|
||||
device_session = related.get("device_session")
|
||||
peer_session = related.get("peer_session")
|
||||
device_entry = self.connection_registry.resolve_device_now(device)
|
||||
peer_entry = (
|
||||
self.connection_registry.resolve_device_now(peer) if peer else None
|
||||
)
|
||||
self._set_call_state(device_entry, False)
|
||||
self._set_call_state(peer_entry, False)
|
||||
|
||||
notifications = []
|
||||
if notify_device and device_entry is not None:
|
||||
notifications.append(
|
||||
self._notify_idle(device_entry, device_session, reason)
|
||||
)
|
||||
if notify_peer and peer_entry is not None:
|
||||
notifications.append(
|
||||
self._notify_idle(peer_entry, peer_session, reason)
|
||||
)
|
||||
if notifications:
|
||||
results = await asyncio.gather(
|
||||
*notifications, return_exceptions=True
|
||||
)
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
logger.warning(
|
||||
"Native MQTT通话结束通知失败: error={}", result
|
||||
)
|
||||
return True
|
||||
|
||||
async def cleanup_timeouts(self) -> int:
|
||||
expired = []
|
||||
now = self.clock()
|
||||
async with self._lock:
|
||||
for caller, pending in list(self.pending_calls.items()):
|
||||
if now - pending.created_at >= self.timeout_seconds:
|
||||
expired.append((caller, pending.generation))
|
||||
for caller, generation in expired:
|
||||
await self.end_call(
|
||||
caller,
|
||||
"呼叫等待超时",
|
||||
notify_device=True,
|
||||
notify_peer=False,
|
||||
expected_generation=generation,
|
||||
)
|
||||
return len(expired)
|
||||
|
||||
async def clear(self) -> None:
|
||||
tasks = list(self._end_tasks.values())
|
||||
self._end_tasks.clear()
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
async with self._lock:
|
||||
devices = set(self.pending_calls)
|
||||
devices.update(self.active_calls)
|
||||
devices.update(self.call_generations)
|
||||
for device in devices:
|
||||
await self.end_call(
|
||||
device,
|
||||
"服务停止",
|
||||
notify_device=False,
|
||||
notify_peer=False,
|
||||
)
|
||||
async with self._lock:
|
||||
remaining = set(self.call_generations)
|
||||
remaining.update(self.call_session_ids)
|
||||
self.pending_calls.clear()
|
||||
self.active_calls.clear()
|
||||
self.call_generations.clear()
|
||||
self.call_session_ids.clear()
|
||||
end_events = list(self._generation_end_events.values())
|
||||
self._generation_end_events.clear()
|
||||
for end_event in end_events:
|
||||
end_event.set()
|
||||
for device in remaining:
|
||||
self._set_call_state(
|
||||
self.connection_registry.resolve_device_now(device),
|
||||
False,
|
||||
)
|
||||
|
||||
def contains(self, device_id: str) -> bool:
|
||||
device = normalize_device_id(device_id)
|
||||
return bool(
|
||||
device
|
||||
and (
|
||||
self._pending_owner_now(device) is not None
|
||||
or device in self.active_calls
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
devices = set(self.call_generations)
|
||||
devices.update(self.call_session_ids)
|
||||
devices.update(self.active_calls)
|
||||
devices.update(self.pending_calls)
|
||||
devices.update(
|
||||
pending.target_mac for pending in self.pending_calls.values()
|
||||
)
|
||||
generations = set(self.call_generations.values())
|
||||
orphan_events = set(self._generation_end_events) - generations
|
||||
return len(devices) + len(orphan_events)
|
||||
|
||||
@property
|
||||
def background_task_count(self) -> int:
|
||||
return sum(not task.done() for task in self._end_tasks.values())
|
||||
|
||||
async def handle_logical_hello(
|
||||
self, device_id: str, session_id: Optional[str]
|
||||
) -> bool:
|
||||
device = normalize_device_id(device_id)
|
||||
if not device:
|
||||
return False
|
||||
async with self._lock:
|
||||
pending_owner = self._pending_owner_locked(device)
|
||||
if (
|
||||
pending_owner is not None
|
||||
and pending_owner != device
|
||||
and device not in self.active_calls
|
||||
):
|
||||
if session_id:
|
||||
self.call_session_ids[device] = session_id
|
||||
return False
|
||||
generation = self.call_generations.get(device)
|
||||
if generation is None:
|
||||
return False
|
||||
return await self.end_call(
|
||||
device,
|
||||
"设备重新进入AI会话",
|
||||
notify_device=False,
|
||||
notify_peer=True,
|
||||
expected_session_id=session_id,
|
||||
expected_generation=generation,
|
||||
)
|
||||
|
||||
def _capture_session(self, device_id: str, entry) -> None:
|
||||
session_id = getattr(entry.transport, "session_id", None)
|
||||
if session_id:
|
||||
self.call_session_ids[device_id] = session_id
|
||||
|
||||
def _drop_session(self, device_id: str) -> Optional[str]:
|
||||
return self.call_session_ids.pop(device_id, None)
|
||||
|
||||
def _remove_call_locked(self, device: str) -> Optional[Dict[str, Any]]:
|
||||
generation = self.call_generations.get(device)
|
||||
end_event = self._generation_end_events.get(generation)
|
||||
if end_event is not None:
|
||||
end_event.set()
|
||||
pending = self.pending_calls.pop(device, None)
|
||||
if pending:
|
||||
self._drop_generation(device)
|
||||
self._drop_generation(pending.target_mac)
|
||||
self._generation_end_events.pop(pending.generation, None)
|
||||
return {
|
||||
"peer": pending.target_mac,
|
||||
"device_session": self._drop_session(device),
|
||||
"peer_session": self._drop_session(pending.target_mac),
|
||||
}
|
||||
|
||||
pending_owner = next(
|
||||
(
|
||||
caller
|
||||
for caller, entry in self.pending_calls.items()
|
||||
if entry.target_mac == device
|
||||
),
|
||||
None,
|
||||
)
|
||||
if pending_owner:
|
||||
pending = self.pending_calls.pop(pending_owner)
|
||||
self._drop_generation(device)
|
||||
self._drop_generation(pending_owner)
|
||||
self._generation_end_events.pop(pending.generation, None)
|
||||
return {
|
||||
"peer": pending_owner,
|
||||
"device_session": self._drop_session(device),
|
||||
"peer_session": self._drop_session(pending_owner),
|
||||
}
|
||||
|
||||
peer = self.active_calls.pop(device, None)
|
||||
if peer:
|
||||
self.active_calls.pop(peer, None)
|
||||
self._drop_generation(device)
|
||||
self._drop_generation(peer)
|
||||
if generation is not None:
|
||||
self._generation_end_events.pop(generation, None)
|
||||
return {
|
||||
"peer": peer,
|
||||
"device_session": self._drop_session(device),
|
||||
"peer_session": self._drop_session(peer),
|
||||
}
|
||||
return None
|
||||
|
||||
def _schedule_end(self, device_id: str, reason: str) -> None:
|
||||
device = normalize_device_id(device_id)
|
||||
generation = self.call_generations.get(device) if device else None
|
||||
if not device or generation is None:
|
||||
return
|
||||
key = (device, generation)
|
||||
existing = self._end_tasks.get(key)
|
||||
if existing is not None and not existing.done():
|
||||
return
|
||||
task = asyncio.create_task(
|
||||
self.end_call(
|
||||
device,
|
||||
reason,
|
||||
notify_device=True,
|
||||
notify_peer=True,
|
||||
expected_generation=generation,
|
||||
)
|
||||
)
|
||||
self._end_tasks[key] = task
|
||||
task.add_done_callback(
|
||||
lambda completed, task_key=key: self._discard_end_task(
|
||||
task_key, completed
|
||||
)
|
||||
)
|
||||
|
||||
def _discard_end_task(
|
||||
self, key: tuple[str, int], task: asyncio.Task
|
||||
) -> None:
|
||||
if self._end_tasks.get(key) is task:
|
||||
self._end_tasks.pop(key, None)
|
||||
if not task.cancelled():
|
||||
task.exception()
|
||||
|
||||
def _allocate_generation_locked(self) -> int:
|
||||
generation = self._next_generation
|
||||
self._next_generation += 1
|
||||
self._generation_end_events[generation] = asyncio.Event()
|
||||
return generation
|
||||
|
||||
async def _send_while_generation_active(
|
||||
self, transport, message: Dict[str, Any], generation: int
|
||||
) -> bool:
|
||||
end_event = self._generation_end_events.get(generation)
|
||||
if end_event is None or end_event.is_set():
|
||||
return False
|
||||
send_task = asyncio.create_task(transport.send_json(message))
|
||||
end_task = asyncio.create_task(end_event.wait())
|
||||
try:
|
||||
done, _ = await asyncio.wait(
|
||||
{send_task, end_task},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if end_task in done:
|
||||
send_task.cancel()
|
||||
await asyncio.gather(send_task, return_exceptions=True)
|
||||
return False
|
||||
await send_task
|
||||
return not end_event.is_set()
|
||||
finally:
|
||||
if not send_task.done():
|
||||
send_task.cancel()
|
||||
if not end_task.done():
|
||||
end_task.cancel()
|
||||
await asyncio.gather(
|
||||
send_task, end_task, return_exceptions=True
|
||||
)
|
||||
|
||||
def _pending_owner_locked(self, device: str) -> Optional[str]:
|
||||
if device in self.pending_calls:
|
||||
return device
|
||||
return next(
|
||||
(
|
||||
caller
|
||||
for caller, pending in self.pending_calls.items()
|
||||
if pending.target_mac == device
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def _pending_owner_now(self, device: str) -> Optional[str]:
|
||||
return self._pending_owner_locked(device)
|
||||
|
||||
def _pending_matches_locked(
|
||||
self, caller: str, target: str, generation: int
|
||||
) -> bool:
|
||||
pending = self.pending_calls.get(caller)
|
||||
return bool(
|
||||
pending
|
||||
and pending.target_mac == target
|
||||
and pending.generation == generation
|
||||
and self.call_generations.get(caller) == generation
|
||||
and self.call_generations.get(target) == generation
|
||||
)
|
||||
|
||||
def _active_matches_locked(
|
||||
self, caller: str, target: str, generation: int
|
||||
) -> bool:
|
||||
return (
|
||||
self.active_calls.get(caller) == target
|
||||
and self.active_calls.get(target) == caller
|
||||
and self.call_generations.get(caller) == generation
|
||||
and self.call_generations.get(target) == generation
|
||||
)
|
||||
|
||||
def _drop_generation(self, device_id: str) -> Optional[int]:
|
||||
return self.call_generations.pop(device_id, None)
|
||||
|
||||
@staticmethod
|
||||
async def _stop_ai_session(entry, session_id: Optional[str]) -> None:
|
||||
if entry is None:
|
||||
return
|
||||
end_conversation = getattr(entry.context, "end_conversation", None)
|
||||
if callable(end_conversation):
|
||||
await end_conversation(session_id)
|
||||
return
|
||||
cancel_tasks = getattr(entry.context, "cancel_conversation_tasks", None)
|
||||
if callable(cancel_tasks):
|
||||
await cancel_tasks()
|
||||
|
||||
@staticmethod
|
||||
def _set_call_state(entry, active: bool) -> None:
|
||||
if entry is None:
|
||||
return
|
||||
entry.context.calling = active
|
||||
if not active:
|
||||
entry.context.incoming_call = None
|
||||
|
||||
@staticmethod
|
||||
async def _notify_idle(entry, session_id: Optional[str], reason: str) -> None:
|
||||
raw_connection = getattr(entry.transport, "raw_connection", None)
|
||||
if raw_connection is None:
|
||||
return
|
||||
try:
|
||||
await raw_connection.notify_device_idle(session_id)
|
||||
finally:
|
||||
end_conversation = getattr(entry.context, "end_conversation", None)
|
||||
if callable(end_conversation):
|
||||
await end_conversation(session_id)
|
||||
if reason:
|
||||
logger.info(
|
||||
"Native MQTT通话结束: device_id={}, reason={}",
|
||||
entry.context.device_id,
|
||||
reason,
|
||||
)
|
||||
@@ -0,0 +1,137 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NativeMqttConnection:
|
||||
client_id: str
|
||||
device_id: Optional[str]
|
||||
connection_id: int
|
||||
context: Any
|
||||
transport: Any
|
||||
|
||||
@property
|
||||
def is_alive(self) -> bool:
|
||||
return bool(getattr(self.transport, "is_connected", False))
|
||||
|
||||
|
||||
class NativeMqttConnectionRegistry:
|
||||
def __init__(self):
|
||||
self._connections: Dict[str, NativeMqttConnection] = {}
|
||||
self._devices: Dict[str, NativeMqttConnection] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def register(self, context: Any, transport: Any) -> bool:
|
||||
client_id = getattr(transport, "client_id", None)
|
||||
raw_connection = getattr(transport, "raw_connection", None)
|
||||
connection_id = getattr(raw_connection, "connection_id", None)
|
||||
if not client_id or connection_id is None:
|
||||
return False
|
||||
|
||||
entry = NativeMqttConnection(
|
||||
client_id=client_id,
|
||||
device_id=self._normalize_device_id(
|
||||
getattr(context, "device_id", None)
|
||||
),
|
||||
connection_id=connection_id,
|
||||
context=context,
|
||||
transport=transport,
|
||||
)
|
||||
async with self._lock:
|
||||
previous_client = self._connections.get(client_id)
|
||||
previous_device = (
|
||||
self._devices.get(entry.device_id)
|
||||
if entry.device_id
|
||||
else None
|
||||
)
|
||||
for previous in (previous_client, previous_device):
|
||||
if previous is None or previous is entry:
|
||||
continue
|
||||
if self._connections.get(previous.client_id) is previous:
|
||||
self._connections.pop(previous.client_id, None)
|
||||
if (
|
||||
previous.device_id
|
||||
and self._devices.get(previous.device_id) is previous
|
||||
):
|
||||
self._devices.pop(previous.device_id, None)
|
||||
self._connections[client_id] = entry
|
||||
if entry.device_id:
|
||||
self._devices[entry.device_id] = entry
|
||||
return True
|
||||
|
||||
async def unregister(self, context: Any, transport: Any) -> bool:
|
||||
client_id = getattr(transport, "client_id", None)
|
||||
if not client_id:
|
||||
return False
|
||||
|
||||
async with self._lock:
|
||||
entry = self._connections.get(client_id)
|
||||
if (
|
||||
entry is None
|
||||
or entry.context is not context
|
||||
or entry.transport is not transport
|
||||
):
|
||||
return False
|
||||
self._connections.pop(client_id, None)
|
||||
if (
|
||||
entry.device_id
|
||||
and self._devices.get(entry.device_id) is entry
|
||||
):
|
||||
self._devices.pop(entry.device_id, None)
|
||||
return True
|
||||
|
||||
async def resolve(self, client_id: str) -> Optional[NativeMqttConnection]:
|
||||
async with self._lock:
|
||||
entry = self._connections.get(client_id)
|
||||
if entry is None or not entry.is_alive:
|
||||
return None
|
||||
return entry
|
||||
|
||||
async def status(self, client_ids: Iterable[str]) -> Dict[str, Dict[str, Any]]:
|
||||
async with self._lock:
|
||||
result = {}
|
||||
for client_id in client_ids:
|
||||
entry = self._connections.get(client_id)
|
||||
exists = entry is not None
|
||||
result[client_id] = {
|
||||
"isAlive": bool(entry and entry.is_alive),
|
||||
"exists": exists,
|
||||
"backend": "native",
|
||||
}
|
||||
return result
|
||||
|
||||
async def resolve_device(
|
||||
self, device_id: str
|
||||
) -> Optional[NativeMqttConnection]:
|
||||
async with self._lock:
|
||||
return self.resolve_device_now(device_id)
|
||||
|
||||
def resolve_device_now(
|
||||
self, device_id: str
|
||||
) -> Optional[NativeMqttConnection]:
|
||||
normalized = self._normalize_device_id(device_id)
|
||||
entry = self._devices.get(normalized) if normalized else None
|
||||
if entry is None or not entry.is_alive:
|
||||
return None
|
||||
return entry
|
||||
|
||||
async def clear(self) -> None:
|
||||
async with self._lock:
|
||||
self._connections.clear()
|
||||
self._devices.clear()
|
||||
|
||||
async def size(self) -> int:
|
||||
async with self._lock:
|
||||
return len(self._connections)
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return len(self._connections)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_device_id(device_id: Optional[str]) -> Optional[str]:
|
||||
if not isinstance(device_id, str):
|
||||
return None
|
||||
normalized = device_id.strip().lower().replace("-", ":")
|
||||
return normalized or None
|
||||
@@ -0,0 +1,525 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any, AsyncGenerator, Dict, Optional
|
||||
from .transport_interface import TransportInterface
|
||||
from config.logger import setup_logging
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
class MQTTTransport(TransportInterface):
|
||||
"""
|
||||
MQTT传输层实现:直接处理MQTT协议消息
|
||||
支持JSON消息和二进制音频数据传输
|
||||
"""
|
||||
|
||||
def __init__(self, mqtt_connection, udp_handler=None):
|
||||
"""
|
||||
初始化MQTT传输层
|
||||
|
||||
Args:
|
||||
mqtt_connection: MQTT连接对象,包含协议处理器
|
||||
udp_handler: UDP处理器,用于音频数据传输
|
||||
"""
|
||||
self._mqtt_connection = mqtt_connection
|
||||
self._udp_handler = udp_handler
|
||||
queue_size = int(getattr(mqtt_connection, "message_queue_size", 128) or 128)
|
||||
self._audio_queue = deque(maxlen=max(1, queue_size))
|
||||
self._control_queue = deque(maxlen=max(32, min(queue_size, 128)))
|
||||
self._urgent_queue = deque(maxlen=32)
|
||||
self._arrival_sequence = 0
|
||||
self._message_event = asyncio.Event()
|
||||
self._closed = False
|
||||
|
||||
# 设置MQTT连接的消息回调
|
||||
self._setup_message_handlers()
|
||||
|
||||
def _setup_message_handlers(self):
|
||||
"""设置消息处理回调"""
|
||||
# 设置MQTT消息接收回调
|
||||
self._mqtt_connection.set_message_callback(self._on_mqtt_message)
|
||||
|
||||
# 设置UDP消息接收回调(如果有UDP处理器)
|
||||
if self._udp_handler:
|
||||
self._udp_handler.set_message_callback(self._on_udp_message)
|
||||
|
||||
def _on_mqtt_message(self, topic: str, payload: str):
|
||||
"""处理接收到的MQTT消息"""
|
||||
try:
|
||||
# 解析JSON消息
|
||||
message_data = json.loads(payload)
|
||||
message_data['_transport_type'] = 'mqtt'
|
||||
message_data['_topic'] = topic
|
||||
|
||||
# Hello is a complete logical-session barrier. Discard all queued
|
||||
# work from the previous session before admitting the new Hello.
|
||||
if message_data.get("type") == "hello":
|
||||
self._audio_queue.clear()
|
||||
self._control_queue.clear()
|
||||
self._urgent_queue.clear()
|
||||
|
||||
self._enqueue_message(message_data)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"MQTT消息JSON解析失败: {e}, payload: {payload}")
|
||||
except Exception as e:
|
||||
logger.error(f"处理MQTT消息失败: {e}")
|
||||
|
||||
def _on_udp_message(self, audio_data: bytes, timestamp: int):
|
||||
"""处理接收到的UDP音频消息"""
|
||||
try:
|
||||
# 构造音频消息格式
|
||||
message_data = {
|
||||
'type': 'audio',
|
||||
'data': audio_data,
|
||||
'timestamp': timestamp,
|
||||
'_transport_type': 'udp'
|
||||
}
|
||||
|
||||
self._enqueue_message(message_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理UDP音频消息失败: {e}")
|
||||
|
||||
def _enqueue_message(self, message_data: Dict[str, Any]) -> None:
|
||||
self._arrival_sequence += 1
|
||||
queued_message = (self._arrival_sequence, message_data)
|
||||
if message_data.get("type") == "abort":
|
||||
self._urgent_queue.append(queued_message)
|
||||
self._message_event.set()
|
||||
return
|
||||
|
||||
if message_data.get("type") == "audio":
|
||||
if len(self._audio_queue) >= self._audio_queue.maxlen:
|
||||
self._audio_queue.popleft()
|
||||
logger.warning("MQTT audio receive queue is full; evicted oldest frame")
|
||||
self._audio_queue.append(queued_message)
|
||||
else:
|
||||
if len(self._control_queue) >= self._control_queue.maxlen:
|
||||
boundary_types = {"hello", "goodbye"}
|
||||
evict_index = next(
|
||||
(
|
||||
index
|
||||
for index, (_, queued) in enumerate(self._control_queue)
|
||||
if queued.get("type") not in boundary_types
|
||||
),
|
||||
None,
|
||||
)
|
||||
if evict_index is None:
|
||||
if message_data.get("type") not in boundary_types:
|
||||
logger.warning(
|
||||
"MQTT control queue contains only session boundaries; "
|
||||
"dropping non-boundary frame"
|
||||
)
|
||||
return
|
||||
self._control_queue.popleft()
|
||||
else:
|
||||
del self._control_queue[evict_index]
|
||||
logger.warning(
|
||||
"MQTT control queue is full; evicted non-boundary control frame"
|
||||
)
|
||||
self._control_queue.append(queued_message)
|
||||
self._message_event.set()
|
||||
|
||||
async def _next_message(self):
|
||||
while not self._closed:
|
||||
# Hello establishes the logical-session boundary. An Abort sent
|
||||
# immediately after the Hello reply must not overtake it and be
|
||||
# compared against the previous session.
|
||||
if (
|
||||
self._control_queue
|
||||
and self._control_queue[0][1].get("type") == "hello"
|
||||
):
|
||||
return self._control_queue.popleft()[1]
|
||||
if self._urgent_queue:
|
||||
return self._urgent_queue.popleft()[1]
|
||||
if self._control_queue and self._audio_queue:
|
||||
queue = (
|
||||
self._control_queue
|
||||
if self._control_queue[0][0] < self._audio_queue[0][0]
|
||||
else self._audio_queue
|
||||
)
|
||||
return queue.popleft()[1]
|
||||
if self._control_queue:
|
||||
return self._control_queue.popleft()[1]
|
||||
if self._audio_queue:
|
||||
return self._audio_queue.popleft()[1]
|
||||
|
||||
self._message_event.clear()
|
||||
if self._urgent_queue or self._control_queue or self._audio_queue:
|
||||
continue
|
||||
await asyncio.wait_for(self._message_event.wait(), timeout=1.0)
|
||||
return None
|
||||
|
||||
async def send(self, data: Any) -> None:
|
||||
"""发送消息"""
|
||||
if self._closed:
|
||||
raise RuntimeError("Transport is closed")
|
||||
|
||||
try:
|
||||
if isinstance(data, dict):
|
||||
# 根据消息类型选择传输方式
|
||||
if data.get('type') == 'audio' and self._udp_handler:
|
||||
# 音频数据通过UDP发送
|
||||
audio_data = data.get('data')
|
||||
timestamp = data.get('timestamp', 0)
|
||||
await self._udp_handler.send_audio(audio_data, timestamp)
|
||||
else:
|
||||
# JSON消息通过MQTT发送
|
||||
topic = data.get('_topic', self._mqtt_connection.reply_topic)
|
||||
payload = json.dumps(data)
|
||||
await self._mqtt_connection.send_message(topic, payload)
|
||||
|
||||
elif isinstance(data, str):
|
||||
# 字符串消息通过MQTT发送
|
||||
await self._mqtt_connection.send_message(
|
||||
self._mqtt_connection.reply_topic,
|
||||
data
|
||||
)
|
||||
|
||||
elif isinstance(data, bytes):
|
||||
# 二进制数据通过UDP发送(如果有UDP处理器)
|
||||
if self._udp_handler:
|
||||
await self._udp_handler.send_audio(data, 0)
|
||||
else:
|
||||
logger.warning("尝试发送二进制数据但没有UDP处理器")
|
||||
|
||||
else:
|
||||
# 其他类型转换为字符串通过MQTT发送
|
||||
await self._mqtt_connection.send_message(
|
||||
self._mqtt_connection.reply_topic,
|
||||
str(data)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"MQTT传输发送消息失败: {e}")
|
||||
raise
|
||||
|
||||
async def send_json(self, message: Any) -> None:
|
||||
if isinstance(message, str):
|
||||
await self.send(message)
|
||||
return
|
||||
await self.send(dict(message))
|
||||
|
||||
async def send_audio(self, audio: bytes, timestamp: int = 0) -> None:
|
||||
if not self._udp_handler:
|
||||
raise RuntimeError("UDP audio channel is not available")
|
||||
await self._udp_handler.send_audio(audio, timestamp)
|
||||
|
||||
@property
|
||||
def requires_audio_tail_grace(self) -> bool:
|
||||
return True
|
||||
|
||||
async def prepare_audio_channel(self, audio_params=None, version: int = 3) -> None:
|
||||
if not self._udp_handler:
|
||||
return
|
||||
if getattr(self._mqtt_connection, "udp_config", None) is None:
|
||||
await self._mqtt_connection.send_hello_reply(audio_params or {}, version)
|
||||
|
||||
async def wait_audio_ready(self, timeout: float = 0) -> bool:
|
||||
if not self._udp_handler:
|
||||
return False
|
||||
deadline = time.monotonic() + max(timeout, 0)
|
||||
while getattr(self._udp_handler, "remote_address", None) is None:
|
||||
if time.monotonic() >= deadline:
|
||||
return False
|
||||
await asyncio.sleep(min(0.05, max(deadline - time.monotonic(), 0)))
|
||||
return True
|
||||
|
||||
async def mark_business_ready(self) -> None:
|
||||
self._mqtt_connection.business_ready_event.set()
|
||||
schedule_recovery = getattr(
|
||||
self._mqtt_connection, "schedule_stale_session_recovery", None
|
||||
)
|
||||
if callable(schedule_recovery):
|
||||
schedule_recovery()
|
||||
|
||||
async def mark_session_ready(self, session_id: str = None) -> None:
|
||||
self._mqtt_connection.mark_business_session_ready(session_id)
|
||||
|
||||
async def end_session(self, session_id: str) -> None:
|
||||
await self._mqtt_connection.notify_device_idle(session_id)
|
||||
|
||||
async def receive(self) -> AsyncGenerator[Any, None]:
|
||||
"""异步消息流"""
|
||||
while not self._closed:
|
||||
try:
|
||||
# 等待消息,设置超时避免无限等待
|
||||
message = await self._next_message()
|
||||
if message is None:
|
||||
break
|
||||
yield message
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# 超时继续循环,检查连接状态
|
||||
if not self.is_connected:
|
||||
break
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"MQTT传输接收消息失败: {e}")
|
||||
break
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭传输层"""
|
||||
if self._closed:
|
||||
return
|
||||
|
||||
self._closed = True
|
||||
|
||||
try:
|
||||
# 关闭MQTT连接
|
||||
if self._mqtt_connection:
|
||||
await self._mqtt_connection.close()
|
||||
|
||||
# 关闭UDP处理器
|
||||
if self._udp_handler:
|
||||
await self._udp_handler.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"关闭MQTT传输层失败: {e}")
|
||||
raise RuntimeError("MQTT transport close failed")
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""检查连接状态"""
|
||||
if self._closed:
|
||||
return False
|
||||
|
||||
try:
|
||||
# 检查MQTT连接状态
|
||||
mqtt_connected = (
|
||||
self._mqtt_connection and
|
||||
self._mqtt_connection.is_connected()
|
||||
)
|
||||
|
||||
return mqtt_connected
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"检查MQTT连接状态失败: {e}")
|
||||
return False
|
||||
|
||||
@property
|
||||
def device_id(self) -> Optional[str]:
|
||||
"""获取设备ID"""
|
||||
return getattr(self._mqtt_connection, 'device_id', None)
|
||||
|
||||
@property
|
||||
def client_id(self) -> Optional[str]:
|
||||
"""获取客户端ID"""
|
||||
return getattr(self._mqtt_connection, 'client_id', None)
|
||||
|
||||
@property
|
||||
def username(self) -> Optional[str]:
|
||||
"""获取MQTT用户名"""
|
||||
return getattr(self._mqtt_connection, 'username', None)
|
||||
|
||||
@property
|
||||
def password(self) -> Optional[str]:
|
||||
"""获取MQTT密码"""
|
||||
return getattr(self._mqtt_connection, 'password', None)
|
||||
|
||||
@property
|
||||
def session_id(self) -> Optional[str]:
|
||||
"""获取会话ID"""
|
||||
return getattr(self._mqtt_connection, 'session_id', None)
|
||||
|
||||
@property
|
||||
def transport_type(self) -> str:
|
||||
return "mqtt"
|
||||
|
||||
@property
|
||||
def has_datagram_audio(self) -> bool:
|
||||
return self._udp_handler is not None
|
||||
|
||||
@property
|
||||
def keeps_connection_between_sessions(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_protocol_authenticated(self) -> bool:
|
||||
return bool(getattr(self._mqtt_connection, "connect_accepted", False))
|
||||
|
||||
@property
|
||||
def raw_connection(self):
|
||||
return self._mqtt_connection
|
||||
|
||||
|
||||
class UDPAudioHandler:
|
||||
"""
|
||||
UDP音频处理器:处理加密音频数据传输
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connection_id: int,
|
||||
udp_server,
|
||||
encryption_config: Dict[str, Any],
|
||||
allowed_remote_ip: Optional[str] = None,
|
||||
):
|
||||
self.connection_id = connection_id
|
||||
self.udp_server = udp_server
|
||||
self.encryption_config = encryption_config
|
||||
self.allowed_remote_ip = allowed_remote_ip
|
||||
self.remote_address = None
|
||||
self.message_callback = None
|
||||
self.audio_interceptor = None
|
||||
self._closed = False
|
||||
self.local_sequence = 0
|
||||
self.remote_sequence = 0
|
||||
self.audio_sequence_start = None
|
||||
self.audio_start_time = None
|
||||
self.frame_ms = 60
|
||||
|
||||
def set_message_callback(self, callback):
|
||||
"""设置消息接收回调"""
|
||||
self.message_callback = callback
|
||||
|
||||
def set_audio_interceptor(self, callback):
|
||||
self.audio_interceptor = callback
|
||||
|
||||
def configure_encryption(self, udp_config: Dict[str, Any]):
|
||||
"""设置UDP加密参数"""
|
||||
if not udp_config:
|
||||
return
|
||||
self.encryption_config = udp_config
|
||||
# A new Hello creates a new UDP session. Allow the first valid packet
|
||||
# from the MQTT peer to establish the new source tuple.
|
||||
self.remote_address = None
|
||||
self.reset_sequence()
|
||||
|
||||
def reset_sequence(self):
|
||||
"""重置UDP序列号(本地/远端)"""
|
||||
self.reset_local_sequence()
|
||||
self.reset_remote_sequence()
|
||||
|
||||
def reset_local_sequence(self):
|
||||
"""重置本地发送序列号"""
|
||||
self.local_sequence = 0
|
||||
|
||||
def reset_remote_sequence(self):
|
||||
"""重置远端接收序列号"""
|
||||
self.remote_sequence = 0
|
||||
self.audio_sequence_start = None
|
||||
self.audio_start_time = None
|
||||
|
||||
async def send_audio(self, audio_data: bytes, timestamp: int):
|
||||
"""发送音频数据"""
|
||||
if self._closed:
|
||||
raise RuntimeError("UDP audio handler is closed")
|
||||
if not self.remote_address:
|
||||
raise RuntimeError("UDP remote address is not ready")
|
||||
|
||||
next_sequence = self.local_sequence + 1
|
||||
await self.udp_server.send_encrypted_audio(
|
||||
self.connection_id,
|
||||
audio_data,
|
||||
timestamp,
|
||||
next_sequence,
|
||||
self.remote_address,
|
||||
self.encryption_config
|
||||
)
|
||||
self.local_sequence = next_sequence
|
||||
|
||||
def send_audio_nowait(self, audio_data: bytes, timestamp: int) -> bool:
|
||||
if self._closed or not self.remote_address:
|
||||
return False
|
||||
next_sequence = self.local_sequence + 1
|
||||
self.udp_server.send_encrypted_audio_nowait(
|
||||
self.connection_id,
|
||||
audio_data,
|
||||
timestamp,
|
||||
next_sequence,
|
||||
self.remote_address,
|
||||
self.encryption_config,
|
||||
)
|
||||
self.local_sequence = next_sequence
|
||||
return True
|
||||
|
||||
def on_udp_message(self, header: bytes, encrypted_payload: bytes, payload_length: int,
|
||||
timestamp: int, sequence: int, remote_addr):
|
||||
"""处理接收到的UDP消息"""
|
||||
if self._closed:
|
||||
return
|
||||
|
||||
if self.allowed_remote_ip and remote_addr[0] != self.allowed_remote_ip:
|
||||
logger.warning(
|
||||
"Rejected UDP packet from non-MQTT peer: {}, expected IP: {}",
|
||||
remote_addr,
|
||||
self.allowed_remote_ip,
|
||||
)
|
||||
return
|
||||
|
||||
if self.remote_address is not None and remote_addr != self.remote_address:
|
||||
logger.warning(
|
||||
"Rejected UDP source rebind during active session: {}, bound: {}",
|
||||
remote_addr,
|
||||
self.remote_address,
|
||||
)
|
||||
return
|
||||
|
||||
if self.audio_sequence_start is not None and sequence <= self.remote_sequence:
|
||||
return
|
||||
|
||||
if sequence != self.remote_sequence + 1:
|
||||
logger.warning(
|
||||
"Received UDP packet with wrong sequence: {}, expected: {}",
|
||||
sequence,
|
||||
self.remote_sequence + 1
|
||||
)
|
||||
|
||||
if len(encrypted_payload) != payload_length:
|
||||
logger.warning(
|
||||
"UDP payload length mismatch: {} != {}",
|
||||
len(encrypted_payload),
|
||||
payload_length,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
key = self.encryption_config.get('key') if self.encryption_config else None
|
||||
if key:
|
||||
cipher = Cipher(algorithms.AES(key), modes.CTR(header), backend=default_backend())
|
||||
decryptor = cipher.decryptor()
|
||||
payload = decryptor.update(encrypted_payload) + decryptor.finalize()
|
||||
else:
|
||||
payload = encrypted_payload
|
||||
except Exception as e:
|
||||
logger.error("UDP decrypt failed: {}", e)
|
||||
return
|
||||
|
||||
if self.remote_address is None:
|
||||
self.remote_address = remote_addr
|
||||
self.audio_start_time = time.time()
|
||||
self.audio_sequence_start = sequence
|
||||
self.remote_sequence = sequence - 1
|
||||
|
||||
if self.audio_sequence_start is None:
|
||||
self.audio_sequence_start = sequence
|
||||
|
||||
self.remote_sequence = sequence
|
||||
|
||||
normalized_timestamp = timestamp
|
||||
if timestamp == 0 and self.audio_sequence_start is not None:
|
||||
normalized_timestamp = (
|
||||
(sequence - self.audio_sequence_start) * self.frame_ms
|
||||
) % (2 ** 32)
|
||||
|
||||
if self.audio_interceptor and self.audio_interceptor(
|
||||
payload, normalized_timestamp
|
||||
):
|
||||
return
|
||||
|
||||
if self.message_callback:
|
||||
self.message_callback(payload, normalized_timestamp)
|
||||
|
||||
async def close(self):
|
||||
"""关闭UDP处理器"""
|
||||
self._closed = True
|
||||
self.message_callback = None
|
||||
self.audio_interceptor = None
|
||||
self.remote_address = None
|
||||
@@ -0,0 +1,100 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
from config.logger import setup_logging
|
||||
|
||||
logger = setup_logging()
|
||||
TAG = __name__
|
||||
|
||||
_MAC_ADDRESS_PATTERN = re.compile(r"^(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$")
|
||||
_ENDPOINT_SCHEME_PATTERN = re.compile(
|
||||
r"^(?:mqtt|tcp|ssl|ws|wss|http|https)://", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def normalize_signature_key(secret_key: str) -> str:
|
||||
"""Treat values emitted by manager-api for an unset parameter as empty."""
|
||||
if secret_key is None:
|
||||
return ""
|
||||
value = str(secret_key).strip()
|
||||
if not value or value.lower() == "null" or "你" in value:
|
||||
return ""
|
||||
return value
|
||||
|
||||
|
||||
def generate_password_signature(content: str, secret_key: str) -> str:
|
||||
"""生成MQTT密码签名(HMAC-SHA256 + Base64)"""
|
||||
try:
|
||||
hmac_obj = hmac.new(
|
||||
secret_key.encode("utf-8"), content.encode("utf-8"), hashlib.sha256
|
||||
)
|
||||
signature = hmac_obj.digest()
|
||||
return base64.b64encode(signature).decode("utf-8")
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"生成MQTT密码签名失败: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def parse_mqtt_endpoint(endpoint: str, default_port: int = None) -> tuple[str, int]:
|
||||
"""Parse the host[:port] syntax supported by the current ESP firmware."""
|
||||
if endpoint is None:
|
||||
return "", default_port
|
||||
value = str(endpoint).strip()
|
||||
if not value or value.lower() == "null" or "你" in value:
|
||||
return "", default_port
|
||||
|
||||
value = _ENDPOINT_SCHEME_PATTERN.sub("", value, count=1).split("/", 1)[0]
|
||||
if not value or value.startswith("[") or value.count(":") > 1:
|
||||
raise ValueError("MQTT endpoint格式无效")
|
||||
|
||||
host = value
|
||||
port = default_port
|
||||
if ":" in value:
|
||||
host, port_text = value.rsplit(":", 1)
|
||||
if not port_text.isdigit():
|
||||
raise ValueError("MQTT endpoint端口无效")
|
||||
port = int(port_text)
|
||||
|
||||
if not host or any(char.isspace() for char in host):
|
||||
raise ValueError("MQTT endpoint主机无效")
|
||||
if port is not None and not 1 <= int(port) <= 65535:
|
||||
raise ValueError("MQTT endpoint端口超出范围")
|
||||
return host, int(port) if port is not None else None
|
||||
|
||||
|
||||
def validate_mqtt_credentials(
|
||||
client_id: str, username: str, password: str, secret_key: str
|
||||
) -> None:
|
||||
"""Validate the gateway-compatible MQTT client id and HMAC credentials."""
|
||||
if not client_id or not isinstance(client_id, str):
|
||||
raise ValueError("clientId必须是非空字符串")
|
||||
|
||||
parts = client_id.split("@@@")
|
||||
if len(parts) not in (2, 3) or not parts[0] or not parts[1]:
|
||||
raise ValueError("clientId格式错误")
|
||||
|
||||
mac_address = parts[1].replace("_", ":")
|
||||
if not _MAC_ADDRESS_PATTERN.fullmatch(mac_address):
|
||||
raise ValueError("clientId中的MAC地址无效")
|
||||
|
||||
normalized_key = normalize_signature_key(secret_key)
|
||||
if len(parts) == 2:
|
||||
if normalized_key:
|
||||
raise ValueError("启用签名时clientId必须包含UUID")
|
||||
return
|
||||
|
||||
if not username or not isinstance(username, str):
|
||||
raise ValueError("username必须是非空字符串")
|
||||
try:
|
||||
user_data = json.loads(base64.b64decode(username, validate=True).decode("utf-8"))
|
||||
if not isinstance(user_data, dict):
|
||||
raise ValueError
|
||||
except Exception as exc:
|
||||
raise ValueError("username不是有效的base64编码JSON") from exc
|
||||
|
||||
if normalized_key:
|
||||
expected = generate_password_signature(client_id + "|" + username, normalized_key)
|
||||
if not password or not hmac.compare_digest(password, expected):
|
||||
raise ValueError("密码签名验证失败")
|
||||
@@ -19,7 +19,7 @@ class XiaozhiServerFacade:
|
||||
提供统一的服务器管理接口,屏蔽内部协议复杂性
|
||||
|
||||
功能:
|
||||
- 协议管理
|
||||
- 协议管理(WebSocket、MQTT)
|
||||
- 本地 ASR 模型预加载
|
||||
- 优雅启动和停止
|
||||
"""
|
||||
@@ -49,15 +49,24 @@ class XiaozhiServerFacade:
|
||||
protocols = self.config.get("protocols", {})
|
||||
if not isinstance(protocols, dict):
|
||||
protocols = {}
|
||||
mqtt_config = self.config.get("mqtt_server", {})
|
||||
if not isinstance(mqtt_config, dict):
|
||||
mqtt_config = {}
|
||||
|
||||
requested = protocols.get("enabled_protocols")
|
||||
requested = requested if isinstance(requested, list) else []
|
||||
websocket_enabled = protocols.get("websocket_enabled")
|
||||
if websocket_enabled is None:
|
||||
websocket_enabled = not protocols or "websocket" in requested
|
||||
|
||||
mqtt_requested = protocols.get("mqtt_enabled") is True or "mqtt" in requested
|
||||
mqtt_enabled = mqtt_config.get("enabled") is True and mqtt_requested
|
||||
|
||||
enabled_protocols = []
|
||||
if websocket_enabled:
|
||||
enabled_protocols.append("websocket")
|
||||
if mqtt_enabled:
|
||||
enabled_protocols.append("mqtt")
|
||||
|
||||
self.config["enabled_protocols"] = enabled_protocols
|
||||
logger.info(f"启用的协议: {enabled_protocols}")
|
||||
@@ -398,7 +407,7 @@ class XiaozhiServerFacade:
|
||||
"""获取支持的协议列表"""
|
||||
if self.multi_protocol_server:
|
||||
return self.multi_protocol_server.get_supported_protocols()
|
||||
return ['websocket']
|
||||
return ['websocket', 'mqtt']
|
||||
|
||||
def is_protocol_enabled(self, protocol: str) -> bool:
|
||||
"""检查协议是否启用"""
|
||||
@@ -416,6 +425,68 @@ class XiaozhiServerFacade:
|
||||
if self.multi_protocol_server:
|
||||
await self.multi_protocol_server.broadcast_message(message, protocol)
|
||||
|
||||
def _get_protocol_server(self, protocol: str):
|
||||
if not self.multi_protocol_server:
|
||||
return None
|
||||
return self.multi_protocol_server.servers.get(protocol)
|
||||
|
||||
async def register_connection_context(self, context, transport) -> bool:
|
||||
if getattr(transport, "transport_type", None) != "mqtt":
|
||||
return False
|
||||
server = self._get_protocol_server("mqtt")
|
||||
if server is None:
|
||||
return False
|
||||
return await server.register_connection_context(context, transport)
|
||||
|
||||
async def unregister_connection_context(self, context, transport) -> bool:
|
||||
if getattr(transport, "transport_type", None) != "mqtt":
|
||||
return False
|
||||
server = self._get_protocol_server("mqtt")
|
||||
if server is None:
|
||||
return False
|
||||
return await server.unregister_connection_context(context, transport)
|
||||
|
||||
async def resolve_native_mqtt_connection(self, client_id: str):
|
||||
server = self._get_protocol_server("mqtt")
|
||||
if server is None:
|
||||
return None
|
||||
return await server.resolve_connection_context(client_id)
|
||||
|
||||
async def get_native_mqtt_status(self, client_ids):
|
||||
server = self._get_protocol_server("mqtt")
|
||||
if server is None:
|
||||
return {
|
||||
client_id: {
|
||||
"isAlive": False,
|
||||
"exists": False,
|
||||
"backend": "native",
|
||||
}
|
||||
for client_id in client_ids
|
||||
}
|
||||
return await server.get_connection_status(client_ids)
|
||||
|
||||
async def request_native_mqtt_call(
|
||||
self, caller_mac: str, target_mac: str, caller_nickname: str = ""
|
||||
):
|
||||
server = self._get_protocol_server("mqtt")
|
||||
if server is None:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Native MQTT服务未启动",
|
||||
}
|
||||
return await server.request_device_call(
|
||||
caller_mac, target_mac, caller_nickname
|
||||
)
|
||||
|
||||
async def accept_native_mqtt_call(self, device_id: str):
|
||||
server = self._get_protocol_server("mqtt")
|
||||
if server is None:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Native MQTT服务未启动",
|
||||
}
|
||||
return await server.accept_device_call(device_id)
|
||||
|
||||
def get_websocket_info(self) -> Dict[str, Any]:
|
||||
"""获取WebSocket连接信息"""
|
||||
if not self.is_protocol_enabled('websocket'):
|
||||
@@ -429,9 +500,24 @@ class XiaozhiServerFacade:
|
||||
'path': '/xiaozhi/v1/'
|
||||
}
|
||||
|
||||
def get_mqtt_info(self) -> Dict[str, Any]:
|
||||
"""获取MQTT连接信息"""
|
||||
if not self.is_protocol_enabled('mqtt'):
|
||||
return {'enabled': False}
|
||||
|
||||
mqtt_config = self.config.get('mqtt_server', {})
|
||||
return {
|
||||
'enabled': True,
|
||||
'host': mqtt_config.get('host', '0.0.0.0'),
|
||||
'port': mqtt_config.get('port', 1883),
|
||||
'udp_port': mqtt_config.get('udp_port', 1883),
|
||||
'public_endpoint': mqtt_config.get('public_endpoint', '')
|
||||
}
|
||||
|
||||
def get_connection_info(self) -> Dict[str, Any]:
|
||||
"""获取所有协议的连接信息"""
|
||||
return {
|
||||
'websocket': self.get_websocket_info(),
|
||||
'mqtt': self.get_mqtt_info(),
|
||||
'active_connections': self.get_active_connections_count()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user