From eac573706d6eb94845aab91f7cf33b04a8103dc4 Mon Sep 17 00:00:00 2001 From: caixypromise Date: Mon, 27 Jul 2026 02:10:55 +0800 Subject: [PATCH] feat: add optional native mqtt and udp transport --- docs/native-mqtt-integration.md | 161 +++ .../xiaozhi/common/constant/Constant.java | 28 +- .../device/controller/OTAController.java | 48 +- .../impl/DeviceAddressBookServiceImpl.java | 99 +- .../service/impl/DeviceServiceImpl.java | 409 ++++-- .../device/service/impl/MqttClientId.java | 28 + .../impl/MqttGatewayAuthorization.java | 19 +- .../impl/MqttManagementEndpointResolver.java | 183 +++ .../impl/MqttManagementHttpClient.java | 56 + .../service/impl/MqttManagementRouter.java | 431 ++++++ .../sys/controller/SysParamsController.java | 6 +- .../service/impl/SysParamsServiceImpl.java | 7 +- .../resources/db/changelog/202607190300.sql | 15 + .../resources/db/changelog/202607260900.sql | 9 + .../resources/db/changelog/202607262100.sql | 11 + .../db/changelog/db.changelog-master.yaml | 23 + main/xiaozhi-server/app.py | 26 +- main/xiaozhi-server/config.yaml | 65 +- .../api/native_mqtt_management_handler.py | 323 +++++ main/xiaozhi-server/core/api/ota_handler.py | 172 ++- main/xiaozhi-server/core/http_server.py | 54 +- .../core/processors/goodbye_processor.py | 14 +- .../core/processors/message_router.py | 3 + .../core/processors/timeout_processor.py | 4 +- .../core/processors/udp_timeout_processor.py | 52 + .../core/protocols/mqtt_connection.py | 649 +++++++++ .../core/protocols/mqtt_protocol.py | 651 +++++++++ .../core/servers/mqtt_server.py | 1167 +++++++++++++++++ .../core/servers/multi_protocol_server.py | 18 +- .../core/services/connection_service.py | 216 ++- .../core/services/native_mqtt_call_manager.py | 726 ++++++++++ .../native_mqtt_connection_registry.py | 137 ++ .../core/transport/mqtt_transport.py | 525 ++++++++ main/xiaozhi-server/core/utils/mqtt_auth.py | 100 ++ .../core/xiaozhi_server_facade.py | 90 +- 35 files changed, 6325 insertions(+), 200 deletions(-) create mode 100644 docs/native-mqtt-integration.md create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttClientId.java create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttManagementEndpointResolver.java create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttManagementHttpClient.java create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttManagementRouter.java create mode 100644 main/manager-api/src/main/resources/db/changelog/202607190300.sql create mode 100644 main/manager-api/src/main/resources/db/changelog/202607260900.sql create mode 100644 main/manager-api/src/main/resources/db/changelog/202607262100.sql create mode 100644 main/xiaozhi-server/core/api/native_mqtt_management_handler.py create mode 100644 main/xiaozhi-server/core/processors/udp_timeout_processor.py create mode 100644 main/xiaozhi-server/core/protocols/mqtt_connection.py create mode 100644 main/xiaozhi-server/core/protocols/mqtt_protocol.py create mode 100644 main/xiaozhi-server/core/servers/mqtt_server.py create mode 100644 main/xiaozhi-server/core/services/native_mqtt_call_manager.py create mode 100644 main/xiaozhi-server/core/services/native_mqtt_connection_registry.py create mode 100644 main/xiaozhi-server/core/transport/mqtt_transport.py create mode 100644 main/xiaozhi-server/core/utils/mqtt_auth.py diff --git a/docs/native-mqtt-integration.md b/docs/native-mqtt-integration.md new file mode 100644 index 00000000..d2bf2d98 --- /dev/null +++ b/docs/native-mqtt-integration.md @@ -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: `@@@@@@` +- publish topic: `device-server` +- subscribe topic: `devices/p2p/` +- 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. diff --git a/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java b/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java index 506e6762..cc5bbcf0 100644 --- a/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java +++ b/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java @@ -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; } } -} \ No newline at end of file +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java b/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java index 4a4bfee8..818ac3ef 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java @@ -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 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 createResponse(DeviceReportRespDTO deviceReportRespDTO) { ObjectMapper objectMapper = new ObjectMapper(); diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceAddressBookServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceAddressBookServiceImpl.java index ff2e4317..9484f4b0 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceAddressBookServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceAddressBookServiceImpl.java @@ -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> 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 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 postToMqtt(String path, Map body, String action) { + private Map postCallRequest( + String callerClientId, + String targetClientId, + Map body) { + try { + MqttManagementHttpClient.Response response = + createMqttManagementRouter().sendCallRequest( + callerClientId, targetClientId, body); + return parseCallResponse(response, "呼叫"); + } catch (Exception e) { + return errorResult("呼叫失败,请稍后再试"); + } + } + + private Map postCallAccept( + String clientId, + Map body) { + try { + MqttManagementHttpClient.Response response = + createMqttManagementRouter().sendCallAccept( + clientId, body); + return parseCallResponse(response, "接听"); + } catch (Exception e) { + return errorResult("接听失败,请稍后再试"); + } + } + + private Map parseCallResponse( + MqttManagementHttpClient.Response response, + String action) { Map 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 gwResult = JSONUtil.parseObj(response); - result.put("status", gwResult.get("status")); - result.put("message", gwResult.get("message")); + Map 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; diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java index 2ed817b1..f126f518 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java @@ -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 */ @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 devices = getUserDevices(user.getId(), agentId); // 构建deviceIds数组 - Set 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 deviceIds = devices.stream() + .map(device -> MqttClientId.build( + device.getBoard(), device.getMacAddress())) + .collect(Collectors.toSet()); // 构建请求入参 - Map> params = MapUtil - .builder(new HashMap>()) - .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 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 } 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 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 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 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 } // 构建客户端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 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 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 } // 构建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 allTools = new ArrayList<>(); String cursor = null; + Set seenCursors = new java.util.HashSet<>(); + int pageCount = 0; + MqttManagementRouter managementRouter = + createMqttManagementRouter(); + MqttManagementEndpointResolver.Backend selectedBackend = null; // 循环获取分页数据 - while (true) { + while (pageCount++ < 32) { // 构建params Map paramsMap = MapUtil.builder(new HashMap()) .put("withUserTools", true) @@ -754,26 +953,44 @@ public class DeviceServiceImpl extends BaseServiceImpl .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 String nextCursor = data.getStr("nextCursor"); if (StringUtils.isBlank(nextCursor)) { // 没有下一页了 - break; + Map 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 resultData = new HashMap<>(); - resultData.put("tools", allTools); - return resultData; + return null; } @Override public Object callDeviceTool(String deviceId, String toolName, Map 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 } // 构建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 params = MapUtil @@ -845,7 +1052,11 @@ public class DeviceServiceImpl extends BaseServiceImpl .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)) { diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttClientId.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttClientId.java new file mode 100644 index 00000000..deb8760d --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttClientId.java @@ -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("-", "_"); + } +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttGatewayAuthorization.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttGatewayAuthorization.java index f165419a..4ba2a1de 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttGatewayAuthorization.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttGatewayAuthorization.java @@ -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 generateDailyTokens(String signatureKey, Instant now) { if (isMissingSignatureKey(signatureKey)) { throw new GatewayRequestException("MQTT Gateway signature key is empty", null); diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttManagementEndpointResolver.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttManagementEndpointResolver.java new file mode 100644 index 00000000..c85b36fd --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttManagementEndpointResolver.java @@ -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 resolve() { + List endpoints = new ArrayList<>(2); + resolveNative().ifPresent(endpoints::add); + resolveGateway().ifPresent(endpoints::add); + return endpoints; + } + + java.util.Optional 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 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); + } + } +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttManagementHttpClient.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttManagementHttpClient.java new file mode 100644 index 00000000..21c227f4 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttManagementHttpClient.java @@ -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; + } + } +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttManagementRouter.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttManagementRouter.java new file mode 100644 index 00000000..a24872e7 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttManagementRouter.java @@ -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 clientIds) { + List endpoints = + endpointResolver.resolve(); + if (endpoints.isEmpty()) { + return ""; + } + + Map 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 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 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 endpoints = + endpointResolver.resolve(); + if (endpoints.isEmpty()) { + return null; + } + + List 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 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 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 clientIds, + String path, + Object requestBody) { + List endpoints = + endpointResolver.resolve(); + if (endpoints.isEmpty()) { + return null; + } + + Map> 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 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> + 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 initializeStatuses( + Set clientIds) { + Map result = new LinkedHashMap<>(); + clientIds.forEach(clientId -> + result.put(clientId, new MutableDeviceStatus())); + return result; + } + + private static void mergeStatusResponse( + Map merged, + MqttManagementEndpointResolver.Backend backend, + String body) { + Map 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 parseStatusResponse( + String body, Set clientIds) { + JSONObject response; + try { + response = JSONUtil.parseObj(body); + } catch (RuntimeException e) { + throw new IllegalStateException( + "MQTT管理状态响应不是JSON对象", e); + } + Map 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 backends = new ArrayList<>(); + + Map toMap() { + Map result = new LinkedHashMap<>(); + result.put("isAlive", alive); + result.put("exists", exists); + result.put("backends", backends); + return result; + } + } + + private record BackendDeviceStatus(boolean exists, boolean alive) { + } +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java b/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java index 49317aa4..c960fa42 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java @@ -85,6 +85,7 @@ public class SysParamsController { public Result 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")) { diff --git a/main/manager-api/src/main/java/xiaozhi/modules/sys/service/impl/SysParamsServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/sys/service/impl/SysParamsServiceImpl.java index b8bc796a..0b282fec 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/sys/service/impl/SysParamsServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/sys/service/impl/SysParamsServiceImpl.java @@ -205,9 +205,14 @@ public class SysParamsServiceImpl extends BaseServiceImpl{time:YYMMDD HH:mm:ss}[{version}_{selected_module}][{extra[tag]}]-{level}-{message}" diff --git a/main/xiaozhi-server/core/api/native_mqtt_management_handler.py b/main/xiaozhi-server/core/api/native_mqtt_management_handler.py new file mode 100644 index 00000000..10ada3de --- /dev/null +++ b/main/xiaozhi-server/core/api/native_mqtt_management_handler.py @@ -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}) diff --git a/main/xiaozhi-server/core/api/ota_handler.py b/main/xiaozhi-server/core/api/ota_handler.py index 1e3b3bd0..78e061dc 100644 --- a/main/xiaozhi-server/core/api/ota_handler.py +++ b/main/xiaozhi-server/core/api/ota_handler.py @@ -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: diff --git a/main/xiaozhi-server/core/http_server.py b/main/xiaozhi-server/core/http_server.py index da6251d6..9c982f7a 100644 --- a/main/xiaozhi-server/core/http_server.py +++ b/main/xiaozhi-server/core/http_server.py @@ -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 diff --git a/main/xiaozhi-server/core/processors/goodbye_processor.py b/main/xiaozhi-server/core/processors/goodbye_processor.py index 186300ca..01e6cfb6 100644 --- a/main/xiaozhi-server/core/processors/goodbye_processor.py +++ b/main/xiaozhi-server/core/processors/goodbye_processor.py @@ -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")) diff --git a/main/xiaozhi-server/core/processors/message_router.py b/main/xiaozhi-server/core/processors/message_router.py index 2737c423..4e6d4431 100644 --- a/main/xiaozhi-server/core/processors/message_router.py +++ b/main/xiaozhi-server/core/processors/message_router.py @@ -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消息 diff --git a/main/xiaozhi-server/core/processors/timeout_processor.py b/main/xiaozhi-server/core/processors/timeout_processor.py index 81d760e2..daedfdc3 100644 --- a/main/xiaozhi-server/core/processors/timeout_processor.py +++ b/main/xiaozhi-server/core/processors/timeout_processor.py @@ -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, ) diff --git a/main/xiaozhi-server/core/processors/udp_timeout_processor.py b/main/xiaozhi-server/core/processors/udp_timeout_processor.py new file mode 100644 index 00000000..1ff5176e --- /dev/null +++ b/main/xiaozhi-server/core/processors/udp_timeout_processor.py @@ -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 diff --git a/main/xiaozhi-server/core/protocols/mqtt_connection.py b/main/xiaozhi-server/core/protocols/mqtt_connection.py new file mode 100644 index 00000000..2b53bf5b --- /dev/null +++ b/main/xiaozhi-server/core/protocols/mqtt_connection.py @@ -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}") diff --git a/main/xiaozhi-server/core/protocols/mqtt_protocol.py b/main/xiaozhi-server/core/protocols/mqtt_protocol.py new file mode 100644 index 00000000..20058eba --- /dev/null +++ b/main/xiaozhi-server/core/protocols/mqtt_protocol.py @@ -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() diff --git a/main/xiaozhi-server/core/servers/mqtt_server.py b/main/xiaozhi-server/core/servers/mqtt_server.py new file mode 100644 index 00000000..1c77d216 --- /dev/null +++ b/main/xiaozhi-server/core/servers/mqtt_server.py @@ -0,0 +1,1167 @@ +import asyncio +import ipaddress +import json +import secrets +import socket +import time +import weakref +from typing import Dict, Any, Set +from config.logger import setup_logging +from core.protocols.mqtt_connection import MQTTConnection +from core.transport.mqtt_transport import MQTTTransport, UDPAudioHandler +from core.services.connection_service import ConnectionService +from core.services.native_mqtt_connection_registry import ( + NativeMqttConnectionRegistry, +) +from core.services.native_mqtt_call_manager import NativeMqttCallManager +from core.utils.mqtt_auth import normalize_signature_key, parse_mqtt_endpoint +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.backends import default_backend + +logger = setup_logging() + + +class MQTTServer: + """ + 原生MQTT服务器:直接处理MQTT协议连接 + 集成到xiaozhi-server架构中 + """ + + def __init__(self, config: Dict[str, Any]): + self.config = config + self.logger = setup_logging() + + # 服务器配置 + server_config = config.get('mqtt_server', {}) + self.mqtt_port = server_config.get('port', 1883) + self.udp_port = server_config.get('udp_port', self.mqtt_port) + self.host = server_config.get('host', '0.0.0.0') + self.public_endpoint = self._resolve_public_host(server_config) + self.udp_bind_host = self.host + self._udp_bind_host_config = server_config.get('udp_bind_host') + self.signature_key = self._resolve_signature_key(config) + if not self.signature_key: + raise ValueError( + "启用原生MQTT必须配置mqtt_server.signature_key或" + "server.mqtt_signature_key" + ) + self.message_queue_size = int(server_config.get('message_queue_size', 128)) + self.business_ready_timeout = float( + server_config.get('business_ready_timeout', 30) or 30 + ) + self.close_timeout = max( + 0.1, float(server_config.get('close_timeout', 2) or 2) + ) + self.shutdown_timeout = max( + self.close_timeout, + float(server_config.get('shutdown_timeout', 10) or 10), + ) + self.goodbye_timeout = max( + 0.1, float(server_config.get('goodbye_timeout', 1) or 1) + ) + self.max_connections = int(server_config.get('max_connections', 1000)) + self.max_pending_connections = int( + server_config.get('max_pending_connections', 128) + ) + self.max_payload_size = int(server_config.get('max_payload_size', 8192)) + + # 连接管理 + self.connections: Dict[int, MQTTConnection] = {} + self.client_id_map: Dict[str, int] = {} + self.udp_handlers: Dict[int, UDPAudioHandler] = {} + self.connection_id_counter = 0 + self._client_id_lock = asyncio.Lock() + self._client_locks = weakref.WeakValueDictionary() + self._client_reservations: Dict[str, int] = {} + self._pending_connection_ids: Set[int] = set() + self._cleanup_tasks: Dict[int, asyncio.Task] = {} + self._draining_tasks: Set[asyncio.Task] = set() + self._connection_handler_tasks: Set[asyncio.Task] = set() + + # 服务器实例 + self.mqtt_server = None + self.udp_server = None + + # 连接服务 + self.connection_service = ConnectionService(config) + self.connection_service.server = self + + # 活跃连接管理 + self.active_transports: Set[MQTTTransport] = set() + self.connection_registry = NativeMqttConnectionRegistry() + self.call_manager = NativeMqttCallManager( + self.connection_registry, + timeout_seconds=server_config.get("call_timeout", 60), + silence_frame=self._create_silence_frame(), + ) + + # 心跳检查 + self.heartbeat_task = None + self.heartbeat_interval = int(server_config.get('heartbeat_interval', 30)) + self._stop_event = asyncio.Event() + self._started_event = asyncio.Event() + self._is_running = False + self._stopping = False + self._shutdown_lock = asyncio.Lock() + self._shutdown_task = None + + async def start(self): + """启动MQTT服务器""" + if self._is_running: + logger.warning("MQTT服务器已经在运行中") + return + + self._stop_event.clear() + self._started_event.clear() + self._stopping = False + try: + # 启动MQTT TCP服务器 + await self._start_mqtt_server() + + # 启动UDP服务器 + await self._start_udp_server() + + # 启动心跳检查 + self.heartbeat_task = asyncio.create_task(self._heartbeat_check()) + self._is_running = True + self._started_event.set() + + logger.info(f"MQTT服务器启动成功: {self.host}:{self.mqtt_port}") + logger.info(f"UDP服务器启动成功: {self.udp_bind_host}:{self.udp_port}") + + # 与 WebSocket 服务器保持一致,由上层持有该长期任务。 + await self._stop_event.wait() + + except Exception as e: + logger.error(f"启动MQTT服务器失败: {e}") + await self._await_shutdown_owner() + raise + finally: + self._is_running = False + self._started_event.clear() + + async def _start_mqtt_server(self): + """启动MQTT TCP服务器""" + self.mqtt_server = await asyncio.start_server( + self._accept_mqtt_connection, + self.host, + self.mqtt_port + ) + if self.mqtt_port == 0 and self.mqtt_server.sockets: + self.mqtt_port = self.mqtt_server.sockets[0].getsockname()[1] + + async def _start_udp_server(self): + """启动UDP服务器""" + loop = asyncio.get_event_loop() + + sock = None + bind_candidates = self._udp_bind_candidates() + for index, bind_host in enumerate(bind_candidates): + candidate = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + candidate.bind((bind_host, self.udp_port)) + except OSError as error: + candidate.close() + if index + 1 >= len(bind_candidates): + raise + logger.warning( + "UDP无法绑定到对外地址 {},回退监听 {}: {}", + bind_host, + bind_candidates[index + 1], + error, + ) + continue + sock = candidate + self.udp_bind_host = bind_host + break + + if sock is None: + raise RuntimeError("UDP socket bind failed") + sock.setblocking(False) + + # 创建UDP协议处理器 + transport, protocol = await loop.create_datagram_endpoint( + lambda: UDPProtocol(self), + sock=sock + ) + + self.udp_server = (transport, protocol) + if self.udp_port == 0: + self.udp_port = transport.get_extra_info("sockname")[1] + + def _udp_bind_candidates(self): + """Prefer a local advertised IPv4 address so UDP replies keep that source IP.""" + explicit_bind_host = str(self._udp_bind_host_config or "").strip() + if explicit_bind_host: + return [explicit_bind_host] + + wildcard_hosts = {"", "0.0.0.0"} + if self.host not in wildcard_hosts: + return [self.host] + + try: + public_ip = ipaddress.ip_address(self.public_endpoint) + except ValueError: + return [self.host] + + if public_ip.version == 4 and not public_ip.is_unspecified: + return [self.public_endpoint, self.host] + return [self.host] + + def _accept_mqtt_connection(self, reader, writer) -> None: + """Track every accepted callback before its coroutine can yield.""" + task = asyncio.create_task( + self._handle_mqtt_connection(reader, writer), + name="mqtt-connection-handler", + ) + self._connection_handler_tasks.add(task) + + def consume(completed_task): + self._connection_handler_tasks.discard(completed_task) + MQTTConnection._consume_background_task( + completed_task, "MQTT连接处理任务" + ) + + task.add_done_callback(consume) + + async def _handle_mqtt_connection(self, reader, writer): + """处理新的MQTT连接""" + if self._stopping: + await self._close_stream_writer(writer, "MQTT停服期间连接") + return + + if ( + self.max_pending_connections > 0 + and len(self._pending_connection_ids) >= self.max_pending_connections + ): + logger.warning( + "MQTT待认证连接数达到上限: {}", self.max_pending_connections + ) + await self._close_stream_writer(writer, "MQTT待认证连接") + return + + connection_id = self._generate_connection_id() + + try: + # 获取客户端地址 + client_addr = writer.get_extra_info('peername') + logger.info(f"新MQTT连接: {client_addr}, connection_id: {connection_id}") + + # 创建UDP音频处理器 + udp_handler = UDPAudioHandler( + connection_id, + self, + {}, # 加密配置将在hello消息中设置 + allowed_remote_ip=client_addr[0] if client_addr else None, + ) + + # 创建MQTT连接处理器 + mqtt_connection = MQTTConnection( + writer.get_extra_info('socket'), + connection_id, + self, + udp_handler, + reader=reader, + writer=writer + ) + mqtt_connection.business_task = asyncio.current_task() + udp_handler.set_audio_interceptor( + lambda payload, timestamp: self.call_manager.route_audio( + mqtt_connection.device_id, payload, timestamp + ) + ) + + # 创建MQTT传输层 + transport = MQTTTransport(mqtt_connection, udp_handler) + + # 注册连接 + self.connections[connection_id] = mqtt_connection + self.active_transports.add(transport) + self._pending_connection_ids.add(connection_id) + + try: + await asyncio.wait_for( + mqtt_connection.connect_processed_event.wait(), timeout=10 + ) + except asyncio.TimeoutError: + logger.warning(f"MQTT CONNECT超时: connection_id={connection_id}") + return + finally: + self._pending_connection_ids.discard(connection_id) + + if not mqtt_connection.connect_accepted: + return + + # 提取连接头信息 + headers = { + 'x-real-ip': client_addr[0] if client_addr else 'unknown', + 'connection-type': 'mqtt' + } + + try: + # 使用ConnectionService处理连接 + await self.connection_service.handle_connection(transport, headers) + + except Exception as e: + logger.error(f"ConnectionService处理MQTT连接失败: {e}") + + except Exception as e: + logger.error(f"处理MQTT连接失败: {e}") + finally: + # 清理连接 + await self._cleanup_connection(connection_id) + + async def _cleanup_connection(self, connection_id: int): + """Await the single cleanup owner without coupling it to caller cancellation.""" + if ( + connection_id not in self._cleanup_tasks + and not self._connection_has_resources(connection_id) + ): + return + cleanup_task = self._ensure_cleanup_task(connection_id) + + if cleanup_task is asyncio.current_task(): + return + await asyncio.shield(cleanup_task) + + def _ensure_cleanup_task(self, connection_id: int) -> asyncio.Task: + cleanup_task = self._cleanup_tasks.get(connection_id) + if cleanup_task is None: + cleanup_task = asyncio.create_task( + self._cleanup_connection_impl(connection_id), + name=f"mqtt-cleanup-{connection_id}", + ) + self._cleanup_tasks[connection_id] = cleanup_task + return cleanup_task + + def _connection_has_resources(self, connection_id: int) -> bool: + if connection_id in self._pending_connection_ids: + return True + if connection_id in self.connections: + return True + if connection_id in self.client_id_map.values(): + return True + if connection_id in self._client_reservations.values(): + return True + return any( + getattr( + getattr(transport, "_mqtt_connection", None), + "connection_id", + None, + ) + == connection_id + for transport in self.active_transports + ) + + async def _cleanup_connection_impl(self, connection_id: int): + """Physically release one connection before removing its registry entries.""" + connection = self.connections.get(connection_id) + transports_to_remove = [ + transport + for transport in list(self.active_transports) + if ( + hasattr(transport, '_mqtt_connection') + and transport._mqtt_connection.connection_id == connection_id + ) + ] + try: + self._pending_connection_ids.discard(connection_id) + if connection is not None: + if ( + connection.udp_config + and connection.is_connected() + and connection.reply_topic + ): + try: + await asyncio.wait_for( + connection.notify_device_idle(), + timeout=self.goodbye_timeout, + ) + except asyncio.TimeoutError: + logger.warning("MQTT连接关闭前发送goodbye超时") + except Exception as e: + logger.warning("MQTT连接关闭前发送goodbye失败: {}", e) + try: + await asyncio.wait_for( + connection.close(), timeout=self.close_timeout + ) + except asyncio.TimeoutError: + logger.warning( + "MQTT连接关闭超时,强制中止socket: {}", connection_id + ) + abort = getattr(connection.protocol, "abort", None) + if callable(abort): + abort() + close_task = getattr(connection, "_close_task", None) + if close_task is not None and not close_task.done(): + self.track_draining_task( + close_task, + f"MQTT连接关闭任务({connection_id})", + ) + + # UDP 使用每个 Hello 独立的随机 cookie,清理该物理连接的全部别名。 + udp_handler = connection.udp_handler if connection is not None else None + if udp_handler is not None: + for session_id, registered in list(self.udp_handlers.items()): + if registered is udp_handler: + self.udp_handlers.pop(session_id, None) + try: + await asyncio.wait_for( + udp_handler.close(), timeout=self.close_timeout + ) + except asyncio.TimeoutError: + logger.warning( + "MQTT UDP处理器关闭超时: {}", connection_id + ) + + for transport in transports_to_remove: + try: + await asyncio.wait_for( + transport.close(), timeout=self.close_timeout + ) + except asyncio.TimeoutError: + logger.warning("MQTT transport关闭超时: {}", connection_id) + abort = getattr( + getattr(transport, "_mqtt_connection", None), + "protocol", + None, + ) + abort = getattr(abort, "abort", None) + if callable(abort): + abort() + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"清理MQTT连接失败: {e}") + finally: + # A cleanup owner may be cancelled during process shutdown. Always + # force the physical socket closed before publishing registry removal. + if connection is not None: + abort = getattr(connection.protocol, "abort", None) + if callable(abort): + abort() + close_task = getattr(connection, "_close_task", None) + if close_task is not None and not close_task.done(): + self.track_draining_task( + close_task, + f"MQTT连接关闭任务({connection_id})", + ) + + for transport in transports_to_remove: + self.active_transports.discard(transport) + + async with self._client_id_lock: + for client_id, mapped_id in list(self.client_id_map.items()): + if mapped_id == connection_id: + self.client_id_map.pop(client_id, None) + for client_id, reserved_id in list( + self._client_reservations.items() + ): + if reserved_id == connection_id: + self._client_reservations.pop(client_id, None) + self.connections.pop(connection_id, None) + + current_task = asyncio.current_task() + if self._cleanup_tasks.get(connection_id) is current_task: + self._cleanup_tasks.pop(connection_id, None) + logger.info(f"MQTT连接清理完成: {connection_id}") + + async def _close_stream_writer(self, writer, label: str) -> None: + """Bound close for sockets rejected before MQTTConnection exists.""" + writer.close() + try: + await asyncio.wait_for( + writer.wait_closed(), timeout=self.close_timeout + ) + except asyncio.TimeoutError: + logger.warning("{}关闭超时,强制中止transport", label) + transport = getattr(writer, "transport", None) + if transport is not None: + transport.abort() + except Exception: + pass + + def track_draining_task(self, task: asyncio.Task, label: str) -> None: + """Keep cancellation-resistant work visible until it actually exits.""" + if task is None or task.done(): + if task is not None: + MQTTConnection._consume_background_task(task, label) + return + if task in self._draining_tasks: + return + self._draining_tasks.add(task) + + def consume(completed_task): + self._draining_tasks.discard(completed_task) + MQTTConnection._consume_background_task(completed_task, label) + + task.add_done_callback(consume) + + def _generate_connection_id(self) -> int: + """生成连接ID""" + self.connection_id_counter += 1 + return self.connection_id_counter + + async def _get_client_lock(self, client_id: str) -> asyncio.Lock: + async with self._client_id_lock: + lock = self._client_locks.get(client_id) + if lock is None: + lock = asyncio.Lock() + self._client_locks[client_id] = lock + return lock + + async def on_client_connected( + self, mqtt_connection: MQTTConnection, accept_callback + ) -> bool: + """Atomically replace one clientId before acknowledging the new owner.""" + client_id = mqtt_connection.client_id + if not client_id or self._stopping: + return False + + client_lock = await self._get_client_lock(client_id) + async with client_lock: + if mqtt_connection._closed or self._stopping: + return False + + reservation_active = False + async with self._client_id_lock: + old_id = self.client_id_map.get(client_id) + identity_count = len( + set(self.client_id_map) | set(self._client_reservations) + ) + if ( + old_id is None + and client_id not in self._client_reservations + and self.max_connections > 0 + and identity_count >= self.max_connections + ): + logger.warning( + "MQTT活跃客户端数达到上限,拒绝clientId: {}", client_id + ) + return False + self._client_reservations[client_id] = ( + mqtt_connection.connection_id + ) + reservation_active = True + try: + if old_id and old_id != mqtt_connection.connection_id: + logger.info(f"检测到重复clientId,关闭旧连接: {client_id}") + cleanup_task = self._ensure_cleanup_task(old_id) + try: + await asyncio.shield(cleanup_task) + except asyncio.CancelledError as cancelled: + # Keep the per-client lock and capacity reservation until + # the predecessor's physical close barrier has completed. + try: + await asyncio.shield(cleanup_task) + except Exception as cleanup_error: + logger.error( + "取消接管时等待旧连接清理失败: {}", cleanup_error + ) + raise cancelled + + if mqtt_connection._closed or self._stopping: + return False + + async with self._client_id_lock: + if ( + self._client_reservations.get(client_id) + != mqtt_connection.connection_id + ): + return False + self.client_id_map[client_id] = mqtt_connection.connection_id + self._client_reservations.pop(client_id, None) + reservation_active = False + + await accept_callback() + except BaseException: + async with self._client_id_lock: + if ( + self.client_id_map.get(client_id) + == mqtt_connection.connection_id + ): + self.client_id_map.pop(client_id, None) + raise + finally: + if reservation_active: + async with self._client_id_lock: + if ( + self._client_reservations.get(client_id) + == mqtt_connection.connection_id + ): + self._client_reservations.pop(client_id, None) + + logger.info(f"MQTT客户端已连接: {client_id}") + return True + + async def on_client_disconnected(self, mqtt_connection: MQTTConnection): + """客户端断开连接回调""" + logger.info(f"MQTT客户端已断开: {mqtt_connection.client_id}") + # Registry removal belongs to the physical cleanup owner. Scheduling + # rather than awaiting avoids a close -> disconnect -> cleanup cycle. + connection_id = mqtt_connection.connection_id + if ( + connection_id in self.connections + and connection_id not in self._cleanup_tasks + ): + self._ensure_cleanup_task(connection_id) + + async def register_connection_context(self, context, transport) -> bool: + existing = self.connection_registry.resolve_device_now( + getattr(context, "device_id", None) + ) + registered = await self.connection_registry.register( + context, transport + ) + if ( + registered + and existing is not None + and existing.transport is not transport + ): + await self.call_manager.end_call( + context.device_id, + "设备连接被接管", + notify_device=False, + notify_peer=True, + expected_session_id=getattr( + existing.transport, "session_id", None + ), + ) + try: + await existing.transport.close() + except Exception as error: + logger.warning( + "关闭被接管的MQTT连接失败: device_id={}, error={}", + context.device_id, + error, + ) + return registered + + async def unregister_connection_context(self, context, transport) -> bool: + removed = await self.connection_registry.unregister(context, transport) + if removed: + await self.call_manager.end_call( + context.device_id, + "设备连接已断开", + notify_device=False, + notify_peer=True, + expected_session_id=getattr(transport, "session_id", None), + ) + return removed + + async def resolve_connection_context(self, client_id: str): + return await self.connection_registry.resolve(client_id) + + async def get_connection_status(self, client_ids): + return await self.connection_registry.status(client_ids) + + async def request_device_call( + self, caller_mac: str, target_mac: str, caller_nickname: str = "" + ): + return await self.call_manager.request_call( + caller_mac, target_mac, caller_nickname + ) + + async def accept_device_call(self, device_id: str): + return await self.call_manager.accept_call(device_id) + + async def end_native_mqtt_call( + self, + device_id: str, + reason: str = "", + notify_device: bool = False, + expected_session_id: str = None, + expected_generation: int = None, + ) -> bool: + return await self.call_manager.end_call( + device_id, + reason, + notify_device=notify_device, + notify_peer=True, + expected_session_id=expected_session_id, + expected_generation=expected_generation, + ) + + async def handle_logical_hello(self, mqtt_connection) -> None: + await self.call_manager.handle_logical_hello( + mqtt_connection.device_id, + mqtt_connection.session_id, + ) + + def bind_udp_session( + self, mqtt_connection: MQTTConnection, udp_handler: UDPAudioHandler + ) -> int: + """Bind one unpredictable UDP cookie to the current Hello session.""" + for session_id, registered in list(self.udp_handlers.items()): + if registered is udp_handler: + self.udp_handlers.pop(session_id, None) + + while True: + session_id = secrets.randbits(32) + if session_id and session_id not in self.udp_handlers: + break + udp_handler.connection_id = session_id + self.udp_handlers[session_id] = udp_handler + return session_id + + async def send_udp_message(self, data: bytes, remote_addr: tuple): + """发送UDP消息""" + if not self.udp_server: + raise RuntimeError("UDP server is not running") + transport, protocol = self.udp_server + transport.sendto(data, remote_addr) + + async def send_encrypted_audio(self, connection_id: int, audio_data: bytes, + timestamp: int, sequence: int, remote_addr: tuple, + encryption_config: Dict[str, Any]): + """发送加密音频数据""" + self.send_encrypted_audio_nowait( + connection_id, + audio_data, + timestamp, + sequence, + remote_addr, + encryption_config, + ) + + def send_encrypted_audio_nowait( + self, + connection_id: int, + audio_data: bytes, + timestamp: int, + sequence: int, + remote_addr: tuple, + encryption_config: Dict[str, Any], + ): + header = self._generate_udp_header(connection_id, len(audio_data), timestamp, sequence) + key = encryption_config.get('key') if encryption_config else None + if key: + cipher = Cipher(algorithms.AES(key), modes.CTR(header), backend=default_backend()) + encryptor = cipher.encryptor() + encrypted = encryptor.update(audio_data) + encryptor.finalize() + message = header + encrypted + else: + message = header + audio_data + + if not self.udp_server: + raise RuntimeError("UDP server is not running") + transport, _ = self.udp_server + transport.sendto(message, remote_addr) + + def _generate_udp_header(self, connection_id: int, length: int, timestamp: int, sequence: int) -> bytes: + """生成UDP消息头""" + header = bytearray(16) + header[0] = 1 # type + header[2:4] = length.to_bytes(2, 'big') # payload length + header[4:8] = connection_id.to_bytes(4, 'big') # connection id + header[8:12] = timestamp.to_bytes(4, 'big') # timestamp + header[12:16] = sequence.to_bytes(4, 'big') # sequence + return bytes(header) + + async def _heartbeat_check(self): + """心跳检查任务""" + try: + while True: + await asyncio.sleep(self.heartbeat_interval) + + # 检查所有连接的状态 + dead_connections = [] + for connection_id, connection in self.connections.items(): + if not connection.is_connected(): + dead_connections.append(connection_id) + + # 清理死连接 + for connection_id in dead_connections: + logger.info(f"清理死连接: {connection_id}") + await self._cleanup_connection(connection_id) + + await self.call_manager.cleanup_timeouts() + + # 记录活跃连接数 + active_count = len(self.connections) + if active_count > 0: + logger.info(f"MQTT活跃连接数: {active_count}") + + except asyncio.CancelledError: + pass + except Exception as e: + logger.error(f"心跳检查任务出错: {e}") + + async def stop(self): + """停止MQTT服务器""" + async with self._shutdown_lock: + self._prune_finished_lifecycle_tasks() + shutdown_in_progress = ( + self._shutdown_task is not None + and not self._shutdown_task.done() + ) + if ( + not self._is_running + and self._shutdown_resources_released() + and not shutdown_in_progress + ): + self._stop_event.set() + return True + + logger.info("正在停止MQTT服务器...") + shutdown_task = self._ensure_shutdown_owner() + + shutdown_result = await asyncio.shield(shutdown_task) + # Existing lifecycle test doubles and older protocol servers return + # None on success. Only an explicit False means cleanup is partial. + complete = shutdown_result is not False + if complete: + logger.info("MQTT服务器已停止") + else: + status = self.get_server_status() + logger.error( + "MQTT监听器已停止,但仍有未退出任务: handlers={}, draining={}", + status["connection_handler_tasks"], + status["draining_tasks"], + ) + return complete + + def _ensure_shutdown_owner(self) -> asyncio.Task: + """Create or reuse the single physical shutdown owner.""" + if self._shutdown_task is None or self._shutdown_task.done(): + self._shutdown_task = asyncio.create_task( + self._run_shutdown_owner() + ) + return self._shutdown_task + + async def _await_shutdown_owner(self): + """Wait for shutdown without letting caller cancellation kill it.""" + return await asyncio.shield(self._ensure_shutdown_owner()) + + async def _run_shutdown_owner(self): + try: + return await self._shutdown_resources() + finally: + # A cancelled stop waiter must not leave start() blocked forever. + self._stop_event.set() + self._is_running = False + self._started_event.clear() + + def _prune_finished_lifecycle_tasks(self) -> None: + for connection_id, task in list(self._cleanup_tasks.items()): + if task.done() and self._cleanup_tasks.get(connection_id) is task: + self._cleanup_tasks.pop(connection_id, None) + for task in list(self._connection_handler_tasks): + if task.done(): + self._connection_handler_tasks.discard(task) + for task in list(self._draining_tasks): + if task.done(): + self._draining_tasks.discard(task) + + def _shutdown_resources_released(self) -> bool: + """Return true only when every MQTT-owned resource is gone.""" + heartbeat_alive = ( + self.heartbeat_task is not None + and not self.heartbeat_task.done() + ) + return not ( + self.mqtt_server + or self.udp_server + or heartbeat_alive + or self.connections + or self.client_id_map + or self.udp_handlers + or self.active_transports + or self._client_reservations + or self._pending_connection_ids + or self._cleanup_tasks + or self.connection_registry.count + or self.call_manager.count + or self.call_manager.background_task_count + or any( + not task.done() + for task in self._connection_handler_tasks + ) + or any(not task.done() for task in self._draining_tasks) + ) + + async def _shutdown_resources(self): + """幂等释放 MQTT/UDP 监听器、连接和后台任务。""" + self._stopping = True + loop = asyncio.get_running_loop() + deadline = loop.time() + self.shutdown_timeout + + # Stop accepting before taking any connection/task snapshot. + if self.mqtt_server: + self.mqtt_server.close() + await self.mqtt_server.wait_closed() + self.mqtt_server = None + # Let callbacks already queued by the event loop enter the synchronous + # tracking wrapper before handler/connection snapshots are taken. + await asyncio.sleep(0) + + if self.heartbeat_task and not self.heartbeat_task.done(): + self.heartbeat_task.cancel() + await asyncio.gather(self.heartbeat_task, return_exceptions=True) + self.heartbeat_task = None + + # Start every cleanup owner first, then wait against one global deadline. + connection_ids = ( + set(self.connections) + | set(self._cleanup_tasks) + | set(self.client_id_map.values()) + | set(self._client_reservations.values()) + | set(self._pending_connection_ids) + ) + connection_ids.update( + connection_id + for connection_id in ( + getattr( + getattr(transport, "_mqtt_connection", None), + "connection_id", + None, + ) + for transport in self.active_transports + ) + if connection_id is not None + ) + cleanup_tasks = [ + self._ensure_cleanup_task(connection_id) + for connection_id in connection_ids + if self._connection_has_resources(connection_id) + or connection_id in self._cleanup_tasks + ] + pending_cleanup = await self._wait_until_deadline( + cleanup_tasks, deadline + ) + if pending_cleanup: + logger.warning( + "MQTT停服清理超过全局时限,强制取消{}个清理任务", + len(pending_cleanup), + ) + for task in pending_cleanup: + task.cancel() + await asyncio.gather(*pending_cleanup, return_exceptions=True) + + # Accepted callbacks are tracked synchronously by _accept_mqtt_connection. + handler_tasks = [ + task + for task in self._connection_handler_tasks + if not task.done() + ] + for task in handler_tasks: + task.cancel() + pending_handlers = await self._wait_until_deadline( + handler_tasks, deadline + ) + for task in pending_handlers: + self.track_draining_task(task, "MQTT连接处理任务") + + draining_tasks = [ + task for task in self._draining_tasks if not task.done() + ] + for task in draining_tasks: + task.cancel() + pending_draining = await self._wait_until_deadline( + draining_tasks, deadline + ) + if pending_draining: + logger.warning( + "MQTT停服后仍有{}个取消不响应任务", len(pending_draining) + ) + + # Defensive closure for any transport whose connection registration was + # corrupted by an earlier failure. + transport_tasks = [ + asyncio.create_task(self._close_transport(transport)) + for transport in list(self.active_transports) + ] + pending_transports = await self._wait_until_deadline( + transport_tasks, deadline + ) + for task in pending_transports: + task.cancel() + if pending_transports: + await asyncio.gather( + *pending_transports, return_exceptions=True + ) + + # Close any orphaned UDP session left behind by a corrupted registry. + for udp_handler in set(self.udp_handlers.values()): + await udp_handler.close() + self.udp_handlers.clear() + + # 关闭UDP服务器 + if self.udp_server: + transport, protocol = self.udp_server + transport.close() + self.udp_server = None + + await self.call_manager.clear() + await self.connection_registry.clear() + self._prune_finished_lifecycle_tasks() + return self._shutdown_resources_released() + + @staticmethod + def _create_silence_frame(): + try: + import opuslib_next + + encoder = opuslib_next.Encoder( + 16000, 1, opuslib_next.APPLICATION_AUDIO + ) + return encoder.encode(bytes(960 * 2), 960) + except Exception as error: + logger.warning("Native MQTT通话静音帧初始化失败: {}", error) + return None + + async def _wait_until_deadline(self, tasks, deadline): + pending = {task for task in tasks if task is not None and not task.done()} + if not pending: + return set() + remaining = max(0.0, deadline - asyncio.get_running_loop().time()) + _, pending = await asyncio.wait(pending, timeout=remaining) + return pending + + async def _close_transport(self, transport) -> None: + try: + await asyncio.wait_for( + transport.close(), timeout=self.close_timeout + ) + except asyncio.TimeoutError: + connection = getattr(transport, "_mqtt_connection", None) + abort = getattr( + getattr(connection, "protocol", None), "abort", None + ) + if callable(abort): + abort() + finally: + self.active_transports.discard(transport) + + async def update_config(self, new_config: Dict[str, Any]) -> bool: + """更新非监听配置;监听地址变化由 MultiProtocolServer 重建实例。""" + try: + self.config = new_config + server_config = new_config.get('mqtt_server', {}) + self.public_endpoint = self._resolve_public_host(server_config) + self._udp_bind_host_config = server_config.get('udp_bind_host') + signature_key = self._resolve_signature_key(new_config) + if not signature_key: + raise ValueError( + "启用原生MQTT必须配置mqtt_server.signature_key或" + "server.mqtt_signature_key" + ) + self.signature_key = signature_key + self.message_queue_size = int(server_config.get('message_queue_size', 128)) + self.business_ready_timeout = float( + server_config.get('business_ready_timeout', 30) or 30 + ) + self.close_timeout = max( + 0.1, float(server_config.get('close_timeout', 2) or 2) + ) + self.shutdown_timeout = max( + self.close_timeout, + float(server_config.get('shutdown_timeout', 10) or 10), + ) + self.goodbye_timeout = max( + 0.1, float(server_config.get('goodbye_timeout', 1) or 1) + ) + self.max_connections = int(server_config.get('max_connections', 1000)) + self.max_pending_connections = int( + server_config.get('max_pending_connections', 128) + ) + self.max_payload_size = int(server_config.get('max_payload_size', 8192)) + self.heartbeat_interval = int(server_config.get('heartbeat_interval', 30)) + + # 已建立连接保留会话快照,新连接使用新配置。 + self.connection_service = ConnectionService(new_config) + self.connection_service.server = getattr(self, "management_owner", self) + return True + except Exception as e: + logger.error(f"更新MQTT服务器配置失败: {e}") + return False + + @staticmethod + def _resolve_signature_key(config: Dict[str, Any]) -> str: + server_config = config.get('mqtt_server', {}) + return ( + normalize_signature_key(server_config.get('signature_key')) + or normalize_signature_key( + config.get('server', {}).get('mqtt_signature_key') + ) + ) + + @staticmethod + def _resolve_public_host(server_config: Dict[str, Any]) -> str: + host, _ = parse_mqtt_endpoint( + server_config.get('public_endpoint'), + None, + ) + return host or 'localhost' + + async def apply_config(self, new_config: Dict[str, Any]) -> bool: + return await self.update_config(new_config) + + def get_server_status(self) -> Dict[str, Any]: + """获取服务器状态""" + return { + 'type': 'mqtt', + 'host': self.host, + 'mqtt_port': self.mqtt_port, + 'udp_port': self.udp_port, + 'active_connections': len(self.connections), + 'active_transports': len(self.active_transports), + 'pending_connections': len(self._pending_connection_ids), + 'cleanup_tasks': len(self._cleanup_tasks), + 'draining_tasks': sum( + not task.done() for task in self._draining_tasks + ), + 'connection_handler_tasks': sum( + not task.done() + for task in self._connection_handler_tasks + ), + 'stopping': self._stopping, + } + + +class UDPProtocol(asyncio.DatagramProtocol): + """UDP协议处理器""" + + def __init__(self, mqtt_server: MQTTServer): + self.mqtt_server = mqtt_server + self.transport = None + + def connection_made(self, transport): + self.transport = transport + + def datagram_received(self, data: bytes, addr: tuple): + """接收UDP数据报""" + try: + # 解析UDP消息头 + if len(data) < 16: + return + + packet_type = data[0] + if packet_type != 1: + return + + payload_length = int.from_bytes(data[2:4], 'big') + if len(data) != 16 + payload_length: + logger.warning( + "UDP数据报长度不匹配: actual={}, expected={}", + len(data), + 16 + payload_length, + ) + return + + connection_id = int.from_bytes(data[4:8], 'big') + timestamp = int.from_bytes(data[8:12], 'big') + sequence = int.from_bytes(data[12:16], 'big') + header = data[:16] + encrypted_payload = data[16:16 + payload_length] + + # 找到对应的UDP处理器 + udp_handler = self.mqtt_server.udp_handlers.get(connection_id) + if udp_handler: + udp_handler.on_udp_message(header, encrypted_payload, payload_length, timestamp, sequence, addr) + + except Exception as e: + logger.error(f"处理UDP数据报失败: {e}") + + def error_received(self, exc): + logger.error(f"UDP协议错误: {exc}") diff --git a/main/xiaozhi-server/core/servers/multi_protocol_server.py b/main/xiaozhi-server/core/servers/multi_protocol_server.py index 9720f505..e0c4938e 100644 --- a/main/xiaozhi-server/core/servers/multi_protocol_server.py +++ b/main/xiaozhi-server/core/servers/multi_protocol_server.py @@ -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: """检查协议是否启用""" diff --git a/main/xiaozhi-server/core/services/connection_service.py b/main/xiaozhi-server/core/services/connection_service.py index 4693a602..fa6e34d7 100644 --- a/main/xiaozhi-server/core/services/connection_service.py +++ b/main/xiaozhi-server/core/services/connection_service.py @@ -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.""" diff --git a/main/xiaozhi-server/core/services/native_mqtt_call_manager.py b/main/xiaozhi-server/core/services/native_mqtt_call_manager.py new file mode 100644 index 00000000..fc80deb5 --- /dev/null +++ b/main/xiaozhi-server/core/services/native_mqtt_call_manager.py @@ -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, + ) diff --git a/main/xiaozhi-server/core/services/native_mqtt_connection_registry.py b/main/xiaozhi-server/core/services/native_mqtt_connection_registry.py new file mode 100644 index 00000000..7dd17bb1 --- /dev/null +++ b/main/xiaozhi-server/core/services/native_mqtt_connection_registry.py @@ -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 diff --git a/main/xiaozhi-server/core/transport/mqtt_transport.py b/main/xiaozhi-server/core/transport/mqtt_transport.py new file mode 100644 index 00000000..61e14e43 --- /dev/null +++ b/main/xiaozhi-server/core/transport/mqtt_transport.py @@ -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 diff --git a/main/xiaozhi-server/core/utils/mqtt_auth.py b/main/xiaozhi-server/core/utils/mqtt_auth.py new file mode 100644 index 00000000..9359628e --- /dev/null +++ b/main/xiaozhi-server/core/utils/mqtt_auth.py @@ -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("密码签名验证失败") diff --git a/main/xiaozhi-server/core/xiaozhi_server_facade.py b/main/xiaozhi-server/core/xiaozhi_server_facade.py index feb424b3..c05c3afd 100644 --- a/main/xiaozhi-server/core/xiaozhi_server_facade.py +++ b/main/xiaozhi-server/core/xiaozhi_server_facade.py @@ -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() }